Skip to content

ADR-0036: KN-86 native framebuffer renderer (supersedes termbox2 display layer)

ADR-0027 (ratified 2026-06-07) adopted termbox2 as the canonical display layer, made 128×75 the canonical grid (8×8 PSF on a 1024×600 framebuffer console), and exposed a constrained KN-86 cell API to cartridges. The spike binary kn86-nosh-tb runs the bare-deck LINK tab, REPL, and the Snake cart in that surface today on the production deckline prototype.

Six days later, on 2026-06-13, the PM and Josh ran a series of on-glass tests against the prototype’s framebuffer. The test utility (/home/kn86/fb_restest.py) painted candidate cell sizes, mixed-size hierarchy layouts, four-panel command-center compositions with real CP437 box-drawing, command-palette and IntelliSense overlays, custom-glyph extensions, and phosphor-color candidates — each directly to /dev/fb0. The results contradicted three of ADR-0027’s framing assumptions:

  1. 128×75 with 8px glyphs is the maximum-density configuration, not the canonical grid. On the Elecrow panel at normal viewing distance, 8×8 reads as dense-but-small. Configurations at 12×12, 12×24, 16×16, and 20×20 all read better for different purposes. Picking a single number forces a global compromise that nothing on the screen actually wants.
  2. Mixed text sizes on one screen are a real product requirement. A mission board wants 8px body text for dense ledgers and 32–40px headlines for the mission title and the countdown timer. A command palette wants 16px prompt text over 8px completion rows. termbox2’s uniform cell grid cannot deliver this; an integer-scaled bitmap renderer can.
  3. Phosphor color is the real aesthetic-mode axis. The retired ADR-0014 “glyph treatment” axis (12×24 scaled vs native) was invalidated by ADR-0027; the on-glass test established that operators want a foreground phosphor color choice (AMBER #E6A020 default, WHITE-on-black, GREEN-on-black), not a glyph-cut choice. This closes the open question raised by the ADR-0034 reconciliation.

The same test utility — a ~200-line Python framebuffer painter using the existing kn86_font[256 × 8] array verbatim — also demonstrated that owning the framebuffer is not a heavyweight engineering undertaking. The font, the box-drawing glyphs, the shading ramp, the half-block primitives, the compositing layer (drop shadows, dither scrim, modal popups), and the entire integer-scaled rendering pipeline can be implemented in well under 2000 lines of C from sources already in the tree.

  1. The empirical evidence overruled the spec. ADR-0027 was a paper decision (the 80×25 felt too narrow; 128×75 was the math). The on-glass test showed neither extreme is canonical; the panel is, and the cell size is per-element. Continuing with termbox2 would lock in a constraint the design no longer wants.
  2. The product-defining UI patterns require mixed-scale rendering. The reveal primitive (ADR-0033), the aesthetic-mode color picker (ADR-0034), the Batch-8 component kit (ui/status-bar, sparklines, big-glyph headlines), and the command-palette/overlay layer demonstrated on glass all assume the renderer can compose elements at different scales on the same surface. termbox2 cannot.
  3. The native renderer is simpler than termbox2 once carts and overlays are factored in. termbox2 brings a uniform cell grid, an event loop, and a present buffer. We need the second two; the first costs us multi-scale rendering and overlay compositing. Replacing the cell grid with a thin RGB565 painter built from the existing font.c table eliminates the impedance mismatch ADR-0027 §“Decision” item 4 introduced (the half-block trick was already an admission that the cell grid was too coarse for some rendering needs).
  4. The cart authoring shape ADR-0027 built is right and stays. The constrained cell API, the runtime-owned chrome rows, the operator-pre-emption semantics, the (on-key) / (on-tick) lambda model, the half-block sub-cell technique — all of these were the right calls and are preserved verbatim. What changes is what’s painting the pixels underneath them.

Replace termbox2 with a KN-86 native framebuffer renderer that owns the 1024×600 RGB565 surface, draws the 8×8 KN-86 Code Page at integer scales (1×–N×), and composites overlays directly. The cell API ADR-0027 built becomes one of several drawing modes the native renderer exposes; carts see no contract change.

The canonical display surface is the 1024×600 panel itself, not a fixed text grid. Cell density is parameterized per-region, not globally:

  1. Canonical surface: 1024×600 framebuffer, RGB565, native amber/amber on black. No letterbox, no logical-vs-physical math, no scaling layer. The renderer paints the panel directly (on device via /dev/fb0; in the desktop emulator via SDL3 surface).
  2. Base glyph: the existing kn86_font[256 × 8] (KN-86 Code Page), 8×8 bitmap, MSB-first. Press Start 2P + CP437 + hand-drawn. No font format change. The Code Page becomes the typeface; integer scaling is the type size.
  3. Integer scaling is the type-size mechanism. A glyph at scale s occupies 8s × 8s pixels. Per-element, freely composable: scale-1 body text and scale-5 headlines on the same screen. Maximum 1× density is 128×75 cells (1024/8 × 600/8); 80×25 is the same panel at 12×24 (8×8 glyph at 1×h/2×v, centered in 12×24 — recovers the ADR-0014 look on demand). Neither is “the grid”; both are integer-scaled views of one surface.
  4. The cell-API surface from ADR-0027 is preserved as a Fe-facing convention, not a backend. Carts continue to call (cell-set col row ch fg bg) / (cell-print …) / (cell-cols)→128 / (cell-rows-usable)→73 / (half-block-set …) etc. The native renderer implements these directly against its surface — cell-set becomes blit_glyph(col*8, row*8, ch, 1, fg, bg). ADR-0005’s cart-facing FFI surface is unchanged.
  5. System code can paint at any scale via a small system-only surface. System Fe (mission board, nEmacs, REPL, deck utils — per the Fe-Lisp Runtime Architecture draft §4b “system tier”) gets (draw-text x y scale fg text), (draw-box x y w h), (draw-glyph col row code scale), (fill-rect x y w h fg), (blit-bitmap …). The two-tier separation (constrained cart cell API vs. privileged system rendering API) ADR-0027 established stays; the privileged tier just exposes more of the native renderer.
  6. HALF-BLOCK 128×150 pseudo-pixel canvas is preserved. Same trick (U+2580/U+2584 glyphs are pre-built into the code page), same 2 vertical sub-pixels per cell, same cart-usable bounds. With the native renderer, half-block becomes a thin convenience layer over set_pixel(x, y, fg); both surfaces coexist.
  7. Phosphor color is selectable. Three canonical foreground colors, runtime-switchable, operator-persisted in nosh-config.toml:
    • AMBER#E6A020 (RGB565 0xE504) — canonical default.
    • WHITE#F0F0F0 (RGB565 0xF79E) — alternate (P4-style phosphor).
    • GREEN#33F033 (RGB565 0x3FE6) — alternate (P1-style phosphor).
    • Black background fixed (0x0000). The exact WHITE and GREEN hexes are starting values pending on-glass tuning; AMBER is locked. The CLAUDE.md Color row updates from #E6A020 to #E6A020 and adds the alternates. ADR-0034’s aesthetic-mode roster (AMBER/AMBER/CIPHER) is replaced by AMBER (default) / WHITE / GREEN in a companion ADR-0034 amendment.
  8. Device boot path: framebuffer kiosk, not console. On device, the runtime opens /dev/fb0 directly under the video group (no Linux console PSF, no setfont, no console-blanker dance — the test utility already verified the kn86 user can open /dev/fb0 for write). On desktop, the renderer paints into an SDL3 RGB565 surface and SDL3 presents the window. SDL3 returns from “audio-only” to “renderer backend on desktop”; on device, audio is the Pico 2’s job (ADR-0017) and SDL3 is not linked at all.
  9. kn86-nosh-tb (the termbox2 spike) is retained as a reference implementation of the constrained-cart authoring shape — useful for cross-checking the cell-API semantics during the native-renderer port — but is no longer on the path to production. The production binary becomes kn86-nosh linked against the native renderer; the SDL display path retired by ADR-0027 stays retired (its retirement is the right call; just for the wrong successor).
  • The constrained cart authoring shape (10-builtin cell API + 5 event callbacks).
  • Runtime-owned chrome rows (cart cannot write Row 0 or the bottom-action row).
  • Operator-keypress pre-emption semantics.
  • The half-block 128×150 pseudo-pixel canvas.
  • Native 8×8 Press Start 2P glyphs.
  • “Constrained surface for carts, privileged surface for system code” tier model.
  • Same VM, same loop, same (on-key) / (on-tick) cart contract.
  • The 1024×600 panel as the immovable hardware anchor.
  • termbox2 as the display library.
  • 128×75 as the canonical grid (it’s now the maximum density, equal-citizen with all other integer-scaled views of the panel).
  • The Linux console PSF + tty1 boot path (replaced by direct /dev/fb0 kiosk).
  • The fixed-cell uniform grid (replaced by integer-scaled per-element rendering).
  • The “SDL is audio-only” framing (replaced by SDL3-as-desktop-renderer-backend).

Option A: Stay with termbox2 (ADR-0027 unchanged)

Section titled “Option A: Stay with termbox2 (ADR-0027 unchanged)”

Pros. Already ratified. Spike binary running on glass. Fixed-grid constraint forces UI discipline. cl-termbox2 authoring-shape lineage carries through.

Cons. Uniform cell grid kills mixed-size text on one screen (the on-glass test made this concrete). The half-block trick already documents that the cell grid is too coarse. The 128×75-vs-80×25 debate is unresolvable inside the constraint. Phosphor-color theming has nowhere to live (termbox2’s color model is decorative, not semantic). Overlay compositing (drop shadows, dither scrim) requires per-pixel control termbox doesn’t expose.

Rejected. Empirical evidence overruled the spec.

Option B: Hybrid — keep termbox2 on device, own-renderer for prototyping only

Section titled “Option B: Hybrid — keep termbox2 on device, own-renderer for prototyping only”

Pros. Lowest churn. Keeps the spike binary’s investment intact. Tooling for mockups stays separate from the production runtime.

Cons. Two display models permanently in the tree. Every UI feature has to be implemented twice or one of the implementations is fictional. The product-defining mixed-scale rendering cannot be in the production runtime. Locks in a second drift point on top of the existing 80×25-vs-128×75 drift.

Rejected. A prototyping renderer that produces canonical screens is the canonical renderer; calling it otherwise is fiction.

Option C: Own renderer, paint to /dev/fb0 directly (ACCEPTED)

Section titled “Option C: Own renderer, paint to /dev/fb0 directly (ACCEPTED)”

Pros. Resolves the mixed-size, phosphor-color, and compositing requirements in one move. Eliminates the 80×25-vs-128×75 false dichotomy (the panel is the canonical surface; cell sizes are per-element). The test utility already demonstrated the entire stack — font parse, glyph blit, box-drawing, shading, half-block, integer scaling, dither scrim, drop shadow, modal overlay, fine progress bars, custom-glyph extension, phosphor recolor — on the real device with no new dependencies. The cart authoring shape ADR-0027 built ports forward unchanged.

Cons. Reverses a recently-ratified ADR (ADR-0027 was 2026-06-07; this is 2026-06-13). Burns the spike binary’s investment as a forward path (kept as a reference; not nothing, but not a path to production either). Requires a small C renderer module (file scaffolding, not framework work) and the boot path retargets from console-PSF to /dev/fb0 kiosk. Loses the cl-termbox2 line-for-line authoring-shape proof; we keep the shape (snake demo, cell+events authoring) but our renderer is the prior art now.

Accepted. The Cons are recoverable; the Pros are product-defining.

Option D: Adopt a third-party renderer (LVGL, custom termbox2 fork, etc.)

Section titled “Option D: Adopt a third-party renderer (LVGL, custom termbox2 fork, etc.)”

Pros. Doesn’t roll our own.

Cons. Every candidate is heavier than our actual needs — we render an 8×8 bitmap font with integer scaling and a fixed amber-on-black palette, no widgets, no animations, no antialiasing, no input subsystem we’re not already running. The test utility’s existence proves the surface area is tractable in-house. Adding a dependency we’ll mostly bypass is worse than writing the ~1500 lines we need.

Rejected. Out of scale with the requirement.


(cell-set …) / (cell-print …) / (half-block-set …) and the (on-key) / (on-tick) contract are unchanged. The constrained cell API ADR-0027 specified is the cart-facing surface; only the C implementation underneath changes. Existing carts (Snake demo, the chrome-violation test cart) port without modification.

System Fe (mission board, nEmacs, REPL, deck utils) gains a richer privileged tier API (draw-text with scale, draw-box, fill-rect, blit-bitmap). The Fe-Lisp Runtime Architecture draft §4b “system tier” specifies a cl-termbox2-shaped privileged surface; this ADR replaces that with a native-renderer privileged surface of the same shape. The §3 C/Fe line, the §4 two-tier sandbox, and the §5 library list are unchanged; the §4b API specifics are the only edit.

CLAUDE.md Canonical Hardware Specification — required updates

Section titled “CLAUDE.md Canonical Hardware Specification — required updates”
  • Display (primary) row: framing changes from “128×75 cells (ADR-0027)” to “1024×600 framebuffer, RGB565, native 8×8 KN-86 Code Page at integer scales (1×–N×); maximum 1× density = 128×75 cells (ADR-0036).”
  • Text grid (primary) row: from “128 columns × 75 rows (ADR-0027)” to “parameterized per-region by integer scale; 128×75 is the 1× ceiling, 80×25 is the 12×24 view, both are equal-citizen views of the same 1024×600 surface (ADR-0036).”
  • Display modes row: “TEXT (128×75 native cells) + HALF-BLOCK (128×150 pseudo-pixels)” preserved; both are now thin layers over the native renderer. “BITMAP” remains retired (per ADR-0014/0027).
  • Color row: #E6A020 (amber) → #E6A020 (AMBER) as the canonical default; document AMBER / WHITE / GREEN as the three selectable phosphor schemes.
  • Spec Hygiene Rule 4 (“emulator grid is runtime-queried, not a fixed types.h constant”) preserved; (cell-cols) → 128, (cell-rows-usable) → 73 unchanged. The legacy KN86_TEXT_COLS/KN86_TEXT_ROWS deviation in types.h remains tracked; the nOSh re-flow task (which ADR-0027 named, this ADR inherits) is what removes them.
  • ADR-0034: roster AMBER / AMBER / CIPHERAMBER / WHITE / GREEN (phosphor schemes). The §4.1 glyph-treatment-delta invalidation flagged in the 128×75 reconciliation amendment is now closed: aesthetic modes control foreground phosphor color, plus the surviving CIPHER-LINE cadence + scanline/overlay treatment from §4.2/§4.3. (get-aesthetic-mode) return values become :amber / :white / :green. SYS-tab picker contract preserved; option set updated. Default mode = :amber.
  • ADR-0027: Status → Superseded by ADR-0036 (display layer only). The authoring-shape contributions (cell API, two-tier model, half-block canvas, chrome reservation, operator pre-emption) are noted as preserved. The §“Ratification gates” section becomes design history (the gates were waived by Josh; the spike completed enough of its intended scope to prove the constrained-cart shape works; the conclusion drawn from the spike was right about what carts see, wrong about what paints the pixels).
  • ADR-0005: Amendment Log entry noting “the cell API surface specified by ADR-0027 is preserved; the implementation backend changes from termbox2 to the native renderer per ADR-0036.” Primitive count unchanged.

A new C module set (render.c/.h, phosphor.c for the AMBER/WHITE/GREEN switch, optional composite.c for shadow/scrim primitives). The existing font.c (already in the tree, already used by the test utility) is the typeface verbatim. The Fe→C bridge picks up the new system-tier primitives; the cart-tier cell-API primitives re-point from cell_api.c (termbox-backed) to the native renderer. The boot path retargets from kn86-nosh.service (console PSF) and kn86-nosh-tb.service (termbox spike) to a single kn86-nosh.service opening /dev/fb0 directly. The desktop emulator’s main loop swaps its SDL display surface from the retired 960×600 to a 1024×600 RGB565 surface fed by the same native renderer.

Detailed file scaffolding lives in the Fe-Lisp Runtime Architecture draft §5 “Fe library stack” with the L1 C substrate row updated to reflect the native renderer.

The on-glass session of 2026-06-13 against ssh://deckline validated the rendering stack: font parse from kn86_font[], glyph blit at scales 1× / 2× / 3× / 4× / 5×, six candidate cell sizes (fb_restest.py 16), four-panel mixed-scale layouts (fb_restest.py lay 14), command-palette and IntelliSense overlay compositing (fb_restest.py pal / hint), custom-glyph extension (21 new glyphs across vertical bars, horizontal sub-cell bars, and four KN-86 icons — fb_restest.py glyphs), and the four phosphor candidates (fb_restest.py colors). Test utility preserved at /home/kn86/fb_restest.py on the device.

Project-wide grep sweep for stale references to:

  • termbox2, kn86-nosh-tb (where used as canonical display-layer claims rather than spike references)
  • 128×75 grid / 128×75 canon (reframed to 1024×600 surface; 128×75 = 1× cell ceiling)
  • #E6A020 / amber #E6A020 (reframed to #E6A020 / AMBER #E6A020; document AMBER as the former value)
  • Linux console PSF, setfont, kmscon, fbcon, tty1 handoff (reframed to /dev/fb0 direct)

The sweep is a single follow-up PR; this ADR is the authoritative change. Carts/cart specs that reference Row 0 / rows 1–73 / Row 74 / 128×75 in cell coordinates remain correct (the cell API survives unchanged).


On-glass session 2026-06-13 (PM + Josh, ssh://deckline against the production prototype). Test utility at /home/kn86/fb_restest.py. PR draft docs/software/runtime/kec-lisp-runtime-architecture.md (PR #75) is the implementation context; this ADR is the decision record.


  • Update CLAUDE.md Canonical Hardware Specification — Display (primary), Text grid (primary), Display modes, Color rows per §“Consequences” above.
  • Amend ADR-0034 — phosphor-scheme roster + (get-aesthetic-mode) return values + default = :amber. The §4.1 invalidation is closed; aesthetic modes control foreground phosphor color.
  • Amend ADR-0005 Amendment Log — cell API backend changes from termbox2 to native renderer; cart-facing contract unchanged.
  • Update ADR-0027 Status line — Superseded by ADR-0036 (display layer only).
  • Update the Fe-Lisp Runtime Architecture draft (PR #75) §4b — system-tier API specifics shift from cl-termbox2-shape to native-renderer-shape; the C substrate row in §5 gains render.c / phosphor.c.
  • Refresh the ADR index (docs/adr/README.md) — add ADR-0036; flag ADR-0027 superseded.
  • Spec Hygiene Rule 3 grep sweep — single follow-up PR, scope per §“Consequences.”
  • Implementation work — tracked separately in the Fe-Lisp Runtime Architecture build backlog (forthcoming once the §9 forks are locked) and in Notion. Out of scope for this ADR’s PR.

ADR-0027 and this ADR record the same product instinct two ways. ADR-0027 reached the right conclusion about what carts should see (the constrained cell API, the two-tier model, the chrome reservation, the operator-keypress contract) and the wrong conclusion about what should paint the pixels (a third-party fixed-grid library instead of a small native renderer). The six-day gap between them is short because the on-glass test was decisive: ADR-0027 was a paper decision; ADR-0036 is a glass decision. Future ADRs that touch the display surface should pass through a glass test before ratification.


Open Questions Resolved (2026-06-13, post-audit)

Section titled “Open Questions Resolved (2026-06-13, post-audit)”

The architect’s drift audit surfaced four open questions after this ADR shipped. Josh resolved them the same day; recorded here so the port doesn’t re-litigate them.

OQ-1 — Keep kn86-nosh-tb in-tree as a “reference implementation” or delete it outright? Resolved: DELETE. Recoverable from git history if ever needed; cleaner tree wins. Story R-9 in the port backlog reflects this — full delete of main_tb.c, *_tb.c, vendor/termbox2/, the kn86-nosh-tb CMake target, the KN86_NOSH_TB #ifdef blocks in nosh_lisp_bridge.c and input_classifier.c, and the corresponding tests. The §“Decision” item 9 framing (“retained as a reference implementation”) is superseded by this resolution — the spike binary served its purpose proving the cart authoring shape; the production native renderer takes that shape forward.

OQ-2 — Production binary naming. Resolved: default proposal. Keep kn86emu as the desktop development binary name (Mac/Linux dev convenience, existing scripts + docs all reference it). Bake nosh as the device install name in the system-image step — the device path becomes /opt/nosh/bin/nosh per boot-and-systemd.md. Single add_executable in CMakeLists.txt; the install rule renames at SD-image build time. Story R-11 in the port backlog reflects this.

OQ-3 — ADR-0034 §4.3 overlay floor table under the new AMBER/WHITE/GREEN roster. Resolved: AMBER=Low (carries the old AMBER’s floor forward as the new default), WHITE=Off, GREEN=Off (starting values pending on-glass tuning). ADR-0034 §4.3 amended in this PR’s cascade. The old :amber and :cipher rows retire with their symbols. Story A-5 in the port backlog locks the SYS-picker UI prose accordingly.

OQ-4 — System-image source-of-truth location. Resolved: tools/sd-provision/pi-gen-stages/stage-kn86-runtime/00-kn86-runtime/files/etc/systemd/system/ in the kinoshita parent repo. All kn86-*.service unit files live there, including kn86-nosh.service, kn86-nosh-tb.service (retires with OQ-1), kn86-coprocessor.service, kn86-hostname.service, kn86-cartridge-mount@.service. Story B-1 in the port backlog is now closed (no spike needed); B-2/B-3/B-4/B-5 use that path directly.