Skip to content

ADR-0005: NoshAPI FFI Surface Enumeration

Supersedes spike: former spikes/ADR-0001-FFI-surface.md
Scope: All-carts, mission-context, and REPL-read-only capability tiers
Related: adr/ADR-0001-embedded-lisp-scripting-layer.md, adr/ADR-0014-display-profile-redesign.md, nosh.h, nosh_stdlib.h, nosh_cart.h


This document enumerates every C function and primitive that the Lisp runtime must expose as built-in functions to cartridge code. Organized by capability tier: All-carts (universally available), Mission-context (only during active mission phases), and REPL-read-only (Lisp REPL can call for inspection, no mutations).

Source documents:

  • /kn86-emulator/src/nosh.h — display and sound API
  • /kn86-emulator/src/nosh_stdlib.h — navigation, list ops, deck-state access, procedural generation
  • /kn86-emulator/src/nosh_runtime.h — cell pool and navigation stack management
  • /kn86-emulator/src/nosh_cart.h — macro signatures (now superseded, but API contracts remain valid)

Tier 1: All-Carts Primitives (Available Always)

Section titled “Tier 1: All-Carts Primitives (Available Always)”
Lisp nameSignatureC referenceSemantic contractRaises
text-clear() → nilnosh_text_clear()Clear text framebuffer (80x25 cells). Does not affect split-view bitmap area.
text-putc(col row char) → nilnosh_text_putc(uint8_t col, uint8_t row, char c)Place a single character at (col, row) in text grid. col: 0–79, row: 0–24. char: ASCII 0–127.:out-of-range if col ≥ 80 or row ≥ 25
text-puts(col row string) → nilnosh_text_puts(uint8_t col, uint8_t row, const char *str)Print a null-terminated string at (col, row), left-aligned. Stops at grid boundary.:out-of-range if col ≥ 80
text-printf(col row format-string arg1 arg2 ...) → nil(Synthesized: formatted text-puts via snprintf + text-puts)Printf-style formatted string at (col, row). Format specifiers: %d, %u, %x, %c, %s. Max 128 bytes output.:format-error if format string is invalid; :out-of-range if col ≥ 80
text-scroll(lines) → nilnosh_text_scroll(int8_t lines)Scroll text up (positive) or down (negative). lines: -25 to +25.:out-of-range if abs(lines) > 25
text-cursor(col row visible) → nilnosh_text_cursor(uint8_t col, uint8_t row, bool visible)Show/hide text cursor at (col, row). visible: #t or #f.:out-of-range if col ≥ 80 or row ≥ 25
text-invert(col row len) → nilnosh_text_invert(uint8_t col, uint8_t row, uint8_t len)Invert (XOR) text color for len characters starting at (col, row). For emphasis.:out-of-range if col + len > 80

Graphics mode (bitmap, 960×600) (Amended 2026-04-24 per ADR-0014)

