Display Pipeline
How a cartridge draw call travels from a text-puts FFI invocation to physical pixels on both the primary Elecrow panel and the CIPHER-LINE auxiliary OLED. Single source for the display rendering path — referenced by orchestration.md, cipher-voice.md, and screen-design-rules.md.
Related:
adr/ADR-0014— authoritative decision on cell geometry, logical canvas, letterbox, and glyph rendering. Read first.adr/ADR-0015— CIPHER-LINE layout and OLED-exclusive routing rule.kn86-emulator/src/display.c,display.h— primary framebuffer and cell renderer.kn86-emulator/src/oled.c,oled.h— CIPHER-LINE renderer and nOSh OLED wrappers.kn86-emulator/src/font.c,font.h— 256-glyph 8×8 KN-86 Code Page bitmap table.kn86-emulator/src/types.h—SystemStatelayout, framebuffer constants,DisplayModeenum.coprocessor-bridge.md— OLED commands route through this bridge on the device.
1. Two-display target overview
Section titled “1. Two-display target overview”The KN-86 Deckline presents two physical displays to the operator. Both render the same KN-86 Code Page glyphs from the same 8×8 bitmap table (font.c), but they serve distinct roles and are driven by separate rendering paths.
┌─────────────────────────────────────────────────────────┐│ Elecrow 7" IPS 1024×600 ││ PRIMARY DISPLAY — cartridge content + firmware rows ││ Logical canvas 960×600, 80×25 text grid ││ (32 px letterbox each side, zero vertical letterbox) │└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐│ SSD1322 3.12" OLED 256×64 (CIPHER-LINE) ││ AUXILIARY DISPLAY — firmware-owned CIPHER voice only ││ 4 logical rows × 32 columns, 8×16 glyph cells │└─────────────────────────────────────────────────────────┘The primary display is driven directly by the Pi’s framebuffer via SDL3 (emulator) or DRM/KMS (device). The CIPHER-LINE OLED is driven by the Pico 2 coprocessor over UART; the Pi sends OLED_SET_ROW / OLED_CLEAR commands per the coprocessor protocol (see coprocessor-bridge.md and docs/software/api-reference/grammars/coprocessor-protocol.md). On the emulator both surfaces are rendered in-process through the same coprocessor vtable seam.
Canonical hardware values — physical panel sizes, pixel counts, current draw — are in the CLAUDE.md Canonical Hardware Specification. This doc covers the software pipeline only.
2. Primary display: logical framebuffer model
Section titled “2. Primary display: logical framebuffer model”2.1. Canvas geometry
Section titled “2.1. Canvas geometry”The primary logical canvas is 960×600 pixels. This is derived entirely from the text grid and cell geometry; the physical Elecrow panel is 1024×600 and is wider than the canvas, producing a 32 px horizontal letterbox on each side. There is zero vertical letterbox. Integer scale is 1× across the entire pipeline.
Key constants from types.h:
| Constant | Value | Meaning |
|---|---|---|
KN86_TEXT_COLS | 80 | Columns in the text grid |
KN86_TEXT_ROWS | 25 | Rows in the text grid (includes firmware rows 0 and 24) |
KN86_CELL_WIDTH | 12 | Physical pixel width of one text cell |
KN86_CELL_HEIGHT | 24 | Physical pixel height of one text cell |
KN86_FRAMEBUFFER_WIDTH | 960 | 80 × 12 |
KN86_FRAMEBUFFER_HEIGHT | 600 | 25 × 24 |
KN86_FONT_WIDTH | 8 | Source glyph bitmap width in pixels |
KN86_FONT_HEIGHT | 8 | Source glyph bitmap height in pixels |
The Elecrow’s 1024 px width is never mapped to a canvas constant — the 32 px letterboxes on each side are invisible to cartridges and exist only in the SDL window scaling or DRM output transform.
2.2. Glyph rendering in a cell
Section titled “2.2. Glyph rendering in a cell”Each 12×24 cell renders one glyph from the 8×8 KN-86 Code Page. The source glyph is scaled 1× horizontal / 2× vertical, producing a visible 8×16 footprint. This footprint is centered in the 12×24 cell with:
- 2 px horizontal padding on each side (padding = (12 − 8) / 2 = 2)
- 4 px vertical padding top and bottom (padding = (24 − 16) / 2 = 4)
Glyph pixel origin for grid position (col, row):
pixel_x = col * 12 + 2pixel_y = row * 24 + 4The display_render() function in display.c walks every pixel of every cell in the text region. For each cell it reads the glyph byte from kn86_font and applies 2× vertical scale by dividing (cy - pad_y) by 2 to get the source font row. Pixels outside the 8×16 glyph band paint as glyph background (amber or black per inversion state).
Inversion: when ATTR_INVERT is set on a cell (attrib_buffer), the entire 12×24 cell is inverted — background pixels become amber, foreground (glyph) pixels become black. This produces a solid amber highlight block with a dark glyph cutout.
Cursor: a cell-wide underscore block spanning the bottom 2 rows of the cell (cell_y + 22 to cell_y + 23), blinking every 20 frames. Active only in TEXT and SPLIT modes, not BITMAP.
2.3. Text buffer layout
Section titled “2.3. Text buffer layout”SystemState carries three parallel arrays, each KN86_TEXT_COLS × KN86_TEXT_ROWS bytes:
text_buffer— one byte per cell, the glyph code point (KN-86 Code Page index)attrib_buffer— attribute flags per cell:ATTR_INVERT(0x01),ATTR_BLINK(0x02),ATTR_DIM(0x04)framebuffer— 1-bit-per-pixel bitmap canvas for BITMAP and SPLIT modes, packed MSB-first
All three are in SystemState (types.h), owned by the nOSh runtime. Cartridges never access these arrays directly — they call NoshAPI FFI primitives (text-puts, draw-sprite, etc.) which route through nosh.c wrappers.
3. Row authority split (Row 0 / Rows 1–23 / Row 24)
Section titled “3. Row authority split (Row 0 / Rows 1–23 / Row 24)”The 25-row grid divides into three ownership zones. This split is non-negotiable — see docs/software/cartridges/authoring/screen-design-rules.md for the full cartridge contract.
| Rows | Owner | Purpose |
|---|---|---|
| Row 0 | Firmware | Status bar: battery indicator, timer, mode indicator, TERM hint |
| Rows 1–23 | Cartridge | All content; the only area cartridges may draw to |
| Row 24 | Firmware | Action bar: contextual prompts, phase chain status, error messages |
Firmware draws on rows 0 and 24 after every frame, overwriting anything a buggy cartridge might have written there. display_text_puts() enforces row < KN86_TEXT_ROWS but does not guard against row 0 or row 24 specifically — the guard is at the FFI layer: text-puts rejects any row outside [1, 23] before forwarding to display_text_puts().
4. Display modes: TEXT, BITMAP, SPLIT
Section titled “4. Display modes: TEXT, BITMAP, SPLIT”SystemState.display_mode is one of three DisplayMode enum values. display_render() dispatches on this to determine which buffers contribute pixels:
| Mode | text_rows value | gfx_rows value | Description |
|---|---|---|---|
DISPLAY_MODE_TEXT (0) | KN86_TEXT_ROWS (25) | 0 | Full text grid. No bitmap output. Default mode at boot. |
DISPLAY_MODE_BITMAP (1) | 0 | KN86_FRAMEBUFFER_HEIGHT (600) | Full 960×600 bitmap canvas. Text buffers ignored. |
DISPLAY_MODE_SPLIT (2) | derived from split_row | derived from split_row | Text occupies rows above split_row; bitmap occupies pixel rows ≥ split_row. |
display_set_mode(state, mode, split_row) sets both fields. In SPLIT mode, split_row is in logical pixels; display_render() divides by KN86_CELL_HEIGHT (24) to convert to a row count for the text section.
Cart-side FFI: (display-set-mode mode) and (display-set-split row) map to display_set_mode(). The BITMAP canvas is the full 960×600 logical framebuffer — the letterbox is invisible to cartridges.
Important: the bitmap framebuffer (state->framebuffer) stores 1 bit per pixel, packed big-endian (MSB = leftmost pixel). display_gfx_pixel() computes bit_index = y * KN86_FRAMEBUFFER_WIDTH + x and the byte/bit offsets accordingly. The render step expands each bit to an amber or black 32-bit RGBA value.
5. OLED routing — CIPHER is OLED-exclusive
Section titled “5. OLED routing — CIPHER is OLED-exclusive”CIPHER-LINE voice output routes to the auxiliary OLED only, never to the primary 80×25 grid. The only sanctioned exception is the Null cartridge, which has a designed main-grid CIPHER-escape mechanic. No other cartridge may render CIPHER glyphs on the primary display. See ADR-0015 and CLAUDE.md Canonical Hardware Specification §Spec Hygiene Rule 6.
The CIPHER-LINE display has its own 4-row layout (constants in types.h):
| Constant | Value | Meaning |
|---|---|---|
OLED_WIDTH | 256 | Physical panel pixels wide |
OLED_HEIGHT | 64 | Physical panel pixels tall |
OLED_COLS | 32 | Characters per row (8 px glyph, no padding) |
OLED_ROWS | 4 | Logical text rows |
OLED_CELL_WIDTH | 8 | Pixel width of one OLED glyph cell |
OLED_CELL_HEIGHT | 16 | 8 px source × 2× vertical scale |
Named row indices:
OLED_ROW_STATUS(0) — battery / timer / mode / TERM hintOLED_ROW_CIPHER_CURRENT(1) — current CIPHER fragmentOLED_ROW_CIPHER_ECHO(2) — previous fragment scrollbackOLED_ROW_CONTEXTUAL(3) — seed capture / gameplay timer / mission meta
OLED glyphs use the same kn86_font 8×8 table as the primary display, but rendered at 8 px wide, 16 px tall (1× horizontal, 2× vertical) with zero horizontal padding. This is tighter than the 12×24 primary cell — the OLED is physically smaller and the 256-px width accommodates exactly 32 characters.
The oled_write_row() function in oled.c handles glyph rendering for the OLED. The nosh_oled_set_row() / nosh_oled_clear() wrappers in oled.c dispatch through the coprocessor vtable when bound (device path: UART → Pico → SSD1322 SPI) and fall back to direct oled_write_row() when unbound (emulator in-process path). The coprocessor vtable seam is the key abstraction: call sites in the runtime do not change between emulator and device builds.
6. Pixel-perfect verification approach
Section titled “6. Pixel-perfect verification approach”The emulator and device must produce byte-identical output for the same draw sequence. The verification approach follows from the pipeline structure:
- Same font table.
kn86_fontinfont.cis the single source of truth for both targets. - Same cell geometry.
types.hconstants define the cell dimensions identically for emulator and prototype. There is no device-specific rendering path. - Same render logic.
display_render()is shared C code; on the device it renders into a DRM framebuffer instead of an SDL texture, but the pixel math is identical. - Letterbox is output-side only. The 32 px horizontal letterbox is applied by the SDL window setup or DRM output configuration, not by
display_render(). The 960×600 logical canvas is always full-resolution. - OLED is Pico-driven on device. The emulator’s
oled_render()produces the same per-pixel output as the Pico’s SSD1322 driver for the same framebuffer contents, but visual comparison requires a screenshot from the emulator vs a photo of the hardware OLED — byte-identical at the framebuffer level, not at the panel driver level.
For regression testing, ctest suites cover test_cell_pool, test_nav_stack, and test_input_dispatch. Display rendering is verified by integration tests that drive a known draw sequence and compare the resulting pixel buffer against a reference bitmap.