Skip to content

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.hSystemState layout, framebuffer constants, DisplayMode enum.
  • coprocessor-bridge.md — OLED commands route through this bridge on the device.

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”

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:

ConstantValueMeaning
KN86_TEXT_COLS80Columns in the text grid
KN86_TEXT_ROWS25Rows in the text grid (includes firmware rows 0 and 24)
KN86_CELL_WIDTH12Physical pixel width of one text cell
KN86_CELL_HEIGHT24Physical pixel height of one text cell
KN86_FRAMEBUFFER_WIDTH96080 × 12
KN86_FRAMEBUFFER_HEIGHT60025 × 24
KN86_FONT_WIDTH8Source glyph bitmap width in pixels
KN86_FONT_HEIGHT8Source 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.

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 + 2
pixel_y = row * 24 + 4

The 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.

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.

RowsOwnerPurpose
Row 0FirmwareStatus bar: battery indicator, timer, mode indicator, TERM hint
Rows 1–23CartridgeAll content; the only area cartridges may draw to
Row 24FirmwareAction 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().


SystemState.display_mode is one of three DisplayMode enum values. display_render() dispatches on this to determine which buffers contribute pixels:

Modetext_rows valuegfx_rows valueDescription
DISPLAY_MODE_TEXT (0)KN86_TEXT_ROWS (25)0Full text grid. No bitmap output. Default mode at boot.
DISPLAY_MODE_BITMAP (1)0KN86_FRAMEBUFFER_HEIGHT (600)Full 960×600 bitmap canvas. Text buffers ignored.
DISPLAY_MODE_SPLIT (2)derived from split_rowderived from split_rowText 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):

ConstantValueMeaning
OLED_WIDTH256Physical panel pixels wide
OLED_HEIGHT64Physical panel pixels tall
OLED_COLS32Characters per row (8 px glyph, no padding)
OLED_ROWS4Logical text rows
OLED_CELL_WIDTH8Pixel width of one OLED glyph cell
OLED_CELL_HEIGHT168 px source × 2× vertical scale

Named row indices:

  • OLED_ROW_STATUS (0) — battery / timer / mode / TERM hint
  • OLED_ROW_CIPHER_CURRENT (1) — current CIPHER fragment
  • OLED_ROW_CIPHER_ECHO (2) — previous fragment scrollback
  • OLED_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.


The emulator and device must produce byte-identical output for the same draw sequence. The verification approach follows from the pipeline structure:

  1. Same font table. kn86_font in font.c is the single source of truth for both targets.
  2. Same cell geometry. types.h constants define the cell dimensions identically for emulator and prototype. There is no device-specific rendering path.
  3. 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.
  4. 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.
  5. 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.