Skip to content

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_tick dispatcher)
  • 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).


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.


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.


The enum keeps existing slots at their current indices. New slots are appended in KN86KeyCode numeric order, SYS skipped.

IndexSlot symbolLisp nameKN86KeyCodeCategory
0KN86_LISP_SLOT_CAR'carKN86_KEY_CARExisting (Wave 4)
1KN86_LISP_SLOT_CDR'cdrKN86_KEY_CDRExisting (Wave 4)
2KN86_LISP_SLOT_EVAL'evalKN86_KEY_EVALExisting (Wave 4)
3KN86_LISP_SLOT_QUOTE'quoteKN86_KEY_QUOTEExisting (Wave 4)
4KN86_LISP_SLOT_INFO'infoKN86_KEY_INFOExisting (Wave 4)
5KN86_LISP_SLOT_BACK'backKN86_KEY_BACKExisting (Wave 4)
6KN86_LISP_SLOT_ENTER'enter(lifecycle, no key)Existing (Wave 4)
7KN86_LISP_SLOT_EXIT'exit(lifecycle, no key)Existing (Wave 4)
8KN86_LISP_SLOT_CONS'consKN86_KEY_CONSNEW — function key
9KN86_LISP_SLOT_NIL'nilKN86_KEY_NILNEW — function key
10KN86_LISP_SLOT_LAMBDA'lambdaKN86_KEY_LAMBDANEW — function key
11KN86_LISP_SLOT_APPLY'applyKN86_KEY_APPLYNEW — function key
12KN86_LISP_SLOT_LINK'linkKN86_KEY_LINKNEW — function key
13KN86_LISP_SLOT_ATOM'atomKN86_KEY_ATOMNEW — function key
14KN86_LISP_SLOT_EQ'eqKN86_KEY_EQNEW — function key
15KN86_LISP_SLOT_PAD_7'pad-7KN86_KEY_PAD_7NEW — numpad
16KN86_LISP_SLOT_PAD_8'pad-8KN86_KEY_PAD_8NEW — numpad
17KN86_LISP_SLOT_PAD_9'pad-9KN86_KEY_PAD_9NEW — numpad
18KN86_LISP_SLOT_PAD_DIV'pad-divKN86_KEY_PAD_DIVNEW — numpad
19KN86_LISP_SLOT_PAD_4'pad-4KN86_KEY_PAD_4NEW — numpad
20KN86_LISP_SLOT_PAD_5'pad-5KN86_KEY_PAD_5NEW — numpad
21KN86_LISP_SLOT_PAD_6'pad-6KN86_KEY_PAD_6NEW — numpad
22KN86_LISP_SLOT_PAD_MUL'pad-mulKN86_KEY_PAD_MULNEW — numpad
23KN86_LISP_SLOT_PAD_1'pad-1KN86_KEY_PAD_1NEW — numpad
24KN86_LISP_SLOT_PAD_2'pad-2KN86_KEY_PAD_2NEW — numpad
25KN86_LISP_SLOT_PAD_3'pad-3KN86_KEY_PAD_3NEW — numpad
26KN86_LISP_SLOT_PAD_SUB'pad-subKN86_KEY_PAD_SUBNEW — numpad
27KN86_LISP_SLOT_PAD_0'pad-0KN86_KEY_PAD_0NEW — numpad
28KN86_LISP_SLOT_PAD_DOT'pad-dotKN86_KEY_PAD_DOTNEW — numpad
29KN86_LISP_SLOT_PAD_ENTER'pad-enterKN86_KEY_PAD_ENTERNEW — numpad
30KN86_LISP_SLOT_PAD_ADD'pad-addKN86_KEY_PAD_ADDNEW — numpad
31KN86_LISP_SLOT_COUNTSentinel (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.


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.


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.

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.


  1. Enum positions stable. Indices 0 through 7 retain their Wave 4 slot assignments (CAR, CDR, EVAL, QUOTE, INFO, BACK, ENTER, EXIT). No renumbering.
  2. Symbol names stable. lisp_event_name_to_slot preserves 'car, 'cdr, 'eval, 'quote, 'info, 'back, 'enter, 'exit → their existing slots. New symbols are strictly additive.
  3. 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.
  4. C handler table unchanged. CellHandlers keeps on_numpad(self, digit) for C carts. C-cart dispatch paths are untouched.
  5. No new primitives. The primitive count advertised in nosh_lisp_bridge.h stays at 38. Only a comment near the register-cell-type section is updated to reflect the wider event vocabulary.

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:

  1. 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.
  2. Carts that want only one numpad key don’t pay the routing cost. A cart using only pad-enter doesn’t need a giant (if (= digit 28) ...) ladder in Lisp.
  3. 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 existing on-numpad handler.
  4. 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.


Four files touched. Minimum viable diff.

  1. kn86-emulator/src/types.h — extend KN86LispSlot enum with the 23 new entries. KN86_LISP_SLOT_COUNT grows accordingly, which auto-sizes CellTypeInfo.lisp_handlers[].
  2. kn86-emulator/src/nosh_runtime.c — extend lisp_slot_for_key with cases for CONS / NIL / LAMBDA / APPLY / LINK / ATOM / EQ and all 16 PAD_*. SYS stays unmapped. Refactor runtime_tick so numpad keys dispatch via the per-key Lisp slot (for Lisp carts) while still routing through the legacy on_numpad(digit) for C carts.
  3. kn86-emulator/src/nosh_lisp_bridge.c — extend lisp_event_name_to_slot with string-literal branches for each new symbol.
  4. kn86-emulator/src/nosh_lisp_bridge.h — comment-only update noting the widened event vocabulary. No new primitives; the advertised count (38) is unchanged.

One new ctest target: test_lisp_slot_widening.

Coverage:

  1. Build a Lisp registration alist binding a distinct no-op counter-increment lambda to every newly-added slot (7 function keys + 16 numpad).
  2. Inject each corresponding KN86KeyCode via the input queue.
  3. Assert each per-slot counter is exactly 1 after runtime_tick.
  4. Inject KN86_KEY_SYS and assert no Lisp handler fires.
  5. The pre-existing 8 slots are exercised by test_lisp_handler_dispatch and test_icebreaker_lisp — those must stay green with zero code changes.

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.h stays frozen at 38.

Negative:

  • Bigger switch statements in lisp_slot_for_key and lisp_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.

Per CLAUDE.md Spec Hygiene Rule 3:

  • This ADR is the primary doc for the widened slot table. No other spec currently enumerates KN86LispSlot values, 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 the register-cell-type idiom 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.h preamble 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.


  1. Keep the 8-slot table; force Wave 5 carts to C. Rejected: defeats Wave 4’s one-language promise and doubles the authoring surface.
  2. 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.
  3. Route SYS to Lisp. Rejected: SYS is the universal escape hatch. Cartridges must not own it.
  4. 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.

DateAuthorChange
2026-04-21Claude (C Engineer subagent)Initial draft for GWP-189.