ADR-0012: Lisp Handler Slot Table Widening (Full Key Paradigm)
Depends on: ADR-0005 (FFI surface), ADR-0006 (cart format v2.0), ADR-0010 (ICE Breaker Lisp sketch) Supersedes: None. Additive extension of the Wave 4 Lisp dispatch path (GWP-159). Related:
- Repo root
CLAUDE.md(Canonical Hardware Specification, 30-key layout) kn86-emulator/src/types.h(KN86KeyCode,KN86LispSlot,CellTypeInfo)kn86-emulator/src/nosh_runtime.c(lisp_slot_for_key,runtime_tickdispatcher)kn86-emulator/src/nosh_lisp_bridge.c(lisp_event_name_to_slot,register-cell-type)
Notion task: GWP-189 Downstream blocks: GWP-186 (Depthcharge Lisp port), GWP-187 (NeonGrid Lisp port), GWP-188 (Black Ledger Lisp port).
Context
Section titled “Context”Wave 4 (GWP-159) shipped Lisp-authored cartridges with a minimal 8-slot handler table: CAR, CDR, EVAL, QUOTE, INFO, BACK, ENTER, EXIT. That surface was sufficient for the ICE Breaker vertical slice but is too narrow for the remaining three launch titles being ported to Lisp — Depthcharge (GWP-186), NeonGrid (GWP-187), and Black Ledger (GWP-188) — which exercise cell behaviors across the full 14-function-key + 16-numpad-key surface.
Today a Lisp cart that needs (for example) ON_APPLY on a contract cell must either fall back to the runtime default (which plays sfx-error) or register a co-authored C sibling cart. That defeats the whole point of Wave 4 — one cartridge, one language, one authoring surface.
This ADR widens the Lisp handler slot table so Lisp carts can register handlers for every user-accessible key: the 14 function keys (except the nOSh runtime-reserved SYS) and all 16 numpad keys.
The cartridge grammar, FFI primitive catalog, and cart format v2.0 header are not changed. This is a pure dispatcher and registration widening.
Decision
Section titled “Decision”Widen KN86LispSlot from 8 entries to 31 entries + _COUNT sentinel (32 total), mirroring the KN86KeyCode enum one-for-one minus the nOSh runtime-reserved SYS key.
The 8 existing slots retain their numeric positions (additive only — ICE Breaker and every prior Wave 4 cart must continue to work bit-for-bit). Seven new function-key slots and sixteen new numpad slots are appended.
The runtime dispatcher (lisp_slot_for_key) is extended to route every non-SYS KN86KeyCode to its slot. The Lisp-side symbol registry (lisp_event_name_to_slot) is extended with the new symbol names.
Numpad dispatch is 16 individual slots, not one on_numpad slot with a numeric argument. This matches the C handler table’s legacy on_numpad(self, digit) shape only for C carts; Lisp carts get per-key resolution, which keeps the door open for future Shift-modified numpad behavior without breaking an existing signature.
Final KN86LispSlot enum layout
Section titled “Final KN86LispSlot enum layout”The enum keeps existing slots at their current indices. New slots are appended in KN86KeyCode numeric order, SYS skipped.
| Index | Slot symbol | Lisp name | KN86KeyCode | Category |
|---|---|---|---|---|
| 0 | KN86_LISP_SLOT_CAR | 'car | KN86_KEY_CAR | Existing (Wave 4) |
| 1 | KN86_LISP_SLOT_CDR | 'cdr | KN86_KEY_CDR | Existing (Wave 4) |
| 2 | KN86_LISP_SLOT_EVAL | 'eval | KN86_KEY_EVAL | Existing (Wave 4) |
| 3 | KN86_LISP_SLOT_QUOTE | 'quote | KN86_KEY_QUOTE | Existing (Wave 4) |
| 4 | KN86_LISP_SLOT_INFO | 'info | KN86_KEY_INFO | Existing (Wave 4) |
| 5 | KN86_LISP_SLOT_BACK | 'back | KN86_KEY_BACK | Existing (Wave 4) |
| 6 | KN86_LISP_SLOT_ENTER | 'enter | (lifecycle, no key) | Existing (Wave 4) |
| 7 | KN86_LISP_SLOT_EXIT | 'exit | (lifecycle, no key) | Existing (Wave 4) |
| 8 | KN86_LISP_SLOT_CONS | 'cons | KN86_KEY_CONS | NEW — function key |
| 9 | KN86_LISP_SLOT_NIL | 'nil | KN86_KEY_NIL | NEW — function key |
| 10 | KN86_LISP_SLOT_LAMBDA | 'lambda | KN86_KEY_LAMBDA | NEW — function key |
| 11 | KN86_LISP_SLOT_APPLY | 'apply | KN86_KEY_APPLY | NEW — function key |
| 12 | KN86_LISP_SLOT_LINK | 'link | KN86_KEY_LINK | NEW — function key |
| 13 | KN86_LISP_SLOT_ATOM | 'atom | KN86_KEY_ATOM | NEW — function key |
| 14 | KN86_LISP_SLOT_EQ | 'eq | KN86_KEY_EQ | NEW — function key |
| 15 | KN86_LISP_SLOT_PAD_7 | 'pad-7 | KN86_KEY_PAD_7 | NEW — numpad |
| 16 | KN86_LISP_SLOT_PAD_8 | 'pad-8 | KN86_KEY_PAD_8 | NEW — numpad |
| 17 | KN86_LISP_SLOT_PAD_9 | 'pad-9 | KN86_KEY_PAD_9 | NEW — numpad |
| 18 | KN86_LISP_SLOT_PAD_DIV | 'pad-div | KN86_KEY_PAD_DIV | NEW — numpad |
| 19 | KN86_LISP_SLOT_PAD_4 | 'pad-4 | KN86_KEY_PAD_4 | NEW — numpad |
| 20 | KN86_LISP_SLOT_PAD_5 | 'pad-5 | KN86_KEY_PAD_5 | NEW — numpad |
| 21 | KN86_LISP_SLOT_PAD_6 | 'pad-6 | KN86_KEY_PAD_6 | NEW — numpad |
| 22 | KN86_LISP_SLOT_PAD_MUL | 'pad-mul | KN86_KEY_PAD_MUL | NEW — numpad |
| 23 | KN86_LISP_SLOT_PAD_1 | 'pad-1 | KN86_KEY_PAD_1 | NEW — numpad |
| 24 | KN86_LISP_SLOT_PAD_2 | 'pad-2 | KN86_KEY_PAD_2 | NEW — numpad |
| 25 | KN86_LISP_SLOT_PAD_3 | 'pad-3 | KN86_KEY_PAD_3 | NEW — numpad |
| 26 | KN86_LISP_SLOT_PAD_SUB | 'pad-sub | KN86_KEY_PAD_SUB | NEW — numpad |
| 27 | KN86_LISP_SLOT_PAD_0 | 'pad-0 | KN86_KEY_PAD_0 | NEW — numpad |
| 28 | KN86_LISP_SLOT_PAD_DOT | 'pad-dot | KN86_KEY_PAD_DOT | NEW — numpad |
| 29 | KN86_LISP_SLOT_PAD_ENTER | 'pad-enter | KN86_KEY_PAD_ENTER | NEW — numpad |
| 30 | KN86_LISP_SLOT_PAD_ADD | 'pad-add | KN86_KEY_PAD_ADD | NEW — numpad |
| 31 | KN86_LISP_SLOT_COUNT | — | — | Sentinel (array size) |
CellTypeInfo.lisp_handlers[] is sized by KN86_LISP_SLOT_COUNT, so the C-struct storage grows transparently from 8 pointers to 31 pointers. On a 64-bit build that is an extra 184 bytes per cell type — bounded by KN86_MAX_CELL_TYPES (32), so the worst-case runtime registry grows by ~6 KB. Well within the static allocation budget.
SYS is firmware-reserved (not routed)
Section titled “SYS is firmware-reserved (not routed)”The SYS function key (KN86_KEY_SYS, index 7 in KN86KeyCode) is deliberately omitted from the Lisp slot table. lisp_slot_for_key(KN86_KEY_SYS) returns -1.
SYS is owned by nOSh (firmware) for system menu / deck settings / debrief surface. Letting cartridges intercept SYS would break the universal escape hatch that lets players reach the system menu from any module. This is consistent with how the SYS key behaves for C carts too — the legacy CellHandlers.on_sys slot exists for completeness but firmware short-circuits SYS before it reaches the cell dispatcher in practice (see GWP-178, post-v0.1 default-capabilities interaction plan).
If a future revision decides to open SYS to cartridges, the slot table will be widened to include KN86_LISP_SLOT_SYS and this ADR will be superseded. Do not route SYS without an ADR change.
Lisp-side registration idiom
Section titled “Lisp-side registration idiom”Unchanged from Wave 4. The register-cell-type primitive accepts an alist of (event-symbol . handler-lambda) pairs:
(= widget-on-apply (fn (cell) (deploy-tool cell)))(= widget-on-lambda (fn (cell) (macro-play cell)))(= widget-on-pad-7 (fn (cell) (quick-select cell 7)))(= widget-on-pad-enter (fn (cell) (commit-selection cell)))
(register-cell-type 'widget (list (cons 'car widget-on-car) (cons 'apply widget-on-apply) (cons 'lambda widget-on-lambda) (cons 'pad-7 widget-on-pad-7) (cons 'pad-enter widget-on-pad-enter)))Unknown symbols still produce a stderr log and are skipped — same behavior as the narrow slot table.
Reader subtlety: 'nil as alist key
Section titled “Reader subtlety: 'nil as alist key”Fe’s reader resolves the literal nil to the nil sentinel, not to a symbol. That means the form (cons 'nil handler) produces the pair (nil . handler) whose car is the nil object. The bridge’s handler-slot walker (populate_handler_slots) uses fe_tostring to extract the key’s printable name — fe_tostring renders nil as the string "nil", which matches the "nil" branch in lisp_event_name_to_slot. So the binding works, but cart authors should be aware that 'nil is NOT a symbol in Fe; it is the nil literal. Functionally indistinguishable at the FFI boundary, but worth noting for anyone debugging the dispatcher.
Forward-compatibility hook: Shift-modified numpad
Section titled “Forward-compatibility hook: Shift-modified numpad”Some proposed gameplay modes (see docs/game-design/KN-86-Lisp-Paradigm-Revisions.md) want Shift+numpad to produce a distinct event, giving carts ~32 numpad-like affordances instead of 16. This ADR does not wire Shift-modified numpad — the 30-key physical layout does not expose a modifier flag in KN86KeyCode today.
The per-numpad-key slot decision keeps that door open: a future ADR can add KN86_LISP_SLOT_SHIFT_PAD_7 through KN86_LISP_SLOT_SHIFT_PAD_ADD (16 more slots), dispatched from a widened KN86KeyCode or a modifier flag on InputEvent. Because the handler signature is unchanged and the slot table is additive, the upgrade stays backward-compatible.
Non-goal for this ADR: we do not reserve slot indices for shift-modified numpad today. Reserving slots forces the enum to “lock in” meanings we haven’t proven. When the shift work lands, it appends at whatever _COUNT is at that time.
Backward compatibility guarantees
Section titled “Backward compatibility guarantees”- Enum positions stable. Indices 0 through 7 retain their Wave 4 slot assignments (
CAR, CDR, EVAL, QUOTE, INFO, BACK, ENTER, EXIT). No renumbering. - Symbol names stable.
lisp_event_name_to_slotpreserves'car,'cdr,'eval,'quote,'info,'back,'enter,'exit→ their existing slots. New symbols are strictly additive. - Handler signature unchanged. Every slot dispatches
(lambda cell-ptr)— the same shape as Wave 4. Numpad slots do NOT receive a digit arg; the key identity IS the digit. - C handler table unchanged.
CellHandlerskeepson_numpad(self, digit)for C carts. C-cart dispatch paths are untouched. - No new primitives. The primitive count advertised in
nosh_lisp_bridge.hstays at 38. Only a comment near theregister-cell-typesection is updated to reflect the wider event vocabulary.
ICE Breaker (GWP-159) regression check
Section titled “ICE Breaker (GWP-159) regression check”The ICE Breaker Lisp cart (kn86-emulator/carts/icebreaker.lsp) binds handlers for 'car, 'cdr, 'eval, 'quote, 'info, 'back, 'enter, 'exit — precisely the existing 8 slots. None of those mappings change. The test_icebreaker_lisp ctest target must stay green after this ADR’s implementation lands (when it runs — the ctest entry is skipped in environments without the kn86cart Rust tool; see CMakeLists.txt SKIP_RETURN_CODE 77).
Numpad dispatch: why 16 slots, not 1 slot + arg
Section titled “Numpad dispatch: why 16 slots, not 1 slot + arg”Two alternatives were considered:
Alternative A (rejected): Keep a single KN86_LISP_SLOT_NUMPAD slot whose handler is invoked with (lambda cell-ptr digit) for any numpad key. Matches the C on_numpad shape.
Alternative B (chosen): 16 individual slots, standard (lambda cell-ptr) signature.
Reasons for B:
- Uniform signature. Every slot on the Lisp side takes the same single-cell arg. The dispatcher doesn’t branch on slot kind. Simpler runtime, simpler mental model.
- Carts that want only one numpad key don’t pay the routing cost. A cart using only
pad-enterdoesn’t need a giant(if (= digit 28) ...)ladder in Lisp. - Shift-modified numpad upgrade path. When shift-numpad lands, it adds 16 sibling slots at the end of the enum. Alt A would have needed a signature change (
(lambda cell-ptr digit modifier)) that breaks every existingon-numpadhandler. - Symmetry with function keys. Function-key dispatch is already per-key. Numpad keys are user-facing inputs; they deserve the same uniform model.
The trade-off: more enum entries. That cost is pure metadata — KN86_MAX_CELL_TYPES (32) × 16 numpad pointers × 8 bytes = 4 KB extra in the worst-case registry. Accepted.
Implementation surface
Section titled “Implementation surface”Four files touched. Minimum viable diff.
kn86-emulator/src/types.h— extendKN86LispSlotenum with the 23 new entries.KN86_LISP_SLOT_COUNTgrows accordingly, which auto-sizesCellTypeInfo.lisp_handlers[].kn86-emulator/src/nosh_runtime.c— extendlisp_slot_for_keywith cases for CONS / NIL / LAMBDA / APPLY / LINK / ATOM / EQ and all 16 PAD_*. SYS stays unmapped. Refactorruntime_tickso numpad keys dispatch via the per-key Lisp slot (for Lisp carts) while still routing through the legacyon_numpad(digit)for C carts.kn86-emulator/src/nosh_lisp_bridge.c— extendlisp_event_name_to_slotwith string-literal branches for each new symbol.kn86-emulator/src/nosh_lisp_bridge.h— comment-only update noting the widened event vocabulary. No new primitives; the advertised count (38) is unchanged.
Test plan
Section titled “Test plan”One new ctest target: test_lisp_slot_widening.
Coverage:
- Build a Lisp registration alist binding a distinct no-op counter-increment lambda to every newly-added slot (7 function keys + 16 numpad).
- Inject each corresponding
KN86KeyCodevia the input queue. - Assert each per-slot counter is exactly 1 after
runtime_tick. - Inject
KN86_KEY_SYSand assert no Lisp handler fires. - The pre-existing 8 slots are exercised by
test_lisp_handler_dispatchandtest_icebreaker_lisp— those must stay green with zero code changes.
Consequences
Section titled “Consequences”Positive:
- Unblocks GWP-186 (Depthcharge), GWP-187 (NeonGrid), GWP-188 (Black Ledger) Lisp ports. All three use keys beyond the Wave 4 8-slot surface.
- Removes the “write a C sibling cart to reach APPLY / ATOM / numpad” escape hatch that would have fragmented Wave 5 authoring.
- Leaves SYS firmware-ownership intact — the one key cartridges should not override.
Neutral:
- Registry storage grows by ~6 KB worst case. Not meaningful at the Pi Zero 2 W scale (512 MB RAM).
- Primitive count unchanged; API surface from
nosh_lisp_bridge.hstays frozen at 38.
Negative:
- Bigger switch statements in
lisp_slot_for_keyandlisp_event_name_to_slot. Both are cold-path lookup tables; O(N) with small N is fine. - Cart authors now have 31 possible binding slots instead of 8. Onboarding docs in ADR-0010 and the capability-model authoring guide need a single-line update pointing at this ADR’s slot table. Tracked as a follow-up docs sub-task, not a blocker.
Documentation Updates
Section titled “Documentation Updates”Per CLAUDE.md Spec Hygiene Rule 3:
- This ADR is the primary doc for the widened slot table. No other spec currently enumerates
KN86LispSlotvalues, so no corrections elsewhere are required. - ADR-0005 (
docs/architecture/adr/ADR-0005-ffi-surface.md) advertises 38 primitives; widening does NOT add primitives, so the tier tables and count stay correct. - ADR-0010 (
docs/architecture/adr/ADR-0010-icebreaker-lisp-sketch.md) documents theregister-cell-typeidiom and says “14 handler slots” in the v1.1 coverage table (line 493). That figure was a forward-looking target for the full key paradigm; this ADR brings the implementation in line with that aspiration (31 slots = 14 function keys minus SYS + 16 numpad + 2 lifecycle hooks = 31, which exceeds the 14 figure because the 14 referenced function keys only). The line stays factually defensible — no edit required, but a follow-up docs task could swap the figure for a pointer to this ADR’s slot table for precision. kn86-emulator/src/nosh_lisp_bridge.hpreamble comment gets a one-line note directing readers to this ADR for the full slot list. Primitive tier counts remain 38.
No CLAUDE.md Canonical Hardware Specification entries are affected — this ADR doesn’t change the 30-key count, row layout, display spec, or font.
Alternatives considered (not chosen)
Section titled “Alternatives considered (not chosen)”- Keep the 8-slot table; force Wave 5 carts to C. Rejected: defeats Wave 4’s one-language promise and doubles the authoring surface.
- Add an “arbitrary key” fallback slot that receives a key code argument. Rejected: breaks the uniform
(lambda cell-ptr)signature and pushes dispatch logic into every cart. - Route SYS to Lisp. Rejected: SYS is the universal escape hatch. Cartridges must not own it.
- Consolidate numpad into one slot with a digit arg. Rejected: see “Numpad dispatch” section above — signature uniformity, extension path, and per-key opt-in all favor 16 slots.
Revision history
Section titled “Revision history”| Date | Author | Change |
|---|---|---|
| 2026-04-21 | Claude (C Engineer subagent) | Initial draft for GWP-189. |