Skip to content

Batch 8 — Architecture Synthesis (Library Shortlist, A4, A5)

Grid canon: 128×75 (ADR-0027 ratified 2026-06-07). TEXT (128×75 cell API) + HALF-BLOCK (128×150 pseudo-pixel). BITMAP / gfx-blit / SPLIT are retired.

This document consolidates three architecture deliverables from Batch 8: Task 3 (the TUI library shortlist verdict), A4 (the two canonical display modes, formalized, with the (draw-keyboard) and (draw-framebuffer) proposals), and A5 (the libuv pattern-only verdict). It is a synthesis of already-merged research entries — it consumes them, it does not re-open them. The grounding decision throughout is ADR-0027: termbox2 is the C-internal display + input substrate, the canonical grid is 128×75, cartridges see a constrained KN-86 cell API (not raw termbox), and the half-block trick gives a 128×150 pseudo-pixel canvas. Read ADR-0027 §“Decision” items 3–6 before this doc.

Source entries:


Task 3 — Library shortlist verdict: borrow patterns, do not adopt

Section titled “Task 3 — Library shortlist verdict: borrow patterns, do not adopt”

The question is settled at the substrate. ADR-0027 already chose termbox2. So Task 3 is not “which TUI library does KN-86 build on” — it is the narrower question: of the broader TUI field (C / C++ / Rust), is there anything to adopt as a dependency, and what architecture model should govern the core/render split?

Verdict: adopt no library beyond the already-ratified termbox2 single-header vendor. Borrow patterns, own the implementation. Every higher-level TUI library on the shortlist either (a) fights KN-86’s bespoke-screen + constrained-FFI model, (b) drags in a language/runtime KN-86 won’t carry (C++17, Rust, Node/React), or (c) duplicates what termbox2 + a thin Fe UI library already gives. Two axes decide nearly every verdict:

  1. C11, no C++ (ADR-0027 §Constraints). This alone reduces every C++ library (FTXUI, imtui, FINAL CUT, Tui Widgets, tvision) to pattern-only at best — none can be a runtime dependency without a C-ABI shim and a C++ toolchain in the Pi system image.
  2. Who owns the screen and input? ADR-0027’s boundary is that the runtime owns termbox; cartridges see a constrained cell API and pre-classified semantic events. Any library wanting to own the event loop, the input modes, or the whole screen collides with that boundary. KN-86 already rejected raw-termbox-to-Fe (ADR-0027 Option B) for exactly this reason; a fatter library claiming the same authority is rejected a fortiori.
LibraryLangModeVerdictWhat to borrow
termbox2C99 / MITimmediate (cell buffer)ADOPT (ratified)— it’s the substrate; it also already diffs the cell buffer, so KN-86 does not re-implement Ink-style output diffing
ncursesCretained-ishSKIPnothing (ADR-0027 §E — re-confirming, not re-deciding)
tuiboxC99 / MITretained (box tree)PATTERN-ONLYUI→Screen→Box region hierarchy; per-region dirty bit
AnbUIC / MITimmediate (dialogs)SKIPminimalist-menu idiom (glance only)
FINAL CUTC++14retainedSKIPnothing
FTXUIC++17 / MITdeclarative/reactivePATTERN-ONLYbox-composition combinators (hbox/vbox/border/flex); focus traversal
imtuiC++ / MITimmediatePATTERN-ONLYimmediate-mode + app-owned-state discipline (the paradigm anchor)
Tui WidgetsC++17retained (Qt-ish)SKIPnothing
RatatuiRust / MITimmediatePATTERN-ONLYstateless-widget + state-struct contract; constraint-split layout; widget catalog

cavacore = the canonical runtime architecture model

Section titled “cavacore = the canonical runtime architecture model”

The most important positive recommendation is not a library — it is a structural pattern KN-86 should adopt by name: the cavacore model from cava. cava split itself into cavacore (pure C logic — FFT, smoothing, bin→bar-height computation, no I/O, no rendering) and a set of thin, swappable output backends (ncurses, raw terminal, SDL, framebuffer) that consume the computed bar heights and draw them. The core knows nothing about how it’s displayed; you re-target the renderer without touching the logic.