Section titled “Graphics mode (bitmap, 960×600) (Amended 2026-04-24 per ADR-0014)”
Lisp nameSignatureC referenceSemantic contractRaises
gfx-clear() → nilnosh_gfx_clear()Clear bitmap framebuffer (all pixels off).
gfx-pixel(x y on) → nilnosh_gfx_pixel(uint16_t x, uint16_t y, bool on)Set or clear a pixel at (x, y). x: 0–959, y: 0–599. on: #t (on) or #f (off).:out-of-range if x ≥ 960 or y ≥ 600
gfx-line(x0 y0 x1 y1) → nilnosh_gfx_line(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1)Draw line from (x0, y0) to (x1, y1) using Bresenham.:out-of-range if any coord out of bounds
gfx-rect(x y w h filled) → nilnosh_gfx_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool filled)Draw rectangle at (x, y) with size (w, h). filled: #t → solid, #f → outline.:out-of-range if x+w > 960 or y+h > 600
gfx-circle(cx cy r) → nilnosh_gfx_circle(uint16_t cx, uint16_t cy, uint16_t r)Draw circle centered at (cx, cy) with radius r.:out-of-range if circle exceeds bounds
gfx-blit(x y bitmap-bytes w h) → nilnosh_gfx_blit(uint16_t x, uint16_t y, const uint8_t *bitmap, uint16_t w, uint16_t h)Blit packed bitmap (1 bit per pixel, rows left-to-right, top-to-bottom) at (x, y). bitmap-bytes: byte array of (w*h+7)/8 bytes.:out-of-range if x+w > 960 or y+h > 600; :invalid-bitmap if bytes insufficient
Lisp nameSignatureC referenceSemantic contractRaises
split-view(bitmap-rows) → nilnosh_split(uint16_t bitmap_rows)Set split-view mode: top bitmap-rows pixels are graphics, remainder is text. bitmap-rows: 0–599. 0 = all text, 600 = all graphics.:out-of-range if bitmap-rows > 600
display-mode() → symbol(Synthesized from internal state)Query current display mode: :text, :graphics, or :split. Read-only.
Lisp nameSignatureC referenceSemantic contractRaises
psg-write(reg val) → nilnosh_psg_write(uint8_t reg, uint8_t val)Write to PSG register reg (0–14) with value val (0–255). Directly controls 3 tone generators, noise, envelope. Use with caution.:invalid-register if reg > 14; :invalid-value if val > 255
psg-read(reg) → integernosh_psg_read(uint8_t reg)Read current value of PSG register reg (0–14). Returns 0–255.:invalid-register if reg > 14
Lisp nameSignatureC referenceSemantic contractRaises
sound-tone(channel freq vol) → nilnosh_sound_tone(uint8_t channel, uint16_t frequency, uint8_t volume)Play tone on channel (0–2) at frequency (1–50000 Hz) at volume (0–15). Clamps to PSG limits.:invalid-channel if channel > 2; :invalid-freq if freq = 0; :invalid-volume if vol > 15
sound-noise(period) → nilnosh_sound_noise(uint8_t period)Set noise period (0–31). 0 = off, higher = lower pitch/slower clock.:invalid-period if period > 31
sound-envelope(shape period) → nilnosh_sound_envelope(uint8_t shape, uint16_t period)Set envelope shape (0–15, bits 0–3 = hold/alternate/attack/hold) and period. PSG-native.:invalid-shape if shape > 15
sound-silence() → nilnosh_sound_silence()Mute all sound (set all channels vol to 0, turn off noise).
Lisp nameSignatureC referenceSemantic contractRaises
sfx-keyclick() → nilnosh_sfx_keyclick()Play short click sound (key confirmation). ~50 ms, 1000 Hz.
sfx-boot() → nilnosh_sfx_boot()Play boot sequence (ascending tones). ~300 ms.
sfx-select() → nilstdlib_sfx_select() (via nosh_cart.h)Play selection sound (short beep).
sfx-confirm() → nilstdlib_sfx_confirm()Play confirmation sound (higher tone).
sfx-error() → nilstdlib_sfx_error()Play error buzz (low, harsh).
sfx-alert() → nilstdlib_sfx_alert()Play alert ping (high tone).
Lisp nameSignatureC referenceSemantic contractRaises
spawn-cell(type-symbol) → cellruntime_spawn_cell(SystemState *state, uint16_t type_id)Allocate a new cell of the given type (e.g., ‘contract, ‘datum, ‘link). Type must be registered in cartridge CART_INIT. Returns cell object or nil if pool exhausted.:unknown-type if type not registered; :pool-exhausted if no more cells available
destroy-cell(cell) → nilruntime_destroy_cell(SystemState *state, void *cell)Return cell to the free pool. Cell object becomes invalid after this call.:invalid-cell if cell is null or corrupted
Lisp nameSignatureC referenceSemantic contractRaises
drill-into(target-cell) → nilruntime_drill_into(SystemState *state, CellBase *target)Push target onto nav stack. Calls ON_EXIT on current cell, ON_ENTER on target.:invalid-cell if target is null; :nav-stack-overflow if depth exceeds 32
navigate-back() → nilruntime_navigate_back(SystemState *state)Pop nav stack. Calls ON_EXIT on leaving cell, ON_ENTER on returning cell. Fails if stack is at root.:nav-stack-underflow if already at root
current-cell() → cellruntime_current_cell(SystemState *state)Return the cell at the top of the nav stack. Returns nil if stack is empty.
set-root(root-cell) → nilruntime_set_root(SystemState *state, CellBase *root_cell)Reset nav stack: clear all, push root-cell. Usually called once at cartridge init.:invalid-cell if root-cell is null
Lisp nameSignatureC referenceSemantic contractRaises
next-sibling() → nilstdlib_next_sibling(SystemState *state)Move cursor to next sibling (CDR direction) in current list. No-op if already at last sibling.
prev-sibling() → nilstdlib_prev_sibling(SystemState *state)Move cursor to previous sibling. No-op if already at first sibling.
Lisp nameSignatureC referenceSemantic contractRaises
list-push(parent cell) → nilstdlib_list_push(CellBase *parent, CellBase *cell)Append cell as child of parent. cell is linked into parent’s child chain.:invalid-cell if parent or cell is null
list-get(parent index) → cellstdlib_list_get(CellBase *parent, int index)Get Nth child of parent (0-indexed). Returns nil if index out of range.
list-length(parent) → integerstdlib_list_length(CellBase *parent)Count children of parent. Returns 0 if parent is a leaf.:invalid-cell if parent is null
is-leaf(cell) → boolstdlib_is_leaf(CellBase *cell)Test if cell has no children. Returns #t or #f.
link-cells(parent child) → nilstdlib_link_cells(CellBase *parent, CellBase *child)Create explicit parent-child relation. Equivalent to list-push.:invalid-cell if parent or child is null
unlink-cell(cell) → nilstdlib_unlink_cell(CellBase *cell)Remove cell from its parent and sibling chain. Cell becomes orphaned but not destroyed.:invalid-cell if cell is null or has no parent
Lisp nameSignatureC referenceSemantic contractRaises
lfsr-seed(seed) → nilstdlib_lfsr_seed(SystemState *state, uint32_t seed)Initialize the LFSR with a 32-bit seed (typically seeded from deck state or mission parameters).
lfsr-next() → integerstdlib_lfsr_next(SystemState *state)Advance LFSR, return next pseudo-random 32-bit value (0–4294967295).
lfsr-range(min max) → integerstdlib_lfsr_range(SystemState *state, uint32_t min, uint32_t max)Return pseudo-random integer in [min, max] inclusive. Accepts negative ranges.:invalid-range if min > max
lfsr-shuffle(array-length) → nilstdlib_lfsr_shuffle(SystemState *state, void **array, int count)Fisher-Yates shuffle of array in-place. Note: Lisp can’t directly mutate arrays; this is lower-level. May need wrapper design (see unknowns).:invalid-array if array is null

