Input Dispatch
Definitive specification for the 30-key input system plus the TERM context-sensitive key: physical layout, hardware, functional semantics, nOSh runtime services, and cartridge SDK.
See CLAUDE.md Canonical Hardware Specification for the canonical keyboard count and TERM row entry.
1. Physical Layout
Section titled “1. Physical Layout”The KN-86 Deckline has 30 primary keys arranged in two distinct grids separated by the amber LCD, plus a TERM key whose binding is determined by the nOSh runtime per context (§3C and §6D). The operator’s left hand works the function grid (14 keys) and the right hand works the data grid (16 keys). Two positions in the 8×4 primary matrix are empty; the TERM key sits on a dedicated switch to the right of the function grid’s fourth row, wired into the matrix at one of the two previously-empty positions (see §1A below).
╔═══════════════════════════════════════╗ ╔═══════════════════════════════════════╗║ LEFT HAND — FUNCTION GRID (14 keys) ║ ║ RIGHT HAND — DATA GRID (16 keys) ║╠═══════════╤═══════════╤═══════╤══════╣ ╠════════╤════════╤════════╤════════════╣║ QUOTE │ CONS │ NIL │ λ ║ ║ 1 │ 2 │ 3 │ ÷ ║║ (defer) │ (build) │ (nil) │(func)║ ║ │ │ │ (divide) ║╠═══════════╪═══════════╪═══════╪══════╣ ╠════════╪════════╪════════╪════════════╣║ INFO │ CAR │ APPLY │ SYS ║ ║ 4 │ 5 │ 6 │ × ║║ (scan) │ (first) │ (use) │(menu)║ ║ │ │ │ (multiply) ║╠═══════════╪═══════════╪═══════╪══════╣ ╠════════╪════════╪════════╪════════════╣║ LINK │ BACK │ CDR │ ATOM ║ ║ 7 │ 8 │ 9 │ − ║║ (link) │ (up) │(rest) │(leaf)║ ║ │ │ │ (subtract) ║╠═══════════╧═══════════╪═══════╪══════╣ ╠════════╪════════╪════════╪════════════╣║ ════ EVAL (3U) ════ │ EQ │░░░░░░║ ║ . │ 0 │ ENT │ + ║║ (execute / commit) │(equal)│░empty║ ║ (point)│ │(enter) │ (add) ║╚═══════════════════════╧═══════╧══════╝ ╚════════╧════════╧════════╧════════════╝Data grid layout is phone-style (1-2-3 top, 0 bottom-center), not calculator-style. See §3D and ADR-0016 §5 for the rationale — letters on the Nokia multi-tap alpha entry (§6 below, and ADR-0016 §6) attach to phone-position muscle memory (2=ABC, 3=DEF, …), so the phone layout is a prerequisite for that input model. Arithmetic column (÷, ×, −, +) and ENT position are unchanged from earlier drafts.
Grid Topology
Section titled “Grid Topology”| Property | Function Grid (Left) | Data Grid (Right) |
|---|---|---|
| Keys | 14 | 16 |
| Rows × Cols | 4 × 4 (minus 2 empties) | 4 × 4 |
| Physical grouping | Semantic clusters by color | Phone-layout numpad (ADR-0016 §5) |
| Keycap legends | Lisp primitives + verbs | Numerals + arithmetic |
| EVAL key | 3U wide (spans cols 1-3, row 4) | — |
Matrix Positions
Section titled “Matrix Positions”The 8×4 matrix scans all 32 intersections. Two are unpopulated:
| Row | Col 0 | Col 1 | Col 2 | Col 3 | Col 4 | Col 5 | Col 6 | Col 7 |
|---|---|---|---|---|---|---|---|---|
| 0 | QUOTE | CONS | NIL | LAMBDA | PAD_1 | PAD_2 | PAD_3 | PAD_DIV |
| 1 | INFO | CAR | APPLY | SYS | PAD_4 | PAD_5 | PAD_6 | PAD_MUL |
| 2 | LINK | BACK | CDR | ATOM | PAD_7 | PAD_8 | PAD_9 | PAD_SUB |
| 3 | EVAL | (empty) | EQ | (empty) | PAD_DOT | PAD_0 | PAD_ENTER | PAD_ADD |
Note: EVAL occupies a 3U keycap centered over Col 0, but electrically connects to the single matrix intersection at (3,0). Of the two previously-empty positions (3,1) and (3,3), (3,3) is populated as the TERM key (context-sensitive, see §3C and §6D); (3,1) remains unpopulated.
1A. TERM Key Placement
Section titled “1A. TERM Key Placement”TERM is a 1U key seated to the immediate right of the EVAL bar, on the same row as the EVAL footprint. The keycap legend reads TERM in gray (same treatment as SYS and INFO). Electrically it occupies matrix position (3,3). Its row-4 placement, right of EVAL, makes it the only non-data-grid key beneath the function cluster; this is deliberate — the operator’s left thumb can reach it without leaving home position, and it sits opposite SYS (on the top-right of the function grid), making the function-grid’s system-layer affordances symmetric.
2. Hardware
Section titled “2. Hardware”Switches
Section titled “Switches”| Property | Value |
|---|---|
| Type | Kailh Choc v1 (PG1350) |
| Variant | White (Clicky, 50gf) or Jade (Heavy Clicky, 60gf) |
| Travel | 3mm total, 1.5mm pre-travel to actuation |
| Rated lifespan | 50 million actuations |
| Mounting | PCB-mount, hot-swap sockets (Mill-Max 0305 or Kailh hot-swap) |
| Quantity | 30 populated + 5 spares = 35 switches |
The audible click is non-negotiable. The design document calls it “Deckline Chatter” — the sound of the device being operated is part of the fiction. Silent switches would undermine the experience.
Keycaps
Section titled “Keycaps”| Property | Value |
|---|---|
| Profile | MBK (low-profile, uniform, Choc v1 compatible) |
| Material | PBT |
| Legend method | Dye-sublimation or UV printing |
| EVAL key | 3U stabilized keycap |
Color coding by legend category:
| Row / Category | Legend Color | Keys |
|---|---|---|
| Lisp Primitives | Amber | CAR, CDR, CONS, NIL, ATOM, EQ |
| Action Verbs | White | EVAL, QUOTE, LAMBDA, APPLY |
| System / Navigation | Red | LINK, BACK |
| nOSh runtime | Gray | INFO, SYS |
| Numpad | White | All 16 data grid keys |
Scanning (Device: custom mech keeb → USB HID)
Section titled “Scanning (Device: custom mech keeb → USB HID)”The 30-key input is built as a custom mechanical keyboard per ADR-0018. A QMK-compatible keyboard controller (Pro Micro ATmega32U4, RP2040-class such as Sea-Picro / KB2040, nice!nano, or equivalent) scans the 8×4 matrix in hardware, applies debounce, and enumerates to the Pi Zero 2 W as a standard USB HID keyboard. The controller connects to the Pi through an internal USB hub IC on the interior plate — no external USB cables. nOSh reads keypresses via Linux evdev (/dev/input/event*).
| Property | Value |
|---|---|
| Matrix topology | 8 column + 4 row = 32 intersections, 30 populated |
| Controller | QMK-compatible (ATmega32U4, RP2040-class, or equivalent); exact part chosen at hardware bring-up |
| Firmware | Stock QMK (or Vial on QMK). Firmware-layer features (tap-dance, layers, combos) are not used — LAMBDA / QUOTE / SYS-hold / TERM semantics all live in nOSh (§6) |
| Scan path | Keyboard controller (QMK) → USB HID → internal USB hub → Pi OTG → Linux evdev → nOSh |
| Effective scan rate | ≥ 1 kHz at the controller; polled by nOSh at input-dispatch time |
| Debounce | 10 ms software window, controller-side |
| Key state storage | 32-bit bitmask (30 bits used) |
PCB path (ADR-0018)
Section titled “PCB path (ADR-0018)”Two acceptable physical realizations of the 30-key matrix:
- Custom-fab unified 30-key PCB (preferred). A purpose-designed board carrying hot-swap Choc v1 sockets, 1N4148 SMD diodes, and the controller (socketed or onboard). Fabbed at JLCPCB / OshPark / PCBWay; ~$30–$60 for 5 boards; ~2-week design + fab cycle. Matches the device’s cohesive identity — single plate, single controller, clean internal routing.
- Modified split layout, reconnected internally (fallback). A pair of split-ergo PCBs (Corne, Ferris Sweep) wired to a single controller under a unified keyplate. Populate only the 30 logical keys; leave unused footprints empty under the plate. Zero-fab path when custom-PCB lead time is unacceptable. Aesthetically compromised (two PCBs under one plate, visible from the inside) but functionally identical.
Either path delivers the same logical 30-key scancode set to the controller, and the controller delivers the same USB HID event stream to the Pi. See ADR-0018 for the full trade-off analysis and options considered.
Scanning (Emulator: SDL2)
Section titled “Scanning (Emulator: SDL2)”The emulator translates SDL keyboard scancodes to KN86 key codes via a 512-element lookup array. No physical matrix exists — the mapping is software-only.
3. The 30 Keys — Complete Reference
Section titled “3. The 30 Keys — Complete Reference”3A. Lisp Primitive Keys (Amber Legends)
Section titled “3A. Lisp Primitive Keys (Amber Legends)”These six keys implement genuine Lisp list operations. Every data structure in every cartridge module is a nested recursive list, and these keys are how the operator traverses and manipulates it.
CAR — “First Element” (Value 5)
Section titled “CAR — “First Element” (Value 5)”| Property | Detail |
|---|---|
| Lisp origin | (car '(a b c)) → a |
| KN-86 meaning | Drill into / examine the first child |
| Default behavior | stdlib_drill_into() — push first_child onto nav stack |
| If leaf (no children) | Error beep (stdlib_sfx_error()) |
| Cognitive role | OODA → Orient (investigate what’s in front of you) |
| Mnemonics | ”Contents of Address Register” — look inside |
Per-module examples:
- ICE Breaker: Enter selected network node; inspect ICE details
- Depthcharge: Descend one fathom bracket; examine sonar contact
- Black Ledger: Drill into transaction; open sub-ledger
- NeonGrid: Move forward into corridor/room
CDR — “Rest of List” (Value 10)
Section titled “CDR — “Rest of List” (Value 10)”| Property | Detail |
|---|---|
| Lisp origin | (cdr '(a b c)) → (b c) |
| KN-86 meaning | Traverse to next sibling |
| Default behavior | stdlib_next_sibling() — move to next_sibling pointer |
| If no next sibling | Error beep |
| Cognitive role | OODA → Orient (scan options laterally) |
| Mnemonics | ”Contents of Decrement Register” — advance through list |
Per-module examples:
- Mission board: Scroll to next available contract
- ICE Breaker: Move to adjacent network node
- Black Ledger: Next transaction in ledger
- Depthcharge: Pan sonar sweep to next bearing
CONS — “Construct Pair” (Value 1)
Section titled “CONS — “Construct Pair” (Value 1)”| Property | Detail |
|---|---|
| Lisp origin | (cons 'a '(b c)) → (a b c) |
| KN-86 meaning | Attach / combine / construct |
| Default behavior | None (module-specific) |
| Cognitive role | OODA → Act (build something from parts) |
| Mnemonics | ”Construct” — join things together |
Per-module examples:
- ICE Breaker: Attach tool/exploit to target node
- Black Ledger: Link two transactions as related entries
- SynthFence: Combine buy/sell orders into a paired position
- Depthcharge: Attach depth charge to bearing
NIL — “Empty List” (Value 2)
Section titled “NIL — “Empty List” (Value 2)”| Property | Detail |
|---|---|
| Lisp origin | nil / '() — the empty list |
| KN-86 meaning | Discard / clear / cancel / reset |
| Default behavior | None (module-specific) |
| Cognitive role | Abort/undo current partial action |
| Mnemonics | ”Nothing” — make it empty |
Per-module examples:
- ICE Breaker: Discard selected tool, cancel deployment
- NeonGrid: Clear current path markers
- Black Ledger: Void flagged transaction
- General: Cancel any in-progress CONS operation
ATOM — “Is It a Leaf?” (Value 11)
Section titled “ATOM — “Is It a Leaf?” (Value 11)”| Property | Detail |
|---|---|
| Lisp origin | (atom 'a) → T; (atom '(a b)) → NIL |
| KN-86 meaning | Test if current element is a leaf (no children) |
| Default behavior | stdlib_is_leaf() — returns true/false, often with audio cue |
| Cognitive role | OODA → Observe (is there more depth here, or is this terminal?) |
| Mnemonics | ”Atomic?” — can I drill further? |
Per-module examples:
- ICE Breaker: Is this node a terminal? (no sub-processes)
- Depthcharge: Is this contact resolved? (no further analysis possible)
- Black Ledger: Is this a single entry? (no sub-transactions)
- General: Useful before CAR — tells you whether drilling will work
EQ — “Are They Equal?” (Value 13)
Section titled “EQ — “Are They Equal?” (Value 13)”| Property | Detail |
|---|---|
| Lisp origin | (eq 'a 'a) → T |
| KN-86 meaning | Compare two quoted/bookmarked elements |
| Default behavior | The nOSh runtime compares current element against last QUOTE’d reference |
| Cognitive role | OODA → Orient (spot differences, find matches) |
| Mnemonics | ”Equal?” — are these the same? |
Per-module examples:
- Black Ledger: Compare two transactions for discrepancies
- ICE Breaker: Compare network node signatures
- Cipher Garden: Test if decrypted output matches expected plaintext
- General: Bookmark with QUOTE, navigate elsewhere, press EQ to compare
3B. Action Keys (White Legends)
Section titled “3B. Action Keys (White Legends)”EVAL — “Execute” (Value 12)
Section titled “EVAL — “Execute” (Value 12)”| Property | Detail |
|---|---|
| Lisp origin | (eval '(+ 1 2)) → 3 |
| KN-86 meaning | Execute / confirm / commit action |
| Physical note | 3U wide key — the largest and most prominent key on the deck |
| Cognitive role | OODA → Act (commit your decision, trigger resolution) |
| Tempo role | The “action clock tick” — every EVAL press advances the game state |
Per-module examples:
- Mission board: Accept selected contract
- ICE Breaker: Execute deployed tool against target
- Depthcharge: Release depth charge at current bearing/depth
- Bare deck: Confirm handle entry, select menu item
QUOTE — “Defer / Bookmark” (Value 0)
Section titled “QUOTE — “Defer / Bookmark” (Value 0)”| Property | Detail |
|---|---|
| Lisp origin | (quote x) / 'x — return x without evaluating |
| KN-86 meaning | Bookmark current element for later reference |
| nOSh runtime service | Press QUOTE → QUO? prompt → numpad 1-8 → Cell ID stored in quote slot |
| Storage | 8 slots in SRAM (volatile across power-off, persistent within session) |
| Semantics | Stores a reference, not a snapshot (like Lisp quote) |
Per-module examples:
- Black Ledger: Bookmark suspicious transaction for comparison
- ICE Breaker: Mark a node for later EQ comparison
- General: Navigate away, then recall via QUOTE + digit
LAMBDA — “Define Macro” (Value 3)
Section titled “LAMBDA — “Define Macro” (Value 3)”| Property | Detail |
|---|---|
| Lisp origin | (lambda (x) (* x x)) — anonymous function |
| KN-86 meaning | Record a sequence of keystrokes for replay |
| nOSh runtime service | Hold LAMBDA 2s → λREC indicator → press keys → press LAMBDA to stop → numpad 1-8 to assign |
| Playback | Tap LAMBDA → λ? → numpad 1-8 → the nOSh runtime replays sequence |
| Quick invoke | LAMBDA + digit simultaneously → instant playback |
| Storage | 8 slots × 32 key events max = 256 bytes in wear-leveled flash |
| Transparency | Replayed events enter the input queue indistinguishably from live input |
Use cases:
- Black Ledger: Record a 5-key audit sequence, replay across hundreds of entries
- ICE Breaker: Record a reconnaissance pattern (INFO→CDR→CDR→CAR), replay at each node
- General: Any repetitive multi-key operation worth automating
APPLY — “Deploy Tool” (Value 6)
Section titled “APPLY — “Deploy Tool” (Value 6)”| Property | Detail |
|---|---|
| Lisp origin | (apply fn args) — apply function to arguments |
| KN-86 meaning | Deploy / use selected tool against current target |
| Cognitive role | OODA → Decide → Act bridge (choose tool, then EVAL confirms) |
Per-module examples:
- ICE Breaker: Deploy exploit tool against ICE node
- Depthcharge: Apply sonar processing filter
- Shellfire: Deploy countermeasure against incoming signal
- General: Often paired with EVAL — APPLY selects, EVAL commits
3C. System / Navigation Keys (Red + Gray Legends)
Section titled “3C. System / Navigation Keys (Red + Gray Legends)”BACK — “Navigate Up” (Value 9)
Section titled “BACK — “Navigate Up” (Value 9)”| Property | Detail |
|---|---|
| KN-86 meaning | Pop navigation stack — return to parent context |
| Default behavior | stdlib_navigate_back() — pop nav stack, call on_exit/on_enter |
| Legend color | Red |
| nOSh runtime note | If nav stack is empty at runtime level (depth 0), BACK is ignored |
INFO — “Inspect” (Value 4)
Section titled “INFO — “Inspect” (Value 4)”| Property | Detail |
|---|---|
| KN-86 meaning | Show detailed information about current element |
| Legend color | Gray |
| Cognitive role | OODA → Observe (gather intelligence before acting) |
Per-module examples:
- ICE Breaker: Display adjacency list, threat level, ICE type
- Mission board: Show full contract details (difficulty, payout, reputation)
- Black Ledger: Show transaction metadata, timestamp, parties
LINK — “Initiate Link” (Value 8)
Section titled “LINK — “Initiate Link” (Value 8)”| Property | Detail |
|---|---|
| KN-86 meaning | Initiate a link operation between elements |
| Legend color | Red |
| nOSh runtime note | LINK protocol is module-defined — the nOSh runtime passes through |
Per-module examples:
- ICE Breaker: Establish connection between network nodes
- Relay: Initiate system image update handshake
- Depthcharge: Link sonar contact to classification
SYS — “System Menu” (Value 7)
Section titled “SYS — “System Menu” (Value 7)”| Property | Detail |
|---|---|
| KN-86 meaning | Tap: open system menu. Hold 2s: hard abort |
| Legend color | Gray |
| Tap behavior | Context menu (brightness, volume, deck status, key test) |
| Hold behavior (2s) | Force-quit to boot screen (replaces ESC — there is no ESC key) |
| nOSh runtime note | SYS hold is the emergency exit. Always available, never overridable by cartridges |
TERM — “Terminal / Context-Sensitive” (Value 10)
Section titled “TERM — “Terminal / Context-Sensitive” (Value 10)”| Property | Detail |
|---|---|
| KN-86 meaning | Context-sensitive — the meaning is determined by current deck state |
| Legend color | Gray |
| Default behavior (no special mode) | Open the terminal view — bare deck REPL if in :bare-deck beat, otherwise a cart-contextual terminal if the cart registers one; otherwise a no-op with a short audio bounce |
| Mode-sensitive behaviors | See §3C.1 below |
| nOSh runtime note | TERM is nOSh-runtime-owned at the mode layer — the nOSh runtime decides which binding fires based on current CIPHER-LINE state, cart beat, and hold duration. Cartridges cannot silently steal TERM; they may request a cart-scoped binding via (term-register-binding :tag :binding-id :label "...") and the nOSh runtime honors it only when no higher-priority mode is active. |
3C.1 TERM Context-Sensitivity Table
Section titled “3C.1 TERM Context-Sensitivity Table”TERM is the only key on the deck whose binding is allowed to change across contexts. This is intentional: CIPHER-LINE introduced a surface that needs a dedicated capture affordance, and it made sense to bind it to the one uncommitted key rather than shadow a Lisp primitive. The table below is canonical; other behaviors may be added by future cartridge registrations, but every addition must fit this model.
| Priority | Context | TERM binding | Label shown on CIPHER-LINE Row 4 |
|---|---|---|---|
| 1 (highest) | CIPHER-LINE seed-capture mode active (set by (aux-show-seed ...)) | Capture seed. Persist the displayed seed to Universal Deck State; exit seed-capture mode. | TERM: CAPTURE |
| 2 | Hot-swap prompt active; nOSh runtime awaiting operator confirmation | Skip swap. Defer swap and put the mission on hold (same as QUOTE-hold; provides a second, ambidextrous path). | TERM: DEFER |
| 3 | Null module, Cipher-Analysis bounty active | Freeze Cipher state. Snapshot current coherence stack and memory-store for analysis; the nOSh runtime renders the snapshot on the main grid. | TERM: FREEZE |
| 4 | Cart has registered a cart-scoped binding and the cart is the active cart | Cart-defined behavior. Cart provides the label; the nOSh runtime renders it on Row 4 unless a higher-priority context overrides. | TERM: <cart-label> |
| 5 | Bare deck, no cart inserted | Open bare-deck terminal REPL. Toggle the main-grid into the nOSh runtime REPL (nEmacs-minor-mode-style). | TERM: REPL |
| 6 (lowest) | None of the above | No-op. Short audio bounce (400 Hz, 50ms). CIPHER-LINE Row 4 is unchanged. | (no override) |
Priority is evaluated top-down at key-press time. The nOSh runtime determines the binding; the cart cannot see a TERM press when a higher-priority binding is active. This is a deliberate asymmetry from the other system keys (SYS, BACK) — TERM is the operator’s handshake with nOSh runtime modes.
3C.2 TERM Hold Behavior
Section titled “3C.2 TERM Hold Behavior”TERM can also be held (≥500 ms). Hold behavior is always the same regardless of context: toggle CIPHER-LINE mute. The OLED backlight stays lit and Row 1 still renders, but Rows 2–3 (Cipher scrollback) blank and the engine suspends emission until TERM is held again. This is the operator’s physical kill switch for the Cipher voice — useful when concentration is wanted or when the operator is recording a session for the community.
Mute state is stored in Universal Deck State (cipher_muted flag, 1 bit) and survives power cycles. Row 4 shows CIPHER: MUTED while muted.
3D. Data Grid (White Numeral Legends)
Section titled “3D. Data Grid (White Numeral Legends)”The 16-key numpad on the right side is a data grid, not a directional pad. It serves four purposes depending on the module context. The critical design principle: digits are data first, direction second.
Numeric Data Entry (Primary)
Section titled “Numeric Data Entry (Primary)”When a module expects numeric input (handle entry, Cipher puzzle answers, LFSR predictions, bearing coordinates), the numpad provides direct digit input 0-9. The arithmetic keys (÷, ×, −, +) serve as additional data keys or operators in some contexts.
Directional / Spatial Interpretation (Module-Optional)
Section titled “Directional / Spatial Interpretation (Module-Optional)”Modules that need steering, heading, or spatial input MAY interpret the numpad’s physical layout as direction. With the phone-style layout (§1, ADR-0016 §5), 1-2-3 sit on the top row and 7-8-9 sit on the third row, so the cardinal and diagonal assignments track the physical topology:
1 = NW 3 = NE (top-row diagonals) 2 ↑ 4 ← 5 → 6 5 = center / confirm / hold position ↓ 8
7 = SW 9 = SE (third-row diagonals)2= north (top-center of the numpad grid)8= south (bottom-of-digits row)4= west,6= east (middle row)1/3= top-row (NW / NE) diagonals7/9= third-row (SW / SE) diagonals5= center / confirm / hold position
This is not a nOSh runtime feature — the nOSh runtime always delivers raw digit values via on_numpad(self, digit). Directional interpretation is a module-level convention. A module’s on_numpad handler decides whether 2 means “the number two” or “steer north.”
Modules that use directional numpad:
- Depthcharge: 2/4/6/8 sets sonar bearing; digit = heading in 45° increments. Pressing 2 means “bearing north,” not “two.”
- Drift: 2/4/6/8 steers antenna heading for RF triangulation. Speed/sensitivity on 1-9 scale.
- NeonGrid: 2/4/6/8 moves through grid corridors. This is the most d-pad-like usage.
- Shellfire: 2/4/6/8 rotates countermeasure array. Digit magnitude sets intensity.
Modules that use pure numeric numpad:
- Black Ledger: Digits enter account numbers, transaction amounts. No directional meaning.
- Cipher Garden: Digits enter decryption key guesses. Pure numeric.
- The Vault: Digits index knowledge entries. Pure numeric.
- Bare Deck: Digits enter operator handle characters, LFSR predictions.
Hybrid usage is valid. A module can use 2/4/6/8 for navigation within one cell type and 0-9 for data entry within another. The interpretation is per-cell, not per-module. The on_numpad handler for a “heading_selector” cell reads 2 as north; the on_numpad handler for a “coordinate_entry” cell reads 2 as the digit two.
Design rule: When a module uses directional numpad, the digit’s numeric value should still make spatial sense in the current phone layout. 2 = north (top-center), 8 = south, 4 = west, 6 = east, 5 = center. Don’t remap digits to arbitrary directions. The physical topology of the numpad IS the map.
Migration note for module authors: The phone layout inverts the vertical axis compared to calculator-layout conventions that appear in earlier drafts. Modules authored against the old convention (8 = north, 2 = south) must swap their north/south digit assignments when re-targeting the phone layout. Corresponding gameplay-spec revisions are tracked under the GWP-217 wave (Launch-titles gameplay-spec revision for phone-style numpad).
Lambda/Quote Slot Selection
Section titled “Lambda/Quote Slot Selection”After LAMBDA or QUOTE prompt, numpad 1-8 selects the slot. This is a runtime-level function — cartridges never see these keypresses during slot selection.
Value Adjustment
Section titled “Value Adjustment”In SYS menu and module configuration, PAD_ADD and PAD_SUB increment/decrement values. Numpad digits can set values directly (e.g., pressing 7 sets volume to 70%). The arithmetic operator keys (÷, ×, −, +) can serve as modifier/mode keys in module-specific contexts.
Numpad Key Reference
Section titled “Numpad Key Reference”| Key | Value | Label | Primary Role |
|---|---|---|---|
| PAD_7 | 14 | 7 | Digit / slot select |
| PAD_8 | 15 | 8 | Digit / slot select |
| PAD_9 | 16 | 9 | Digit |
| PAD_DIV | 17 | ÷ | Context-specific |
| PAD_4 | 18 | 4 | Digit / slot select |
| PAD_5 | 19 | 5 | Digit / slot select |
| PAD_6 | 20 | 6 | Digit / slot select |
| PAD_MUL | 21 | × | Context-specific |
| PAD_1 | 22 | 1 | Digit / slot select |
| PAD_2 | 23 | 2 | Digit / slot select |
| PAD_3 | 24 | 3 | Digit / slot select |
| PAD_SUB | 25 | − | Decrement / minus |
| PAD_0 | 26 | 0 | Digit |
| PAD_DOT | 27 | . | Decimal / separator |
| PAD_ENTER | 28 | ENT | Confirm numpad entry |
| PAD_ADD | 29 | + | Increment / plus |
4. Input Processing Pipeline
Section titled “4. Input Processing Pipeline”4A. Event Flow
Section titled “4A. Event Flow”Physical Key Press │ ▼┌─────────────────────┐│ Keyboard Controller │ Device: USB HID from QMK controller → evdev│ or SDL Event │ Emulator: SDL_PollEvent()└─────────┬───────────┘ │ ▼┌─────────────────────┐│ Debounce (10ms) │ Ignore state changes within 10ms window└─────────┬───────────┘ │ ▼┌─────────────────────┐│ Translate to │ sdl_to_kn86[512] lookup (emulator)│ KN86KeyCode │ Matrix position lookup (hardware)└─────────┬───────────┘ │ ▼┌─────────────────────┐│ Enqueue InputEvent │ 128-slot ring buffer│ (type, key, time) │ KEY_DOWN, KEY_UP, KEY_REPEAT, KEY_HOLD└─────────┬───────────┘ │ ▼┌─────────────────────────────────────────────┐│ Hold Detection (per-frame) ││ LAMBDA/SYS: 2000ms → KEY_HOLD event ││ All others: 500ms → KEY_REPEAT at 10Hz │└─────────┬───────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────┐│ nOSh Runtime Intercept ││ LAMBDA hold → enter recording mode ││ LAMBDA tap (in λ? mode) → replay prompt ││ SYS hold → hard abort to boot screen ││ QUOTE tap → bookmark prompt (QUO?) ││ Lambda/Quote + numpad → slot operation │└─────────┬───────────────────────────────────┘ │ (if not intercepted) ▼┌─────────────────────────────────────────────┐│ Dispatch to Current Cell's Handler ││ CellHandlers lookup: key → handler slot ││ If handler exists → call it ││ If NULL → ignore (silent) ││ Numpad 0-9 → on_numpad(self, digit) │└─────────────────────────────────────────────┘4B. Event Types
Section titled “4B. Event Types”typedef enum { INPUT_EVENT_KEY_DOWN = 0, /* Key pressed (initial) */ INPUT_EVENT_KEY_UP = 1, /* Key released */ INPUT_EVENT_KEY_REPEAT = 2, /* Auto-repeat (500ms delay, 10Hz rate) */ INPUT_EVENT_KEY_HOLD = 3 /* Long hold detected (2000ms, LAMBDA/SYS only) */} InputEventType;4C. Input Queue
Section titled “4C. Input Queue”typedef struct { InputEvent events[128]; /* Ring buffer */ int head; /* Next write position */ int tail; /* Next read position */} InputQueue;The queue is drained every frame by either bare_deck_tick() or runtime_tick(). At 60fps with a 128-slot buffer, overflow is practically impossible under normal operation.
4D. Key State Tracking
Section titled “4D. Key State Tracking”uint8_t key_states[30]; /* 1 = currently pressed, 0 = released */uint32_t key_hold_time[30]; /* Timestamp of last KEY_DOWN (0 if released) */5. Cartridge SDK — Input API
Section titled “5. Cartridge SDK — Input API”5A. Handler Macros (nosh_cart.h)
Section titled “5A. Handler Macros (nosh_cart.h)”Cartridge authors define input behavior per-cell-type using handler macros. Each macro generates a static function with the correct signature:
/* Lisp primitives (amber legend) — list operations */CELL_ON_CAR(name) /* void fn(void *self) — drill in / examine first child */CELL_ON_CDR(name) /* void fn(void *self) — traverse to next sibling */CELL_ON_CONS(name) /* void fn(void *self) — attach / construct */CELL_ON_NIL(name) /* void fn(void *self) — discard / cancel */CELL_ON_ATOM(name) /* void fn(void *self) — test if leaf */CELL_ON_EQ(name) /* void fn(void *self) — compare with quote slot */
/* Action keys (white legend) — execution verbs */CELL_ON_EVAL(name) /* void fn(void *self) — execute / commit */CELL_ON_QUOTE(name) /* void fn(void *self) — bookmark (usually nOSh runtime) */CELL_ON_LAMBDA(name) /* void fn(void *self) — macro (usually nOSh runtime) */CELL_ON_APPLY(name) /* void fn(void *self) — deploy tool */
/* System keys (gray/red legend) */CELL_ON_BACK(name) /* void fn(void *self) — navigate up */CELL_ON_INFO(name) /* void fn(void *self) — inspect / show details */CELL_ON_LINK(name) /* void fn(void *self) — initiate link */CELL_ON_SYS(name) /* void fn(void *self) — system menu (tap only) */
/* Data grid */CELL_ON_NUMPAD(name) /* void fn(void *self, uint8_t digit) — numpad 0-9 */
/* Lifecycle (not input-triggered) */CELL_ON_DISPLAY(name) /* void fn(void *self) — render to framebuffer */CELL_ON_ENTER(name) /* void fn(void *self) — cursor arrived at this cell */CELL_ON_EXIT(name) /* void fn(void *self) — cursor leaving this cell */5B. Handler Table Registration
Section titled “5B. Handler Table Registration”/* Define cell type with custom fields */CELL_TYPE(network_node, uint8_t ice_level; uint16_t data_value; bool compromised;);
/* Define handlers for each key */CELL_ON_CAR(network_node) { cell_network_node *self = (cell_network_node *)_self; if (self->base.first_child) { stdlib_drill_into(g_state); /* Navigate to first child */ } else { stdlib_sfx_error(); /* Leaf node — can't drill */ }}
CELL_ON_CDR(network_node) { stdlib_next_sibling(g_state); /* Move to next adjacent node */}
CELL_ON_INFO(network_node) { cell_network_node *self = (cell_network_node *)_self; display_clear_text(g_state); nosh_print(g_state, 0, 0, "NODE INTEL"); nosh_printf(g_state, 0, 2, "ICE Level: %d", self->ice_level); nosh_printf(g_state, 0, 3, "Data: %04X", self->data_value); nosh_printf(g_state, 0, 4, "Status: %s", self->compromised ? "OPEN" : "LOCKED");}
CELL_ON_EVAL(network_node) { cell_network_node *self = (cell_network_node *)_self; if (self->compromised) { /* Extract data — mission progress */ mission_extract_data(self->data_value); stdlib_sfx_confirm(); } else { stdlib_sfx_error(); /* Can't extract from locked node */ }}
/* Wire handlers into table */CELL_HANDLERS(network_node, .on_car = _cell_network_node_on_car, .on_cdr = _cell_network_node_on_cdr, .on_info = _cell_network_node_on_info, .on_eval = _cell_network_node_on_eval, /* Unset handlers (NULL) are silently ignored by the dispatcher */);5C. Runtime Dispatch (nosh_runtime.c)
Section titled “5C. Runtime Dispatch (nosh_runtime.c)”The runtime’s dispatch_key_handler() maps each KN86KeyCode to the corresponding function pointer in the current cell’s CellHandlers table:
static CellHandler dispatch_key_handler(const CellHandlers *handlers, KN86KeyCode key) { if (handlers == NULL) return NULL; switch (key) { case KN86_KEY_CAR: return handlers->on_car; case KN86_KEY_CDR: return handlers->on_cdr; case KN86_KEY_CONS: return handlers->on_cons; case KN86_KEY_NIL: return handlers->on_nil; case KN86_KEY_ATOM: return handlers->on_atom; case KN86_KEY_EQ: return handlers->on_eq; case KN86_KEY_EVAL: return handlers->on_eval; case KN86_KEY_QUOTE: return handlers->on_quote; case KN86_KEY_LAMBDA: return handlers->on_lambda; case KN86_KEY_APPLY: return handlers->on_apply; case KN86_KEY_BACK: return handlers->on_back; case KN86_KEY_INFO: return handlers->on_info; case KN86_KEY_LINK: return handlers->on_link; case KN86_KEY_SYS: return handlers->on_sys; default: return NULL; }}Numpad keys are handled separately — the dispatcher extracts the digit (0-9) and calls on_numpad(self, digit).
5D. Navigation Stack
Section titled “5D. Navigation Stack”The nOSh runtime maintains a 32-deep navigation stack. Standard library helpers manage it:
| Function | Effect |
|---|---|
stdlib_drill_into(state) | Push current->first_child onto stack. Call on_exit on old, on_enter on new. |
stdlib_next_sibling(state) | Replace stack top with current->next_sibling. Call on_exit/on_enter. |
stdlib_prev_sibling(state) | Replace stack top with current->prev_sibling. Call on_exit/on_enter. |
stdlib_navigate_back(state) | Pop stack. Call on_exit on current, on_enter on new top. |
All four functions beep (stdlib_sfx_error()) if the target pointer is NULL.
6. nOSh-Runtime-Level Key Services
Section titled “6. nOSh-Runtime-Level Key Services”These key functions are owned by the nOSh runtime and cannot be overridden by cartridges. The nOSh runtime intercepts them before dispatch reaches the cell handler table.
6A. Lambda Macro System
Section titled “6A. Lambda Macro System”| Property | Value |
|---|---|
| Trigger | Hold LAMBDA for 2000ms |
| Indicator | λREC in status bar |
| Capacity | 8 slots × 32 key events each |
| Storage | 256 bytes in wear-leveled flash (production) / file (emulator) |
| Playback | Tap LAMBDA → λ? → numpad 1-8 |
| Quick invoke | LAMBDA + digit simultaneously |
Recording flow:
- Hold LAMBDA 2s →
λRECappears, recording starts - Press any sequence of keys (up to 32 events)
- Press LAMBDA to stop →
λ?prompt - Press numpad 1-8 to assign slot (overwrites existing)
Playback flow:
- Tap LAMBDA →
λ?prompt - Press numpad 1-8 → the nOSh runtime replays at original timing
- Events enter input queue — cartridge code cannot distinguish live from macro
6B. Quote Bookmark System
Section titled “6B. Quote Bookmark System”| Property | Value |
|---|---|
| Trigger | Tap QUOTE |
| Indicator | QUO? prompt |
| Capacity | 8 slots, each holds a Cell ID reference |
| Storage | SRAM (volatile across power-off, persistent across cartridge swaps) |
| Semantics | Reference (not snapshot) — like Lisp quote |
6C. SYS Hold — Emergency Abort
Section titled “6C. SYS Hold — Emergency Abort”| Property | Value |
|---|---|
| Trigger | Hold SYS for 2000ms |
| Effect | Unconditional abort to boot screen |
| Override | Not possible — the nOSh runtime handles before cartridge dispatch |
| Purpose | Replaces ESC key. The KN-86 has no ESC. |
6D. TERM Mode Selector
Section titled “6D. TERM Mode Selector”| Property | Value |
|---|---|
| Trigger | Tap TERM |
| Effect | Evaluates priority table (§3C.1) at key-press time, dispatches the matching binding |
| Override | Cartridges may register a priority-4 cart-scoped binding via (term-register-binding ...); nOSh runtime priorities 1–3 and 5 always win |
| Purpose | Single context-sensitive affordance for nOSh runtime modes (seed capture, hot-swap defer, Cipher freeze, REPL) |
6E. TERM Hold — CIPHER Mute Toggle
Section titled “6E. TERM Hold — CIPHER Mute Toggle”| Property | Value |
|---|---|
| Trigger | Hold TERM for 500ms |
| Effect | Toggle cipher_muted flag; blank CIPHER-LINE Rows 2–3 and suspend Cipher engine emission (when muted); restore emission (when unmuted) |
| Override | Not possible — the nOSh runtime handles before cartridge dispatch |
| Purpose | Operator kill-switch for the Cipher voice; stored in Universal Deck State and persistent across power cycles |
6F. CIPHER-LINE Seed Capture Flow
Section titled “6F. CIPHER-LINE Seed Capture Flow”The seed-capture interaction is a first-class nOSh runtime service used whenever a cartridge or nOSh runtime subsystem needs to surface a value for the operator to commit to Universal Deck State. Typical callers: Cipher Garden’s decryption key slots, Null’s Cipher-seed freeze/replay, Shellfire’s intercept session IDs, Drift’s triangulation solution hash.
Trigger. Cartridge or nOSh runtime calls (aux-show-seed seed-bytes :label "<short-label>"). See docs/software/runtime/cipher-voice.md §11 for the primitive signature.
State machine.
state: CIPHER_LINE_DEFAULT └─ Row 1: nOSh runtime status strip Row 4: nOSh runtime chord hints
transition on aux-show-seed(seed, label): → state: CIPHER_LINE_SEED_MODE
state: CIPHER_LINE_SEED_MODE └─ Row 1: FROZEN — "SEED <label>: <seed-hex>" (e.g., "SEED RELAY: A7F391...") Row 4: PINNED — "TERM: CAPTURE BACK: DISMISS" Rows 2–3: Cipher engine continues normal operation Input routing: TERM (tap) → capture + transition BACK (tap) → dismiss + transition TERM (hold) → still toggles Cipher mute (6E has higher priority than mode) Any other key → forwarded to cart as normal
transition on TERM-tap: nOSh runtime writes seed to Universal Deck State: - If cart supplied a :slot hint, write to (aux-seed-slots[:slot]) - Otherwise, write to the next free aux-seed-slot - If no free slot, display "SLOTS FULL" on Row 4 for 2s and remain in SEED_MODE — no key is captured until operator dismisses nOSh runtime plays confirmation tone (1.5 kHz, 100ms) nOSh runtime exits SEED_MODE → CIPHER_LINE_DEFAULT nOSh runtime pushes (:event :type :seed-captured :tag :firmware :target <slot> :affect (:significant))
transition on BACK-tap: nOSh runtime plays cancel tone (800 Hz, 100ms) nOSh runtime exits SEED_MODE → CIPHER_LINE_DEFAULT no deck-state write
transition on aux-show-seed(nil, nil): cart cancels the seed capture nOSh runtime exits SEED_MODE → CIPHER_LINE_DEFAULTAux seed slots. Universal Deck State reserves 8 slots for captured seeds (aux-seed-slots[0..7], each 16 bytes). Slots are visible to cartridges via the (deck-seed-slot N) accessor and persisted to flash on the normal deck-state cadence (mission-phase boundary, cart swap, power-off). The slots are cross-cartridge — a seed captured in Cipher Garden is readable by any subsequent cart — which is intentional, per the capability model’s “cartridge history builds deck texture” principle.
Voice integration. While in CIPHER_LINE_SEED_MODE, the Cipher engine continues to emit on Rows 2–3 per the Grammar Framework. Beat typically drops to :mission-brief or :idle during seed capture (cart’s call). Common pattern: Cipher drifts about prior seed captures while the operator decides to commit. Example expected fragment stream:
Row 2 another handshake.Row 3 last one cost us.Grammars may be tuned to bias drift during seed capture; this is a cart-level choice, not a nOSh runtime behavior.
Accessibility. TERM is on the function grid’s right edge — reachable by either hand. The default emulator mapping for TERM (to be confirmed during hardware bring-up) should accommodate single-handed operation; the current planning target is a Tab-key-adjacent binding so laptop users can capture without reaching.
7. OODA Cognitive Framework
Section titled “7. OODA Cognitive Framework”Every module on the KN-86 maps its interaction to the Observe-Orient-Decide-Act loop. The keys are the physical expression of this cycle:
OBSERVE ──────────────────── INFO (scan), ATOM (test depth) │ ▼ORIENT ───────────────────── CAR (drill in), CDR (scan laterally), BACK (retreat) │ ▼DECIDE ──────────────────── CONS (attach tool), APPLY (deploy), QUOTE (bookmark) │ ▼ACT ─────────────────────── EVAL (commit), EQ (compare result) │ └─── cycle back to OBSERVETempo varies by module:
- ICE Breaker: 2-5 second cycles (fast, reactive)
- Shellfire: 6-8 second cycles (medium, strategic)
- Depthcharge: 10-30 second cycles (slow, deliberate)
- Black Ledger: Self-paced (no time pressure, deep analysis)
Expert operators develop rhythmic “Deckline Chatter” — the sound of their OODA cycle is audible in the room.
8. Key Remapping Architecture
Section titled “8. Key Remapping Architecture”8A. Emulator Keymapping (Current Implementation)
Section titled “8A. Emulator Keymapping (Current Implementation)”/* input.c — hardcoded, static array */static KN86KeyCode sdl_to_kn86[512] = {0};
static void init_keymap(void) { /* Function grid: QWER / ASDF / ZXCV / 12 rows */ sdl_to_kn86[SDL_SCANCODE_Q] = KN86_KEY_QUOTE; sdl_to_kn86[SDL_SCANCODE_W] = KN86_KEY_CONS; /* ... 14 function keys ... */
/* Data grid: physical numpad */ sdl_to_kn86[SDL_SCANCODE_KP_7] = KN86_KEY_PAD_7; /* ... 16 numpad keys ... */}8B. Planned: Configurable Keymapping
Section titled “8B. Planned: Configurable Keymapping”Per ADR-001 (docs/adr/001-keymap-config-format.md), the remapping system adds:
- Config file format: INI-style
KN86_KEY = SDL_SCANCODEpairs - CLI flag:
--keymap <path>loads custom mapping - Default keymap:
assets/default.keymap— matches current hardcoded mapping - Laptop keymap:
assets/laptop.keymap— UIOP/JKL;/M,./ replaces numpad - Runtime rebind: SYS menu → key test mode → press key to remap → EVAL to confirm
- API:
keymap_load(),keymap_save(),keymap_init_defaults()
8C. Default Emulator Mapping
Section titled “8C. Default Emulator Mapping”| KN86 Key | SDL Scancode | QWERTY Key | Hand | Grid Position |
|---|---|---|---|---|
| QUOTE | SDL_SCANCODE_Q | Q | Left | Func R1C1 |
| CONS | SDL_SCANCODE_W | W | Left | Func R1C2 |
| NIL | SDL_SCANCODE_E | E | Left | Func R1C3 |
| LAMBDA | SDL_SCANCODE_R | R | Left | Func R1C4 |
| INFO | SDL_SCANCODE_A | A | Left | Func R2C1 |
| CAR | SDL_SCANCODE_S | S | Left | Func R2C2 |
| APPLY | SDL_SCANCODE_D | D | Left | Func R2C3 |
| SYS | SDL_SCANCODE_F | F | Left | Func R2C4 |
| LINK | SDL_SCANCODE_Z | Z | Left | Func R3C1 |
| BACK | SDL_SCANCODE_X | X | Left | Func R3C2 |
| CDR | SDL_SCANCODE_C | C | Left | Func R3C3 |
| ATOM | SDL_SCANCODE_V | V | Left | Func R3C4 |
| EVAL | SDL_SCANCODE_1 | 1 | Left | Func R4C1 (3U) |
| EQ | SDL_SCANCODE_2 | 2 | Left | Func R4C3 |
| PAD_1 | SDL_SCANCODE_KP_1 | Numpad 1 | Right | Data R1C1 |
| PAD_2 | SDL_SCANCODE_KP_2 | Numpad 2 | Right | Data R1C2 |
| PAD_3 | SDL_SCANCODE_KP_3 | Numpad 3 | Right | Data R1C3 |
| PAD_DIV | SDL_SCANCODE_KP_DIVIDE | Numpad / | Right | Data R1C4 |
| PAD_4 | SDL_SCANCODE_KP_4 | Numpad 4 | Right | Data R2C1 |
| PAD_5 | SDL_SCANCODE_KP_5 | Numpad 5 | Right | Data R2C2 |
| PAD_6 | SDL_SCANCODE_KP_6 | Numpad 6 | Right | Data R2C3 |
| PAD_MUL | SDL_SCANCODE_KP_MULTIPLY | Numpad * | Right | Data R2C4 |
| PAD_7 | SDL_SCANCODE_KP_7 | Numpad 7 | Right | Data R3C1 |
| PAD_8 | SDL_SCANCODE_KP_8 | Numpad 8 | Right | Data R3C2 |
| PAD_9 | SDL_SCANCODE_KP_9 | Numpad 9 | Right | Data R3C3 |
| PAD_SUB | SDL_SCANCODE_KP_MINUS | Numpad - | Right | Data R3C4 |
| PAD_DOT | SDL_SCANCODE_KP_PERIOD | Numpad . | Right | Data R4C1 |
| PAD_0 | SDL_SCANCODE_KP_0 | Numpad 0 | Right | Data R4C2 |
| PAD_ENTER | SDL_SCANCODE_KP_ENTER | Numpad Enter | Right | Data R4C3 |
| PAD_ADD | SDL_SCANCODE_KP_PLUS | Numpad + | Right | Data R4C4 |
Note: The
SDL_SCANCODE_KP_Nbindings are keyboard-level scancodes — they refer to the USB keyboard’s physical keycap, not the KN-86 position. A USB numpad’s7keycap stays7(still the top-left of the USB numpad because laptops/desktops use calculator-layout numpads); it’s the KN-86 position ofKN86_KEY_PAD_7that moves to Row 3 Col 1 under phone layout. Scancode-to-key mapping is purely semantic.
8D. Laptop Keymap
Section titled “8D. Laptop Keymap”Phone-style KN-86 positions mapped onto home-row-adjacent QWERTY keys. The KN86 key codes underneath are the same phone-layout bindings as §8C; only the scancodes change.
Left hand (unchanged): Right hand (letter keys replace numpad): Q W E R U I O P (1 2 3 ÷) A S D F J K L ; (4 5 6 ×) Z X C V M , . / (7 8 9 −) 1 2 N SPC RET ] (. 0 ENT +)The exact laptop-keymap bindings ship in
assets/laptop.keymapand are subject to ergonomic tuning. The invariant is that the logical grid (row × col) follows the phone layout per §1 and §3D regardless of which scancodes the laptop keymap chooses.
9. Design Invariants
Section titled “9. Design Invariants”These rules cannot be violated by any module, remapping, or future extension:
- 30 keys, no more. The physical device has exactly 30 switches. The emulator must expose exactly 30 KN86 key codes.
- Two grids, two hands. Left = function (semantic operations). Right = data (numeric entry). This split is architectural, not cosmetic.
- SYS hold is sacred. 2-second SYS hold always aborts to boot screen. No cartridge can override this. It’s the operator’s emergency exit.
- LAMBDA/QUOTE belong to the nOSh runtime. Macro recording and bookmarking are nOSh runtime services. Cartridges can define
on_lambdaandon_quotehandlers for conditional behavior during recording, but the services themselves belong to the nOSh runtime. - CAR drills, CDR traverses. These are genuine list operations. If a module’s data isn’t structured as a nested list navigable by CAR/CDR, the module’s data model needs to be redesigned (see Lisp Paradigm Audit).
- EVAL is irreversible. Pressing EVAL commits an action. There is no undo. The operator must OBSERVE and ORIENT before they ACT. This is by design — the tension of commitment is the game.
- NULL handlers are silent. If a cell doesn’t define a handler for a key, pressing that key does nothing. No error, no beep. Only ATOM (leaf test) and the stdlib navigation functions produce error beeps.
- Numpad is data-first, direction-optional. The nOSh runtime delivers numpad events as raw digits (0-9) via
on_numpad(self, digit). The module decides whether a digit means a number or a direction. Modules MAY interpret the physical layout of 2/4/6/8 as cardinal steering and 1/3/7/9 as diagonals — but this is a module convention, not a nOSh runtime feature. The nOSh runtime never sends “north” — it sends “8.” ÷, ×, −, + are also numpad keys and follow the same rule: raw values, module-interpreted meaning.