This is exactly the discipline KN-86 already commits to, made explicit:

  • Core (C, render-agnostic): mission board, economy (credits/reputation), phase chain, Universal Deck State, CIPHER grammar/voice emission, capability dispatch, the Fe VM. None of it knows whether it’s drawn by termbox2 on a Pi tty1 or by a terminal on a developer’s laptop.
  • Render layer (thin, swappable): termbox2 against the cell grid — one backend, same on device (Linux-console tty1) and emulator (a normal terminal). Audio is independently swappable (SDL audio in the emulator per ADR-0025; PSG→I2S→MAX98357A on the Pico 2 on device per ADR-0017). The backends consume core state; they don’t own logic.
  • The constrained Fe cell API IS the cavacore seam. ADR-0027’s cell API is the cavacore boundary applied to cartridges: carts express what to draw (cells, half-blocks, semantic intent); the runtime decides how it reaches the panel. Swapping termbox2 for a future backend is “a non-event for cartridges” — ADR-0027 says so verbatim.

The cavacore split parallels the capability model one layer up. The capability model is already a core/render split at the cartridge boundary (logic in the runtime, carts as capability modules). cavacore names the same discipline at the runtime/renderer boundary. Naming it guards against the exact failure mode ADR-0027 diagnosed — logic leaking into the renderer (pixel padding, letterbox math, dev-chrome in the device window) — by giving the boundary a name and a doctrine.

The Ink component model: an optional internal Fe library, not the substrate

Section titled “The Ink component model: an optional internal Fe library, not the substrate”

Qualified yes — as an optional internal Fe library, never the cart-facing substrate, never a React/yoga import. The substrate stays imperative and constrained (ADR-0027’s cell API + semantic events), because that boundary protects chrome ownership and input policy and must not be re-opened. Bespoke gameplay carts keep drawing cells directly in immediate mode (the cl-termbox2 “loop, render, yield” shape ADR-0027 modeled cart authoring on, validated by imtui’s immediate-mode + app-owned-state discipline). Structured, chrome-heavy surfaces opt into a component layer that borrows exactly three Ink patterns:

  1. Box-composition layout combinators → integer cell rects. Borrow the mental model (hbox / vbox / pad / align / fixed / fill resolving to integer cell rectangles inside rows 1..73), not yoga flexbox. A fixed 128×75 grid has no responsive reflow and no on-device resize; a full flexbox engine is overkill and yoga is a C++ dependency KN-86 would never vendor. FTXUI (C++ proof that declarative composition is pleasant for fixed grids) and Ratatui’s constraint-split Layout (split a rect by Length/Percentage/Min/Ratio) are the smaller, KN-86-appropriate reference — and the same JoinHorizontal/JoinVertical/Place idea Lip Gloss expresses. A KN-86-scale layout helper is ~1% of yoga’s implementation cost for ~80% of the ergonomics.
  2. Focus management consuming semantic on-key events — the single highest-value idea for a 31-key/no-mouse device. Ink’s useFocusManager contract (focusable elements register, exactly one is active, a directional/Tab input moves focus, the active element gets the key stream) is the natural layer that consumes ADR-0027’s already-classified (:up :down :left :right :enter …) events and decides which sub-widget they act on. It sits between (on-key) and per-widget handlers. This is the most directly transplantable Ink pattern.
  3. Component-level memoization for tree-shaped chrome-heavy surfaces. termbox2 already diffs at the cell-byte level, so KN-86 does not re-implement Ink’s string diff. The only part of the reconciler worth the Fe complexity is the higher diff — “this component’s props didn’t change, so don’t re-run its render lambda.” For a Fe VM under arena discipline (ADR-0004), avoiding re-running render lambdas is the scarce resource, more than avoiding cell writes. It buys little for full-screen gameplay carts that repaint wholesale; it buys a lot for the mission board, REPL, nEmacs, and bare-deck tabs.