Deck State Access (Read-only in this tier; mutations in Mission-context tier)

Section titled “Deck State Access (Read-only in this tier; mutations in Mission-context tier)”
Lisp nameSignatureC referenceSemantic contractRaises
deck-state() → deck-state-objectstdlib_deck_state(SystemState *state)Get reference to universal deck state. Object has fields: :handle, :credits, :reputation, :cartridge-history, :phase-chain.
get-handle() → string(Synthesized: deck_state()->handle)Get operator’s handle (callsign). Max 16 chars. Read-only.
get-credits() → integer(Synthesized: deck_state()->credit_balance)Get current credit balance. Read-only.
get-reputation() → integer(Synthesized: deck_state()->reputation)Get current reputation points. Read-only.
has-capability(bit-index) → boolstdlib_has_capability(SystemState *state, uint8_t bit)Test if cartridge capability bit is set (0–31). cartridge_history is a 32-bit bitmask tracking which modules have been completed.:invalid-bit if bit ≥ 32
Lisp nameSignatureC referenceSemantic contractRaises
draw-threat-bar(level max-level col row) → nilstdlib_draw_threat_bar(uint8_t level, uint8_t max_level, uint8_t col, uint8_t row)Draw threat bar: level/max_level filled blocks (■) out of max_level total. Max 16 blocks. Placed at (col, row) in text grid.:out-of-range if max_level > 16 or col+max_level > 80
draw-progress-bar(percentage col row width) → nilstdlib_draw_progress_bar(uint8_t pct, uint8_t col, uint8_t row, uint8_t width)Draw progress bar: pct% filled (0–100). width: 1–32 chars. Placed at (col, row) in text grid.:invalid-percentage if pct > 100; :invalid-width if width > 32 or col+width > 80
draw-bordered-box(col row w h title) → nilstdlib_draw_bordered_box(uint8_t col, uint8_t row, uint8_t w, uint8_t h, const char *title)Draw a box outline with optional title. w, h: 3–77, 2–24. title: optional, centered.:out-of-range if col+w > 80 or row+h > 25; :invalid-size if w < 3 or h < 2

Tier 2: Mission-Context Primitives (Active phase handler only)

Section titled “Tier 2: Mission-Context Primitives (Active phase handler only)”

These are available only when a mission phase is active and the handler is executing in mission context. Calling from a non-mission context raises :not-in-mission.

