ADR-0027: termbox2 as the primary display + input layer (spike-gated)
Context
Section titled “Context”The KN-86 is a text-grid device. Every screen the operator ever sees — boot splash, mission board, LINK protocol, REPL, nEmacs, all 17 launch-cartridge UIs — is built from monospace cells, attributes, and a cursor. Yet the current rendering stack is a hand-rolled SDL3 pixel framebuffer: display.c walks a 960×600 logical canvas, font.c/font_12x24.c carry two parallel glyph tables, display_profile.c computes letterbox offsets, dev_overlay.c uses #error checks to keep dev chrome off Row 0/Row 24, and the desktop emulator window stacks a main panel + OLED preview + keyboard overlay into a 1024×980 SDL window.
That stack has bitten us repeatedly:
- The Elecrow 7” panel is 1024×600 but the canonical framebuffer is 960×600, requiring a 32 px horizontal letterbox per side as “canon.” Self-imposed.
- Press Start 2P (8×8 source) renders into a 12×24 cell with non-uniform 1× horizontal / 2× vertical scale, distorting the font’s identity. ADR-0014 added an opt-in 12×24 native glyph table (
KN86_FONT_12X24=1) that is a redrawn approximation, not Press Start 2P. - The kiosk service (
kn86-nosh.service) launches the SDL window at 1024×980 to host OLED + keyboard preview; on the device withSDL_VIDEODRIVER=kmsdrmand a 1024×600 panel, the renderer scales the 980 logical px down to 600 physical, squishing the main framebuffer to roughly the top 367 px and leaving the bottom of the panel black-but-rendered. (Diagnosed on hardware 2026-04-29 from operator photo; root cause is the dev-emulator chrome bleeding into the device window layout, not row count or font scale.) - Every cartridge screen is a custom draw, but cartridge authors must reason about pixel padding, cell bytes, and Row 0/Row 24 contracts because the runtime exposes a pixel framebuffer beneath a thin cell veneer.
- ADR-0014’s 80×25 grid pins every gameplay spec, screen wireframe, and
#errormacro to a row count chosen to match a self-imposed framebuffer dimension that has nothing to do with the physical panel.
The Common Lisp port of termbox2 (cl-termbox2) demonstrates the authoring shape we want from cartridge code: a working snake game in ~80 lines that includes the full game loop, rendering, and input handling, with no display plumbing. We do not, however, want to give cartridges that level of authority over the device — see §“Decision” item 5.
The KN-86 should be authored at the shape of the snake listing while keeping presentation and input policy in the runtime. The cartridge surface is a constrained cell-and-event API; the C runtime owns termbox.
Forcing functions
Section titled “Forcing functions”- The device-side display bug surfaced on 2026-04-29. The kiosk window-size logic embeds dev-emulator chrome below the main panel. Fixing it inside the SDL pixel-framebuffer architecture is one more round of profile-and-letterbox spelunking. Replacing the architecture solves the bug class, not just this instance — if termbox2 on tty1 turns out to be operationally viable on Pi Zero 2 W. The “if” is exactly what the spike resolves.
- The 12×24 / 8×16 font dispute hasn’t resolved cleanly. ADR-0014 picked a 12×24 logical cell, ADR-0014 F1 added a 12×24 native-glyph opt-in flag, and the operator-visible result still doesn’t read as Press Start 2P. A console-PSF font path renders Press Start 2P at its real 8×8 proportions with no scale arithmetic.
- NoshAPI’s 54 primitives are mostly display plumbing. Of the ADR-0005 surface, ~25 primitives concern cells, sprites, viewport, cursor, and blit operations. A constrained KN-86 cell API on top of termbox2 collapses that to ~12 builtins.
- Q4 2027 ship target. Cutting the display stack 18 months out is cheap. Cutting it three months pre-ship while debugging hardware bring-up is not. The cost window is now.
Constraints
Section titled “Constraints”- The Elecrow 1024×600 panel is fixed. Aspect ratio 128:75 (≈ 1.7067:1; a non-standard “WSVGA” aspect, neither 16:9 nor 16:10). Both axes are divisible by 8, which is why the 8×8 PSF font lands cleanly. Every cell-size + grid-count must derive from the panel, not the other way around.
- Press Start 2P at 8×8 is non-negotiable. The font is a load-bearing aesthetic. Any path that stretches it loses the device’s identity.
- C11. No C++. termbox2 is a single-file C99 library, MIT-licensed.
- CIPHER-LINE OLED is unchanged. ADR-0015 — separate physical display, separate render path on the Pico 2 coprocessor. termbox2 owns the main panel only.
- Audio is unchanged. PSG → I2S → MAX98357A on device (Pico 2-driven), SDL audio on the desktop emulator. Independent of display. ADR-0025’s SDL3 dependency is retained for emulator audio.
- Fe interpreter and
.kn86container stay. Cartridge code is still Fe Lisp source in a.kn86blob, tree-walked on load (ADR-0001, ADR-0004, ADR-0006; no bytecode — see the ADR-0004 2026-06-14 amendment). The container’s sprite/PSG-blob sections become deprecated bytes on ratification but the format version is unchanged. - Input policy stays in nOSh, not Fe. The 31-key semantics, hold detection, multi-tap timing (ADR-0022), TERM key context dispatch, global runtime chords, and easter-egg recognition (e.g., the SYS+INFO×4 legacy-terminal trigger from ADR-0021) are system concerns. Cartridges receive already-classified semantic events. Cartridges never see raw
tb_eventstructs.
Decision
Section titled “Decision”Adopt termbox2 as the C-internal cell-grid display and input layer for the nOSh runtime, expose a constrained KN-86 cell API (not termbox itself) to Fe Lisp cartridges, and make the SDL display path’s retirement contingent on a spike that passes hard ratification gates on real Pi Zero 2 W hardware. SDL3 is retained for desktop-emulator audio output only; on the device, audio remains Pico 2-driven via I2S.
Concretely:
-
Vendor termbox2 in tree. Land termbox2 as
vendor/termbox2/termbox2.h(single-header, MIT). No system-package dependency. License notice carried inLICENSE-third-party.md. -
Spike binary
kn86-nosh-tb(working name) that links Fe + the KN-86 cell API + termbox2. Built by a sibling target inkn86-emulator/CMakeLists.txt. Initial scope: render the bare-deck LINK tab, a Snake-equivalent demo cart, and the REPL surface. Once that passes the gates in §“Ratification gates,” this binary replaces the SDL display path and the SDL renderer/font/profile sources are deleted. Until then,kn86-nosh-tbships alongside the SDL path; no SDL display code is deleted before the gates pass. -
Canonical text grid: 128 cols × 75 rows. This is what 8×8 PSF on a 1024×600 framebuffer console actually gives you. No constrained viewport, no 12×12 cell math, no fight with fbcon. The cartridge surface (see §item 5) exposes
(cell-cols)returning 128 and(cell-rows-usable)returning 73 (two rows of chrome — top status, bottom action — are owned by nOSh and not addressable by carts). -
Half-block “pseudo-pixel” canvas: 128 × 150. Cartridges that want pixel-flavored output (boot animations, attract scenes, gameplay sprites) use the U+2580 / U+2584 half-block characters with foreground/background color pairs to give 2 vertical sub-pixels per cell — 128 cols × 75 rows × 2 vertical sub-cells = 128 × 150 effective pixels. (CGA’s 160×100 mode used the same trick at a different grid; we lose CGA-faithfulness here, gain hardware truth.) This replaces ADR-0014’s BITMAP mode (960×600 pixel canvas) on ratification.
-
Cartridge FFI is a constrained KN-86 cell API, not raw termbox. The Fe-visible surface is bounded so cartridges cannot clear the whole screen, write to chrome rows, change input/output modes, take over the cursor, or otherwise break runtime presentation. The C side binds termbox directly; Fe sees only:
Fe builtin Behavior (cell-set col row ch fg bg)Write a single cell. Runtime drops writes to row 0 and row (cell-rows) - 1(chrome rows) silently and increments acart_chrome_violationcounter for diagnostics.(cell-print col row fg bg str)Same bounding as cell-set; clipped at column boundaries.(cell-clear-cart-region)Clears rows 1..73 only. Never touches chrome. (cell-cols)Returns 128 (full cols). (cell-rows-usable)Returns 73 (rows 1..73 inclusive). (half-block-set x y on)Writes a half-block sub-pixel within the cart-usable canvas (128 × 146 effective; 2 sub-rows × 73 usable rows). (half-block-rect x y w h on)Filled rectangle in half-block sub-pixels. (half-block-clear)Clears the cart-usable half-block canvas only. (present)Marks the frame ready. The runtime may auto-present at frame-loop boundaries — cartridges should treat presentas advisory, not mandatory.(yield)Returns control to the runtime so it can poll input and dispatch events. Not exposed to Fe:
tb_init,tb_shutdown,tb_set_input_mode,tb_set_output_mode,tb_set_cursor,tb_hide_cursor, rawtb_poll_event/tb_peek_event,tb_clear(whole-screen clear). The runtime owns those. A misbehaving cart can waste CPU but cannot break the device’s presentation contract. -
Input is a runtime concern. Cartridges receive semantic events, never raw
tb_event. nOSh runs the termbox event loop in C, classifies key events through the 31-key map, runs hold-time detection, multi-tap state (ADR-0022 phone-layout numpad cycling — ABC2-style), TERM key context dispatch (ADR-0021 legacy-mode chord, ADR-0016 REPL-context routing), and global easter-egg recognition. It then dispatches classified events to cartridges via Fe callbacks:Fe callback (cart-defined; runtime invokes) Payload (on-key key-symbol)key-symbolis one of:up :down :left :right :enter :back :info :sys :term :lambda :eval :linkor:numpad-0…:numpad-9(already classified by the 31-key map)(on-multi-tap key-symbol char-symbol position-int)For numpad text input (ADR-0022 multi-tap). position-intis the cycle index.(on-hold key-symbol duration-ms)Fired when a key has been held past the hold threshold (default 500 ms; ADR-0016). (on-resize cols rows)Fired on terminal resize (mostly relevant to the desktop emulator; the device panel is fixed). (on-tick frame-num)Per-frame tick (target 30 Hz on device, configurable on emulator). Privileged inputs (TERM context dispatch, global SYS+INFO×4 legacy-mode trigger, hidden runtime chords) are intercepted by nOSh before the cart sees them. A cart cannot bind
:sysor read multi-key chord state directly; those belong to the device, not the program running on it. -
Press Start 2P as the Linux console PSF font on device. The system image (pi-gen
stage-kn86-runtime) ships apress-start-2p.psfrendered from the 8×8 bitmap source.setfontruns at boot from a systemd unit. The device boots into a Linux framebuffer console (kmscon or fbcon on tty1) with the panel at its native 1024×600 mode — yielding a clean 128×75 grid at 8×8 cells with no viewport math.kn86-nosh.servicelaunches the binary on tty1 (aftersetfonthas set the console font and after the boot splash service exits). No SDL window, noSDL_VIDEODRIVER=kmsdrm. The exact tty1 handoff sequence — getty disable, raw-mode setup, console-blanker disable, recovery-on-crash — is part of the spike scope and is not solved by this ADR. -
Sprite/blob sections of
.kn86deprecated on ratification, not removed. Existing carts still load. Sprite blobs become unreferenced bytes; the loader logs a warning and continues. New carts target the half-block canvas. A future ADR (post-ratification) decides whether to format-bump.kn86to v3.0 to drop the sprite section, or leave it indefinitely as a no-op. -
Migration is one-shot on ratification, not a compatibility shim. Once the gates pass: no
#ifdef KN86_DISPLAY_TERMBOXbranching. The SDL display path is deleted in the same PR that promoteskn86-nosh-tbtokn86-nosh. ADR-0014 is then formally Superseded; the doc-and-content sweep (§“Content blast radius” below) ships in that same PR. Pre-ratification, both paths coexist; SDL is the live runtime.
Ratification gates
Section titled “Ratification gates”The spike (kn86-nosh-tb) must pass all of the following on a real Pi Zero 2 W with the production Elecrow panel before this ADR’s supersession of ADR-0014 takes effect, the doc-and-content sweep ships, or any SDL display code is deleted:
-
Geometry. Real Pi tty1 shows a controlled 128×75 viewport at the intended 8×8 PSF font geometry. No viewport scaling, no letterbox, no off-screen rendering. The bare-deck LINK tab, REPL, and Snake demo all render edge-to-edge with chrome (top + bottom) drawn by nOSh and content drawn by the cart in rows 1..73.
-
Crash recovery. SIGINT, SIGTERM, SIGSEGV, and
kill -9of the nOSh process all leave the terminal in a usable state — no stuck raw mode, no hidden cursor, no scrambled output. The systemd unit’sRestart=on-failurebrings nOSh back without operator intervention. Verified by:kill -9 $(pgrep nosh)from a serial console, observe panel recovery and process restart inside 5 seconds. -
Input fidelity. All 31 keys produce the right semantic event. Hold timing is reproducible (no missed
:on-holdunder load). Multi-tap on the numpad (ABC2 cycling) hits the right character at every position. TERM context dispatch routes correctly to terminal mode, REPL, and CIPHER seed capture. The SYS+INFO×4 legacy-mode chord is detected by nOSh and routed to the legacy-terminal launch path. No cartridge ever observes the chord intermediate states. No dropped or duplicated events measured over a 5-minute scripted input fuzz at the keyboard MCU’s max event rate. -
Redraw performance. Worst-case full-screen update (every cell rewritten with new content + 2× half-block canvas redraw + a 30 Hz frame loop) sustains the 30 Hz target on Pi Zero 2 W without dropped frames or visible tearing. Specific frame budget: ≤ 25 ms per frame at the 95th percentile. Measured by an instrumented spike that logs
tb_presentwall time over a 60-second worst-case test cart. -
Chrome ownership enforcement. A deliberately-misbehaving test cart that calls
(cell-set 0 0 ?X +amber+ +black+)(writing to row 0) and(cell-set 0 74 ?X +amber+ +black+)(writing to the last row) produces zero visible change to the chrome rows. Thecart_chrome_violationcounter increments. The test cart cannot recover the runtime cursor, change input/output modes, or take over presentation. -
Operator judgment. Bare-deck (LINK tab + at least one other tab), the REPL, and a Snake-equivalent gameplay cart all feel better on actual hardware than the SDL versions do today. Josh-judged, not me-judged. This is the only subjective gate; it’s load-bearing and explicit.
If any gate fails, this ADR is reversed before the SDL display path is deleted. The reversal cost is low (the spike binary is removed; SDL stays live; the doc sweep doesn’t ship). If any gate is marginal, the spike continues until the marginal gate is decisively passed or decisively failed. There is no “good enough” middle.
Options Considered
Section titled “Options Considered”Option A: Keep SDL3 pixel framebuffer, fix the kiosk window-size bug (REJECTED)
Section titled “Option A: Keep SDL3 pixel framebuffer, fix the kiosk window-size bug (REJECTED)”Patch main.c:347-350 so the device build computes window height = phys_h only (1024×600) and the OLED + keyboard preview chrome appear only when --dev-chrome is passed. Keep the 12×24 cell, the 960×600 logical canvas, the dual font tables, and ADR-0014.
Solves the immediate “bottom of the screen is black” bug but leaves: (a) the font scale problem (Press Start 2P still stretched 1× horizontal / 2× vertical), (b) the 32 px horizontal letterbox, (c) ADR-0014’s 80×25 grid that under-fills the panel even when correctly rendered, (d) the 54-primitive NoshAPI surface, (e) the entire display.c / font.c / font_12x24.c / display_profile.c stack as ongoing maintenance burden, (f) the dev_overlay #error macros, (g) every future “the display does X weird” bug is one more profile-and-letterbox debug session. Solves an instance, not the class.
Option B: termbox2 + raw 1:1 Fe binding (REJECTED — failed systems review)
Section titled “Option B: termbox2 + raw 1:1 Fe binding (REJECTED — failed systems review)”The first draft of this ADR proposed binding termbox’s full surface (tb-init, tb-shutdown, tb-clear, tb-poll-event, tb-set-input-mode, etc.) directly into Fe, modeled on cl-termbox2.
Rejected because it gives cartridges authority that belongs to the device, not the program: clear-the-whole-screen (including chrome), change input/output modes, take over the cursor, intercept raw key events before nOSh’s TERM/SYS/global-chord dispatch sees them. The cl-termbox2 snake listing is the right authoring shape (game-loop + render + poll, no display plumbing); it is not the right boundary of cartridge authority on a device whose runtime needs to enforce presentation and input policy.
Option C: termbox2 + constrained KN-86 cell API (ACCEPTED, spike-gated)
Section titled “Option C: termbox2 + constrained KN-86 cell API (ACCEPTED, spike-gated)”This ADR. Replaces the entire pixel-framebuffer stack with a cell-grid TUI library that already does what we hand-rolled, but the cartridge surface is a constrained Fe FFI (~12 builtins, bounded to cart-usable rows) and input is dispatched as semantic events. Console PSF font renders Press Start 2P at native 8×8. Half-block trick gives a 128×150 pseudo-pixel canvas. The runtime owns termbox; cartridges see a cell API.
Option D: tuibox (REJECTED)
Section titled “Option D: tuibox (REJECTED)”tuibox is built on termbox2 and adds widgets, mouse callbacks, click handlers, and CSS-style box layout. The KN-86 has no mouse (31 keys), every screen is bespoke draw, and cartridge authoring is “loop, render, yield” — not “declare a button, bind a handler.” tuibox’s value-add is widgets we don’t use; the cost is a wider C surface, more concepts, and a layer that fights bespoke screen designs (mission board, REPL, nEmacs) when they don’t fit a widget tree. Strictly additive complexity for zero gain.
Option E: ncurses (REJECTED)
Section titled “Option E: ncurses (REJECTED)”Mature, ubiquitous, but: (a) terminfo dependency, (b) larger API surface than termbox2, (c) less amenable to single-file vendoring, (d) optimized for terminal compatibility we don’t need — the device targets one terminal (Linux console) and the emulator targets modern terminals only. termbox2’s smaller, modern, MIT-licensed surface is a better fit for a vendored dependency.
Option F: Stay on the pixel framebuffer; rebuild it cleanly (REJECTED)
Section titled “Option F: Stay on the pixel framebuffer; rebuild it cleanly (REJECTED)”Acknowledge that ADR-0014’s 12×24 cell + 960×600 framebuffer is wrong, redesign the pixel stack (1024×600 panel, 8×8 native font, no letterbox, drop the OLED-emulator chrome from the device window) without leaving SDL. This is real engineering — not a fix-the-bug patch — and the result is a correct hand-rolled cell grid on top of a pixel framebuffer. The endpoint is termbox2, written by us, with our bugs and our maintenance burden, in N months. termbox2 already exists and is 7000 lines of battle-tested C.
Trade-off Analysis
Section titled “Trade-off Analysis”Option C (this ADR) wins against Option A (“fix SDL”) on:
- Authoring surface. Cartridges go from 54 NoshAPI primitives + a Row 0/24 contract + sprite-blob discipline to ~12 KN-86 cell builtins + semantic event callbacks. Cartridge code reads at the level of the cl-termbox2 snake listing without giving cartridges the authority that listing assumes.
- Font fidelity. Press Start 2P renders as Press Start 2P — Linux console + PSF, no scale math, no padding centering, no opt-in 12×24 native table.
- Bug class. The kiosk window-size bug, font-scale bug, letterbox debate, and ADR-0014 row-count debate share a root cause (pixel framebuffer pretending to be a cell grid). Replacing the layer makes that class extinct.
- Maintenance. Deletes (on ratification)
display.c,font.c,font_12x24.c,display_profile.c, the bulk ofdev_overlay.c, and the OLED + keyboard preview chrome from the device path. Net code reduction estimated at ~3500 lines.
Option C wins against Option B (“raw termbox to Fe”) on:
- Presentation policy enforcement. Carts cannot clear chrome, change modes, take the cursor, or intercept privileged input. The device behaves like a device, not a terminal that runs Lisp.
- Input policy lives in one place. TERM dispatch, hold detection, multi-tap timing, easter-egg chords — all in nOSh. A cart can’t accidentally swallow a chord nOSh needs to see, and can’t deliberately bypass policy.
- Future-proofing. The cell API is stable across termbox versions or even a future replacement (if termbox2 ever goes unmaintained, swapping the C-side implementation is a non-event for cartridges).
Option C costs us:
- All the unknowns the spike is designed to surface. termbox2 on tty1 as a product runtime is unproven for our use case. Raw mode recovery, getty/systemd handoff, console blanking, cursor state, resize behavior, redraw performance on the Pi Zero 2 W’s GPU-less console — these are surfaces we’ve never had to think about under the SDL/kmsdrm path. The ratification gates are precisely the metering exercise. If the gates fail, we trade known SDL problems for unknown Linux-console problems and have to reverse.
- Pixel-precise visual content. Boot splash, attract demos, and any cartridge that drew at the pixel level (clip animations, sprite blits) must be redone as half-block + box-drawing + glyph compositions. Real content cost. Honest trade.
- Migration blast radius. ADR-0014 superseded, ADR-0005 amended, CLAUDE.md Canonical Hardware Specification updated, every gameplay-spec wireframe rewritten, every screen-design doc revisited — and a real product-redesign pass at the new grid (see §“Content blast radius”). Mitigated by the fact that most of those docs were already wrong on row count after the new grid choice.
The honest cost is the spike’s risk and the content-redesign cost. The honest gain is the bug-class extinction, the font fidelity, and the presentation-policy boundary.
Content blast radius (this is a product redesign, not just a renderer swap)
Section titled “Content blast radius (this is a product redesign, not just a renderer swap)”Moving from 80×25 to 128×75 changes the language of every screen, not just the row numbers. Density, pacing, mission board composition, REPL layout, nEmacs panes, module specs, screenshots in marketing material, prompts that hardcode row references, and generated scene assets all need redesign work — not a search-and-replace.
Estimated scoping (refined during the spike):
- Bare-deck (5 tabs × full-screen): redesign at 128×75 to use the new density meaningfully. ~1 week.
- REPL: scrollback geometry, command line, status — recomposed for the wider/taller grid. ~2–3 days.
- nEmacs: structural editor pane layout (ADR-0008, ADR-0016) re-pitched at 128×73 usable rows. ~1 week.
- Mission board: card-grid composition recosted; mission-card density choices re-evaluated. ~3–5 days.
- 17 launch-cartridge module specs: wireframes audited, row hardcodes removed, density recosted. Bulk pass. ~2 weeks.
- Boot splash + attract demos: rewritten against half-block canvas. ~2 weeks (overlaps the BITMAP retirement cost).
- Marketing screenshots, scene assets, prompt refs: swept after the runtime lands. ~1 week of scattered work.
Total: 6–8 weeks of redesign + content work, parallel-izable across agents. This is the largest content redesign in the project to date and it ships in the same PR that retires the SDL path. The spike doesn’t gate the redesign — design work can happen in parallel — but the redesign doesn’t ship until the spike passes.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Cartridge authoring matches the cl-termbox2 snake-listing shape — game-loop + render + yield, no display plumbing.
- Cartridge authority is bounded. Cannot break chrome, swallow privileged input, or take over presentation.
- Press Start 2P renders as Press Start 2P at native 8×8, on device and in the emulator.
- Canonical grid: 128×75 cells, 128×150 half-block pseudo-pixels — derived from the panel, not negotiated against it.
- NoshAPI shrinks from 54 → ~30 primitives (cell/sprite/cursor/viewport plumbing replaced by the constrained API; deck state, mission board, cipher, capability, audio, save unchanged).
- ~3500 lines deleted from the runtime on ratification.
- Class of bugs eliminated: font scale / letterbox / row count / dev-chrome-on-device.
- Desktop emulator becomes a normal terminal program. Run it in iTerm, kitty, alacritty — no SDL window, no
--scaleknob, no profile selection.
Negative / Accepted costs
Section titled “Negative / Accepted costs”- Spike risk: the gates may fail. termbox2-on-tty1 might not be operationally viable on Pi Zero 2 W. If it isn’t, this ADR is reversed before SDL deletion, the spike binary is removed, and ~2–4 weeks of spike effort is sunk cost. This is the explicit risk the gates are designed to expose early.
- BITMAP mode dies (on ratification). Pixel sprites are gone. Boot splash, attract demos, sprite-bearing carts must be reauthored against the half-block canvas. ~2 weeks of content rework.
- Product redesign at 128×75. 6–8 weeks of design + content work across screens, specs, prompts, and scene assets. Largest single content effort in the project to date.
.kn86sprite/blob sections become dead bytes on ratification. Loader warns and skips. Format-bump deferred to a future ADR.- ADR-0014 Superseded; ADR-0005 amended. Every gameplay spec with row-count hardcodes revisited.
- Pi-side boot path changes. No more SDL kmsdrm; nOSh launches on tty1 with a console PSF font. Boot splash service rewritten as a tty1 splash.
kn86-nosh.serviceEnvironment line dropsSDL_VIDEODRIVER=kmsdrm. The exact tty/getty/raw-mode interaction is part of the spike scope. - Terminal abstraction surface we haven’t fully metered. Resize, tty modes, redraw cost on the Pi’s GPU-less console — all spike-scope.
- One-way migration on ratification. Once SDL deletion lands, reverting is real archaeology.
Follow-on work this ADR creates
Section titled “Follow-on work this ADR creates”- Spike:
kn86-nosh-tbbinary + the six ratification gates. (Notion task, spawned after this ADR merges; this is the operative work, not a side quest.) - Press Start 2P PSF font generation — convert the 8×8 source to Linux console PSF format; ship in the system image.
- Constrained KN-86 cell API spec — separate document; the table in §“Decision” item 5 is the seed.
- Semantic input event spec — formalize the
(on-key … )/(on-multi-tap … )/(on-hold … )/(on-resize … )/(on-tick … )callbacks as a versioned FFI surface. kn86-boot-splashrewrite — tty1 splash via console writes, not SDL.- Half-block rendering helpers — Fe-side library for the 128×150 pseudo-pixel canvas (pixel, line, rect, sprite-from-bytes-with-half-block).
- Cartridge migration for boot/attract demos — rewrite the SDL-sprite-bearing demos against half-block.
.kn86sprite-section deprecation — loader warning + planned v3.0 format bump (separate ADR).- Spec-and-content redesign sweep — bare-deck, REPL, nEmacs, mission board, module specs, marketing assets at 128×75. ~6–8 weeks parallel-izable.
Documentation Updates (REQUIRED on ratification — not aspirational)
Section titled “Documentation Updates (REQUIRED on ratification — not aspirational)”Ratification update (2026-06-07): With ratification by decision, this checklist is now live. The canonical-declaration items land immediately in the docs PR (CLAUDE.md, this ADR, ADR-0014 banner, ADR README, ADR-0005 amendment, and the spec/reference docs that declare the grid). The code items (kn86-emulator/*, termbox2 vendoring, kn86-nosh-tb, system-image / boot changes) and the per-screen redesigns (orchestration, bare-deck, REPL, nEmacs, mission board, all module wireframes, ui-patterns, clip-system) are the 6–8-week redesign tracked in the nOSh re-flow and cartridge re-flow tasks — they do not block the canonical declaration. Boxes checked below are done in the canonical-declaration PR.
-
CLAUDE.md— Canonical Hardware Specification updated 2026-06-07:Text grid (primary)→128 columns × 75 rows;Usable content area→128 × 73(rows 1..73);Font cell→8×8 pixel bitmap, rendered native;Display modes→TEXT (128×75 cells), HALF-BLOCK (128×150 pseudo-pixels); 960×600 / 12×24 / letterbox language removed; Spec Hygiene Rule 4 rewritten to cell-API getters (with the types.h tracked-deviation note); Rules 5 & 6 row numbers updated to Row 0 / Row 74 / 128×75. -
docs/adr/README.md— ADR-0014 row flipped to Superseded by ADR-0027; ADR-0027 row marked Ratified. -
docs/adr/ADR-0014-display-profile-redesign.md— Status banner:Superseded by ADR-0027; pointer at top. -
docs/adr/ADR-0005-ffi-surface.md— amendment section noting the 54 → ~30 primitive reduction and the constrained cell API (ratification 2026-06-07). -
docs/software/runtime/orchestration.md— replace pixel-framebuffer + Row 0/24 contract with cell-grid + chrome-ownership convention. -
docs/software/runtime/bare-deck-terminal.md— recomposed at 128×73 usable rows. -
docs/software/runtime/bare-deck-content-brief.md— STATUS / CIPHER / LAMBDA / LINK / SYS tab content recosted at 128×73. -
docs/software/runtime/cipher-voice.md— cross-link the main-grid change (CIPHER stays OLED-only). -
docs/software/runtime/repl.md— REPL geometry recosted at 128×73. -
docs/software/runtime/input-dispatch.md— semantic event model replaces SDL scancode dispatch; the runtime-owned classification path is documented here. -
docs/software/cartridges/authoring/screen-design-rules.md— chrome-ownership convention; usable rows = 1..73. -
docs/software/cartridges/authoring/clip-system.md— clip primitive retired or rewritten against half-block. -
docs/software/cartridges/authoring/ui-patterns.md— cell-API patterns; sprite patterns retired. -
docs/software/cartridges/modules/*.md— every module’s wireframes redesigned at 128×73 (this is the bulk of the content-redesign sweep). -
docs/software/api-reference/grammars/character-set.md— note Press Start 2P now renders via Linux console PSF on device, not via in-tree font tables; remove the 12×24 native-glyph variant. -
docs/software/api-reference/nosh-api/versioning.md— FFI surface delta; version bump rules apply. -
docs/software/api-reference/nosh-api/cell-api.md(new) — the constrained cell API spec. -
docs/software/api-reference/nosh-api/input-events.md(new) — the semantic event spec. -
docs/device/os/system-image-build.md— Press Start 2P PSF generation; tty1 console launch for nOSh;setfontsystemd unit; raw-mode/getty handling (the spike-resolved sequence). -
docs/device/os/boot-and-systemd.md—kn86-nosh.serviceunit changes; OLED-preview / keyboard-preview chrome removed from device window. -
tools/sd-provision/pi-gen-stages/stage-kn86-runtime/.../kn86-nosh.service— dropSDL_VIDEODRIVER=kmsdrm; switch to tty1 launch pattern (lifted from the spike-resolved sequence). -
kn86-emulator/CMakeLists.txt—vendor/termbox2include path;kn86-nosh-tbtarget; SDL3 link retained for audio target only. -
kn86-emulator/src/types.h—KN86_TEXT_COLS/KN86_TEXT_ROWS/KN86_CELL_*/KN86_FRAMEBUFFER_*removed (cells are runtime-queried). -
kn86-emulator/src/main.c— SDL window/renderer/texture/framebuffer init replaced with the termbox bring-up; OLED + keyboard preview chrome moved behind a--dev-chromeflag for the desktop emulator only. -
kn86-emulator/src/display.c,font.c,font_12x24.c,display_profile.c— deleted in the migration PR. -
kn86-emulator/src/dev_overlay.c— Row 0/24#errormacros removed; overlay rebound. -
kn86-emulator/src/input.c— SDL scancode dispatch replaced by the runtime-owned termbox event loop + 31-key classifier. -
kn86-emulator/src/cell_api.c(new) — the constrained cell API implementation. -
kn86-emulator/src/input_classifier.c(new) — 31-key map, hold detection, multi-tap, TERM context, global-chord recognition. -
kn86-emulator/src/font_12x24.c,font_12x24.h,tools/gen_font_12x24.py— deleted. -
vendor/termbox2/termbox2.h(new) — vendored upstream. -
LICENSE-third-party.md— termbox2 MIT notice added. -
README.md— emulator run instructions updated (run in a terminal, not as an SDL window). -
docs/_meta/definitive-articles.md— display-profile entry retired; termbox2 + cell-API entries added.
A PR that retires the SDL display path without ticking these boxes fails review.
Narrative (for the design history)
Section titled “Narrative (for the design history)”We spent six months building a pixel framebuffer beneath a thin cell veneer because that’s what the SDL example code shows you how to do. The KN-86 was always a text-grid device — every screen the operator sees is monospace cells, attributes, and a cursor — and we kept paying a tax for pretending otherwise: the framebuffer was 960×600 instead of 1024×600, Press Start 2P got stretched 1× horizontal and 2× vertical and stopped looking like itself, the kiosk window inherited dev-emulator chrome and rendered to roughly the top half of the panel on real hardware, and the cartridge FFI exposed 54 primitives where ~12 would have done. Looking at the cl-termbox2 snake listing was the moment the architectural mistake became unambiguous — the shape of cartridge code wants to be cell-set, cell-print, render, yield. The first draft of this ADR proposed binding termbox 1:1 into Fe so cartridge code could read like that listing literally; systems-engineering review correctly pushed back that the snake listing’s authority is wrong for a device runtime — cartridges should not be able to clear chrome, change input modes, or intercept TERM and SYS chords before nOSh sees them. The revised decision keeps the authoring shape and tightens the cartridge boundary: the C runtime owns termbox; the Fe FFI is a constrained KN-86 cell API and a semantic input event surface. The grid drops from a CGA-flavored 80×50 (which wasn’t what the hardware actually gave us) to the 128×75 the panel and 8×8 PSF font naturally produce. The half-block canvas becomes 128×150 pseudo-pixels — less CGA-faithful, more hardware-truthful. The whole thing is gated on a spike with six hard criteria, because moving from a known-bad SDL stack to an unknown-good Linux-console stack is exactly the kind of trade where the “unknown” half deserves to be metered before the SDL path is deleted. If the gates fail, we reverse cleanly — no SDL is deleted before they pass. If they pass, we ship the redesign sweep in the same PR. This is the largest single bet the project has made on a foundational layer, and the gating reflects that.