Do NOT import React, yoga, or a reconciler. A “Lisp-native Ink” is a worthwhile convenience library, never a requirement and never a retained scene graph the runtime has to own.

ink-ui status-bar = the canonical Row 74 keybind-hint-bar reference

Section titled “ink-ui status-bar = the canonical Row 74 keybind-hint-bar reference”

KN-86’s chrome contract reserves the bottom row as a runtime-owned action bar — under the 128×75 grid that is Row 74 (the superseded 80×25 grid’s “Row 24”). The action bar’s job is exactly what ink-ui’s status-bar does: advertise the currently-available key actions as compact badges. ink-ui renders each hint as an inverse-bold badge (key glyph in inverse video, label adjacent in normal weight) — precisely the idiom a monochrome amber-on-black device wants, because inverse video is the one “second color” a monochrome display gets for free (swap fg/bg → black-on-amber badge against the amber-on-black field; no palette needed, contrast does the work).

Treat ink-ui’s status-bar as the design reference for the Row 74 action-bar spec: badge composition, hint-group spacing, truncation when hints overflow the 128-column width, active/inactive distinction. Directly relevant to the REPL’s permanent Row 74 advertisement (TERM-key surface) and to every cart that needs to surface its current verb set. The runtime owns Row 74, so this is a nOSh chrome reference, not cart-facing. The companion ink-ui idioms — tab-bar (active tab in inverse highlight → bare-deck STATUS/CIPHER/LAMBDA/LINK/SYS tabs), select-input/multi-select (focus-manager consumers → mission-card selection), table (CP437 box-drawing borders → contract lists), text-input (REPL command line; note multi-tap text entry is runtime-owned per ADR-0022/0027 on-multi-tap) — are patterns to re-implement against the cell API, not components to import, but the catalog tells KN-86 which Fe-side widgets are worth building first.

The Batch-1–4 Bubble-Tea / Lip-Gloss (Go) primary-stack recommendation is superseded by ADR-0027 (termbox2 + C with a constrained Fe cell API). Ink’s value here is patterns for an optional Fe UI library, not a framework adoption.


Under ADR-0027 the KN-86 has two canonical drawing surfaces. Every cart screen, widget, attract scene, and ambient generator targets one of these — there is no third mode, and BITMAP / gfx-blit / SPLIT are gone.

ModeGeometryCart-usableFe surfaceUse
TEXT128 cols × 75 rows (8×8 PSF, native)rows 1..73 (row 0 + row 74 are nOSh chrome)cell-set, cell-print, cell-clear-cart-region, cell-cols→128, cell-rows-usable→73All glyph content: menus, lists, tables, prose, box-drawing chrome, the function-block legend
HALF-BLOCK128 × 150 effective pseudo-pixels (U+2580/U+2584 fg/bg pairs, 2 vertical sub-cells per row)128 × 146 (2 sub-rows × 73 usable rows)half-block-set, half-block-rect, half-block-clearPixel-flavored output: boot/attract animation, sprites, fine strokes (bonsai trunk/leaf), waveform/sparkline fills

Both surfaces share one cell grid; they are two interpretations of the same termbox back buffer (a half-block “pixel” is a fg/bg color pair inside a cell). The two A4 primitives below are PROPOSALS for Josh’s decision — not ratified. Each sits at a different altitude relative to the FFI boundary, and that difference is the whole point of the section.

(a) (draw-keyboard …) — PROPOSAL (warrants a future ADR)

Section titled “(a) (draw-keyboard …) — PROPOSAL (warrants a future ADR)”

Source: inspiration/typeinc.md. Typeinc’s signature affordance is an on-screen keyboard widget that mirrors the physical keys and lights up the active/next key. KN-86 has something Typeinc doesn’t: a specific, opinionated, documented physical surface — the Ferris Sweep 34-key split with the function-block legend (ADR-0022 / ADR-0031). A (draw-keyboard) widget renders that specific keyboard on the 128×75 grid, making the synthesis-level claim “the keymap is discoverable in-band” literally true: the deck draws its own input surface back at the operator.