Lisp nameSignatureC referenceSemantic contractRaises
phase-advance() → nilnosh_phase_advance() (synthesized)Advance to next phase in the mission phase chain. Triggers ON_EXIT in current phase, ON_ENTER in next. If this is the last phase, calls mission_complete.:not-in-mission if not in active phase context; :no-next-phase if already at last phase
mission-complete() → nilnosh_mission_complete() (synthesized)Mark mission complete, award payout, advance deck state. Pops mission context, returns to mission board.:not-in-mission if not in active phase context
award-credits(amount) → nilstdlib_credit_add()Add credits to deck balance. amount can be negative (penalty).
modify-reputation(delta) → nilstdlib_rep_modify(SystemState *state, int16_t delta)Modify reputation by delta (positive = gain, negative = loss). Clamped at [0, 32767].
Lisp nameSignatureC referenceSemantic contractRaises
set-phase-data(key value) → nil(Synthesized: mission context dict)Set arbitrary key-value pair in current phase’s persistent data. Survives phase boundaries within a multi-phase mission.:not-in-mission if not in active phase context
get-phase-data(key) → value(Synthesized: mission context dict)Retrieve phase data by key. Returns nil if not set.:not-in-mission if not in active phase context
set-capability(bit-index) → nilstdlib_set_capability(SystemState *state, uint8_t bit)Set cartridge capability bit (0–31). Marks this module as completed.:invalid-bit if bit ≥ 32; :not-in-mission if not in active phase context

Tier 3: REPL-Read-Only Primitives (Player-facing Lisp REPL, ADR-0002)

Section titled “Tier 3: REPL-Read-Only Primitives (Player-facing Lisp REPL, ADR-0002)”

Available in the built-in Lisp REPL only (read-only inspection). No mutations. Subset of Tier 1 + Tier 2 read operations.

CategoryPrimitives
Display (read-only)display-mode, get-handle, get-credits, get-reputation, has-capability
Navigation (inspect)current-cell, list-get, list-length, is-leaf, deck-state
Procedural (generate)lfsr-seed, lfsr-next, lfsr-range
ForbiddenAll mutations: text-*, gfx-*, sound-*, spawn-cell, destroy-cell, drill-into, navigate-back, phase-advance, mission-complete, award-credits, modify-reputation, set-capability

Rationale: REPL is for exploration, debugging, and scripted automation (ADR-0002). Mutations would corrupt mission state. Sound and display in REPL are out of scope for MVP.


C TypeLisp RepresentationNotes
voidnilNo return value
bool#t or #fTrue/false
uint8_t, uint16_t, uint32_tinteger (0–2^N-1)No explicit type; range-checked in C
int8_t, int16_t, int32_tintegerSigned; range-checked in C
float, double(not supported in MVP)Deferred to Phase 2 if needed
const char * (null-terminated)stringImmutable; null-term guaranteed by C side
CellBase *cell (opaque object)Lisp cannot inspect fields directly; only pass to cell functions
CellList(via list-* functions)Lisp sees lists as parent-child relations, not explicit list objects
DeckState *deck-state-object (record)Lisp reads fields via get-* functions or accessor pattern

Dispatch Contract: Handler execution model

Section titled “Dispatch Contract: Handler execution model”

When a cell event fires (e.g., player presses CAR key on contract cell):

  1. Runtime looks up cell type → finds handler
  2. If handler is C function pointer (runtime cells): call directly, blocking
  3. If handler is Lisp lambda reference (cartridge cells): marshal args, invoke Fe evaluator, return result
  4. Dispatch latency is transparent: Lisp handlers have same latency contract as C handlers (5 ms target, 10 ms ceiling)

This is the tagged-union approach from ADR-0001. Cell grammar (v2.0 spec) will define how handlers are authored in Lisp; the FFI layer is agnostic to their origin.


v1.1 cartridge grammar used C macros (e.g., spawn_cell(typename)) for compile-time type safety. In Lisp:

  • No compile-time type safety; we rely on runtime argument validation
  • Each primitive validates arg count and types at invocation
  • Example: (spawn-cell 'contract) — symbol ‘contract is looked up in type registry at runtime

With arena allocation (Fe VM, no GC):

  • Cell allocation is fast (bump allocator)
  • Cell lifetime is bounded by mission instance or cartridge load boundary
  • If a cartridge spawns cells and resets the arena, all cells are implicitly freed
  • This is safe as long as cartridges don’t hold references across arena resets
  • The mission-phase boundary is the natural reset point

C strings are null-terminated; Lisp strings are Fe’s native string type (immutable). FFI:

  • Cartridge Lisp → C: Lisp string marshaled to C const char * (pointer + length)
  • C → Lisp: C string (char *) wrapped in Lisp string object
  • Max string length in cartridge: limited by arena size; typically 256–1024 bytes per string

  1. Array mutation in Lisp: lfsr-shuffle mutates a C array in-place. Lisp is functional; how do we expose this cleanly? Possible solutions:

    • Design a Lisp-side array type that FFI can mutate
    • Or remove shuffle, provide shuffle-based helpers in Lisp stdlib instead
  2. Error handling: how deep are error traces? If a Lisp handler calls text-puts with invalid args, does the error propagate to the player? Or silent clamping? Current design: silent clamping (defensive). May need refinement based on gameplay patterns.

  3. Mission phase context: “not in mission” errors are runtime only. Cartridge author cannot check at load time. Documentation and testing are critical.

  4. Cipher integration: ADR-0002 scopes Cipher (voice NPC) as a Lisp-integrated system. Cipher handlers are Lisp lambdas too. They’re not explicitly listed here — they’re instances of Tier 1 (all-carts) primitives called from Cipher mission context.


54 primitives enumerated across three tiers. All map cleanly to existing C APIs (nosh.h, nosh_stdlib.h, nosh_runtime.h). No new C functions required — just FFI wrapping. Lisp cartridges author in kebab-case; C side is snake_case (conventional).

Ready for FFI binding implementation (Phase 2 action item) and Fe integration prototype.


2026-04-24 — Bitmap-FFI bounds retarget per ADR-0014 F4

Section titled “2026-04-24 — Bitmap-FFI bounds retarget per ADR-0014 F4”

Status effect: Accepted (unchanged). Amendment pattern follows the ADR-0006 precedent (2026-04-22): new **Amended:** header line + this log section + in-place contract updates; no change to Decision or Options Considered.

Rationale. ADR-0014 (Accepted 2026-04-22) redefined the KN-86’s canonical logical framebuffer from an implicit 1024×600 (which was never implemented cleanly — see ADR-0014 §Context) to a 960×600 logical framebuffer integer-scaled into the 1024×600 Elecrow panel with a 32 px horizontal letterbox per side and zero vertical letterbox. Cartridges see 960×600 as their full bitmap canvas; the 32 px letterbox is invisible to them. ADR-0014 action item F4 explicitly required sweeping cartridge-authoring docs to restate the canvas as 960×600. ADR-0005 is the normative FFI contract for cartridge Lisp; it qualifies as the primary cartridge-authoring doc in F4’s scope.

Contract changes. Four cells in the #### Graphics mode table (and its header) were retargeted. Original values shown struck-through; current values are live in the table above.

LocationBeforeAfter
Section header#### Graphics mode (bitmap, 1024×600)#### Graphics mode (bitmap, 960×600)
gfx-pixel contractx: 0–1023, y: 0–599x: 0–959, y: 0–599
gfx-pixel raisesif x ≥ 1024 or y ≥ 600if x ≥ 960 or y ≥ 600
gfx-rect raisesif x+w > 1024 or y+h > 600if x+w > 960 or y+h > 600
gfx-blit raisesif x+w > 1024 or y+h > 600if x+w > 960 or y+h > 600

The uint16_t coordinate type remains correct (960 and 600 both fit in a u16). The vertical axis (0–599, y ≥ 600) is unchanged because ADR-0014’s vertical dimension is unchanged. gfx-line and gfx-circle cite “any coord out of bounds” / “circle exceeds bounds” without naming the numeric limit, so no cell-text change is required; the bounds they reference now derive from the amended gfx-pixel and gfx-rect tables.

Scope of this amendment. This is a value fix to the FFI contract, not a design-commitment change. The FFI’s shape — kebab-case Lisp names, snake_case C references, three capability tiers, 54 primitives — is unchanged. Downstream consumers (cartridge authors, the emulator’s FFI binding implementation, and ADR-0007’s scripted-mission extensions) inherit the tighter x-axis bound automatically; existing cart code that wrote to x ≥ 960 was already out of spec against ADR-0014’s framebuffer even while the ADR-0005 table said otherwise, so the amendment closes an inconsistency rather than creating one.

Authority trail. CLAUDE.md Canonical Hardware Specification (Display modes row: BITMAP (960×600)) and ADR-0014 (Decision, Action Items §F4) are the upstream sources. Prior cartridge-authoring sweeps fixed by Run 2026-04-24T11:17:53Z (see docs/_archive/AUDIT-LOG.md): UI Design System, Null / Drift gameplay specs, Definitive Guide NoshAPI summary, Prototype Architecture bitmap-mode paragraph. ADR-0005 was escalated as E12 in that run and ratified by Josh the same day.