Sketch (names indicative; finalize in the FFI catalog):

(draw-keyboard
:layout :ferris-sweep ; :ferris-sweep (default) | :base | :shift (MO(L1) secondaries)
:legend :function-block ; :function-block (Lisp primitives + digits) | :blank | :char
:highlight '(LAMBDA CDR) ; key ids drawn brightened / inverted
:pressed '(EVAL) ; momentary press feedback (SYS keyboard-test live mode)
:region (rect 1 40 128 30)) ; col row w h within rows 1..73

Rendering model (TEXT mode, 128×73):

  • Two key clusters — left 3×5 + 2 thumb, right 3×5 + 2 thumb — drawn as box-drawing key caps mirroring the physical split, with a gutter between halves. The 128-wide grid is what makes the two-half render legible — under the retired 80×25 grid a full split with both halves and legends would have been cramped; at 128×73 both halves fit comfortably side-by-side with room for a status column. This is a concrete payoff of the ADR-0027 widening, worth citing when the widget is specced.
  • Legend layer writes each key’s printed function into its cap. Per ADR-0031 §3.1 L0 BASE (2026-06-21 home-row revision): left half = the 14 Lisp primitives (NIL / INFO / LAMBDA(FN) / CONS / LINK on top; APPLY / BACK / CDR / CAR / QUOTE on home; SYS / EQ / ATOM on bottom + 2 v2 spares; EVAL on inner thumb, LSHIFT on outer thumb); right half = the digit 3×3 (1-2-3 / 4-5-6 / 7-8-9) on the inner three columns, , . / ; - punctuation + ENT on the outer two columns, 0 on inner thumb, TERM on outer thumb. Press Start 2P at native 8×8 (ADR-0027) means a 3-char legend (CDR, NIL, ENT) fits inside a key cap cleanly. :layout :shift redraws the MO(L1) shift-secondary legends (` ! @ $ % ^ * & # : + ( ) \ ").
  • States as intensity + inversion, never a second hue (the deck’s only color is amber #E6A020 on black): idle = dim cap outline + mid-amber legend; :highlight next-key = brightened amber legend; :pressed active = inverted cell pair ((cell-set col row ch +black+ +amber+) for a pressed cap vs +amber+ +black+ idle). The inversion treatment is free on the cell API; no hue, no compromise to the monochrome rule. (Cross-link effect/ascii-effects.md brighten for the highlight treatment.)

Uses: in-band keymap discovery (forget where CDR lives → help chord → deck draws the labeled keyboard, no paper card); SYS keyboard-test (draw the split, light each physical key on press — Typeinc’s light-up behavior repurposed as the canonical bring-up/field diagnostic); first-boot and REPL/nEmacs tutorials (highlight the next key — “press LAMBDA, left home, index”); showing cart key bindings (a cart that layers contextual bindings on TERM calls (draw-keyboard :highlight …) to show them on the real layout instead of as text).

The widget is read-only chrome. It draws the input surface; it does not capture input. Input policy stays in nOSh (ADR-0027 §“Decision” item 5); (draw-keyboard) is a renderer the runtime feeds with already-classified key state. Treat it as a full-region-or-overlay affordance (help overlay, SYS test, tutorial step), summoned and then yielded — Typeinc’s own author drops the keyboard widget when space is tight (typeinc-mini), so it earns its space when called, not as persistent chrome.

Why this warrants a future ADR: (draw-keyboard) is a new FFI primitive, not sugar over an existing one. It adds a cart-facing surface, it encodes the canonical keyboard layout as runtime data (which means the layout becomes a versioned dependency of the FFI), and its legend/highlight/pressed semantics are a contract cart authors and the SYS-test path both depend on. Adding it is an ADR-0005 amendment (a primitive-count change with a documented contract), exactly the class of change ADR-0035 / ADR-0034 / ADR-0033 each took to a short ADR. Recommend: a dedicated ADR (or a tightly-scoped ADR-0005 amendment) defining the (draw-keyboard) signature, the layout-id enum, the legend modes, and the highlight/pressed state vocabulary, cross-referenced to ADR-0022/0031 as the canonical legend source so the legend content is referenced, not restated (Spec Hygiene Rule 1).

(b) (draw-framebuffer pixels) — PROPOSAL: reconcile as half-block sugar, NOT a new FFI surface

Section titled “(b) (draw-framebuffer pixels) — PROPOSAL: reconcile as half-block sugar, NOT a new FFI surface”

The original A4 brief imagined a (draw-framebuffer pixels) primitive that blits a packed pixel buffer to the screen. Reconcile, do not duplicate. The surface it would have duplicated — the BITMAP / gfx-blit pixel framebuffer (ADR-0014’s 960×600 canvas with gfx-pixel/gfx-line/gfx-rect/gfx-circle) — is gone. ADR-0027 retired BITMAP mode on ratification, and ADR-0005’s 2026-06-07 amendment removed those ~25 cell/sprite/cursor/viewport/bitmap primitives outright. A fresh full-framebuffer primitive would re-introduce exactly the pixel surface ADR-0027 just deleted.

Spec it instead as sugar over half-block-set — a sprite-from-bytes helper that blits a packed byte-array into the 128×150 half-block canvas (cart-usable 128 × 146):

  • It is a convenience wrapper, not a new FFI primitive: it unpacks a byte array and calls half-block-set / half-block-rect underneath. It adds zero new authority and touches nothing outside the cart-usable half-block region (chrome rows stay untouchable by construction).
  • It belongs inside ADR-0027’s already-planned half-block helper Fe library (ADR-0027 §“Follow-on work” item 6: “Half-block rendering helpers — Fe-side library … pixel, line, rect, sprite-from-bytes-with-half-block”). (draw-framebuffer) is that “sprite-from-bytes-with-half-block” helper, named. It is Fe-side library code over the existing primitive, not a C-side FFI addition.
  • Validated by php-gameboy (R6): a real .gb ROM rasterized to a terminal cell grid (Braille 2×4 sub-pixels in its case; KN-86’s v0.1 path is half-block ▀▄ until braille lands in character-set.md Layer 2) — empirical proof that a packed-pixel-buffer → cell-grid blit pipeline runs a real handheld game at speed, monochrome by construction. That is precisely the pipeline (draw-framebuffer)-as-half-block-sugar provides.

Why this does NOT warrant a new FFI ADR. Because it is Fe-side sugar over half-block-set, it lands inside the half-block helper library that ADR-0027 already commissioned — no FFI-surface change, no primitive-count delta, no chrome/authority concern. It is library work, not an architecture decision. (If, during implementation, a measured hot path argues for a C-side packed-blit primitive for throughput, that would be the moment for an ADR-0005 amendment — but the default and correct first form is Fe-side sugar.)

A4 summary: which primitives warrant an ADR

Section titled “A4 summary: which primitives warrant an ADR”
ProposalFormFFI impactADR needed?
(draw-keyboard …)new cart-facing primitive; encodes the canonical layoutadds a primitive + a layout-id dependency to the FFIYes — dedicated ADR or scoped ADR-0005 amendment (legend content referenced to ADR-0022/0031, not restated)
(draw-framebuffer pixels)Fe-side sugar over half-block-setnone — lives in the ADR-0027 half-block helper libraryNo — library work; ADR only if a measured hot path later forces a C-side packed-blit primitive

Source: research/libuv.md. libuv is the cross-platform async-I/O event-loop library under Node.js: a single loop thread (epoll/kqueue/IOCP) plus a small worker thread pool for blocking ops. The A5 question: should it be KN-86’s C-level event substrate?

Verdict: pattern-reference, not adoption. Do not link libuv. The four-part argument:

  1. The networking half is unused. KN-86 has no real sockets in the gameplay surface — the LINK protocol is local fiction rendered on-device. Firmware update (ADR-0011) is the only real network path and it is an out-of-band system-image operation, not a runtime concern. Half of libuv’s value proposition is dead weight.
  2. The realtime half is already offloaded to the Pico 2. Audio (PSG/I2S) and the CIPHER-LINE OLED ticker live on the Pico 2 (ADR-0017) — specifically to keep realtime work off Linux’s non-realtime scheduler. The Pi’s event loop never touches them; the UART backchannel is one low-rate fd. The handles libuv would model (uv_async_t for audio, a write handle for the OLED) have nothing to do on the Pi.
  3. The thread pool solves problems KN-86 doesn’t have — and breaks Fe’s single-threaded arena. libuv’s headline value over a bare poll() is its worker pool. But audio is on the Pico (a thread pool would re-import the exact underrun-jitter problem ADR-0017 solved by moving synthesis to dedicated silicon), and procgen is LFSR-cheap and arena-bound by design (ADR-0004 — no GC, arena reset at boundaries, single-threaded invariant). Touching the Fe arena from a worker thread breaks that invariant; the honest path for a genuinely expensive generation step is to chunk it across frames on the main loop (Update-Method pattern), not to thread it. Both problems the pool exists to solve are absent or already solved.
  4. termbox2 already owns the loop. Per ADR-0027, termbox2 owns the display present-path and the input-fd wait (tb_poll_event). KN-86 waits on ~three fds total — input, the UART backchannel, occasional load-boundary file I/O. The frame-budget cadence is a timer (15–30 fps cap, event-idle most of the time), not fd-readiness. Adding libuv means running two loops (libuv + termbox) or rewriting termbox’s input path onto libuv handles — strictly more complexity for a loop whose real job is already owned.

Take the pattern, documented: a single event loop, typed event sources, callbacks that classify and dispatch into the higher-level interpreter and run to completion within the frame budget before yielding — and a bounded worker pool held in reserve for the rare genuinely-blocking op. The composition libuv would impose is structurally identical to what KN-86 already runs:

[ libuv loop ] [ KN-86 today (ADR-0004/0005/0027) ]
uv_run(loop) main loop: tb_poll_event() + frame timer
→ handle ready → semantic input event (nOSh-classified)
→ C callback → C dispatch
→ Fe handler (sync, in-frame) → Fe handler (sync, in-frame, arena-bound)
→ yield to loop → yield; redraw if dirty

libuv would be a heavier engine under the same shape, not a new capability. The existing tb_poll_event + frame-timer + load-boundary-synchronous-read model is correct, smaller, and matches the project’s single-threaded-by-design / realtime-on-the-Pico posture. Revisit only if a measured, on-hardware, frame-stalling blocking op appears that can’t be chunked across frames (e.g. a large cart asset decompressed at load that measurably stalls the frame) — at which point the reserved bounded worker pool is the answer.


  • ADR-0027 — termbox2 substrate, 128×75 grid, constrained Fe cell API, 128×150 half-block canvas, §“Follow-on work” item 6 (the half-block helper library that (draw-framebuffer) belongs to). This synthesis confirms and extends ADR-0027; it does not re-open it.
  • ADR-0005 (FFI surface, amended 2026-06-07 per ADR-0027) — the 54→~30 primitive reduction; the home for a future (draw-keyboard) amendment.
  • ADR-0022 / ADR-0031 — the canonical Ferris Sweep layout + function-block legend the (draw-keyboard) widget renders (legend content lives there, not here — Spec Hygiene Rule 1).
  • ADR-0017 (Pico 2 coprocessor) — why the realtime half of libuv is irrelevant: audio + OLED are off the Pi.
  • ADR-0004 (Fe VM arena discipline) — the single-threaded arena invariant that makes a thread pool a hazard, and the “chunk expensive work across frames” answer.
  • Source entries: research/tui-library-shortlist.md, research/ink.md, research/ink-web.md, inspiration/typeinc.md, effect/cbonsai.md, research/libuv.md.