Skip to content

CIPHER-LINE Row 4 Contextual Dispatch

CIPHER-LINE is the auxiliary 256x64 OLED. ADR-0015 carves it into four logical rows:

RowContentOwner
1Status strip (battery / timer / mode chip / TERM hint)nOSh runtime
2CIPHER scrollback — current fragmentCIPHER engine
3CIPHER scrollback — previous echoCIPHER engine
4ContextualMany surfaces compete

Row 4 is the polymorphic row. Today it holds (depending on state) seed-capture display, gameplay sub-timer countdown, REPL arena gauge, nEmacs T9 suggestion palette, mission session meta, eject countdown (“RESUME: MM:SS”), Legacy Terminal F-key labels, and the kernel panic line. Until GWP-326 each surface wrote oled_write_row(oled, OLED_ROW_CONTEXTUAL, ...) directly. Two consequences:

  1. Last-writer-wins per frame. Any two surfaces active at once silently fight; render order in the main loop is the only arbiter. Adding a new surface meant editing main.c.
  2. No release semantics. When a surface goes inactive (cart unloaded, nEmacs exits, F-key overlay clears) it has to remember to clear the row, and the next-priority surface has to remember to repaint it.

The fix is a single dispatcher.

Every surface that wants to render Row 4 calls oled_row4_push(...) with an owner id, a priority class, and either a render callback or a static string. To stop rendering it calls oled_row4_release(owner).

Once per frame the runtime calls oled_row4_dispatch():

preempt claims → topmost preempt wins (highest seq)
otherwise → topmost regular wins (highest seq)
otherwise → default render fn (or row blank if unset)

seq is a monotonic counter, incremented on every push. The “topmost” claim is the one most recently pushed (or replaced-in-place) within its priority class.

Pushing twice from the same owner replaces in place. The slot’s seq is bumped, so the latest push of a given owner becomes topmost without growing the stack. This is the common case: cipher.c re-pushes its timer text every frame, nemacs.c re-pushes its T9 palette every keystroke, and so on.

Two classes only:

ClassUsed bySemantics
REGULARevery owner except seedStacks last-push-wins.
PREEMPTseed capture (TERM hold)Beats every regular claim regardless of stack order.

Seed capture is the only preemptive owner. ADR-0015 §4 (aux-show-seed) is explicit: “Replaces any sub-timer or mission meta that was on Row 4.” Operators trigger seed capture by holding TERM; the operator-initiated overlay must always win, even over an active eject countdown or a cart’s gameplay sub-timer.

When seed capture is released (cipher_show_seed(0)), the topmost REGULAR claim becomes the winner again — whichever surface was previously rendering picks back up automatically. No explicit “what was I before” plumbing is required at the call site.

When the stack is empty, oled_row4_dispatch() runs the default render fn registered via oled_row4_set_default(). Bare-deck mission meta is the canonical default — when the operator is on the bare-deck terminal with no cart loaded and no overlay active, Row 4 shows the next-mission summary or the “READY” idle text.

If no default is configured, Row 4 goes blank.

OwnerOwner idPrioritySurface / SourceNotes
Bare-deck mission metaOLED_ROW4_OWNER_BARE_DECKregularbare_deck.cDefault fallback registered at boot. Stays at stack-bottom; never explicitly pushed/popped.
CIPHER timerOLED_ROW4_OWNER_CIPHER_TIMERregularcipher.c”TIMER MM:SS” countdown when any timer slot is active (ADR-0015 §4). Released when no timer is live.
Eject countdownOLED_ROW4_OWNER_CIPHER_EJECTregularcipher.c”RESUME: MM:SS” while cart-FSM is in AWAITING_SWAP (GWP-304). Outranks timer because eject is push-priority over the live timer when both are active. Released on any non-AWAITING_SWAP state change.
Seed captureOLED_ROW4_OWNER_CIPHER_SEEDpreemptcipher.c”SEED:XXXXXXXX” while TERM hold is active. Released by cipher_show_seed(0).
REPLOLED_ROW4_OWNER_REPLregularnemacs.c (REPL buffer)Arena gauge + cursor context (GWP-315). Pushed when the editor is in REPL buffer mode; released on exit.
nEmacsOLED_ROW4_OWNER_NEMACSregularnemacs.c (literal/structural mode)T9 palette / echo line (ADR-0016 §7, GWP-316). Same lifecycle as REPL but distinct owner so tests can assert which buffer is active.
Mission sessionOLED_ROW4_OWNER_MISSION_SESSIONregularnosh_runtime.c (future)Reserved for multi-phase mission summary text. Not yet wired; the slot is reserved so the test fixtures stay stable.
Cart-activeOLED_ROW4_OWNER_CART_ACTIVEregularLisp builtin (row4-claim)Carts can claim Row 4 via the FFI bridge for cart-defined contextual content. Released on cart unload.
Legacy TerminalOLED_ROW4_OWNER_LEGACY_TERMINALregularlegacy_terminal/fkey_overlay.cF-key label bar (ADR-0021). Pushed on overlay enter, released on overlay clear.

Panic handler renders Row 4 directly via the panic OLED hook and does not participate in the stack. Panic is a terminal state; the dispatch contract no longer applies once we’re painting the panic notice.

The dispatcher’s rules are deterministic:

  1. If any preempt claim exists, the highest-seq preempt wins.
  2. Else the highest-seq regular wins.
  3. Else the default render fn runs.
  4. Else Row 4 is cleared.

Concrete scenarios:

  • Cart-active timer + eject mid-mission. Cart pushes CART_ACTIVE. Cart-FSM enters AWAITING_SWAP; cipher.c pushes CIPHER_EJECT. Eject is the most-recent regular claim → wins. When swap completes, cipher.c releases CIPHER_EJECT and the cart’s claim re-emerges (its seq is now the highest live regular).
  • REPL open + seed capture. nEmacs has pushed REPL. Operator holds TERM; cipher.c pushes CIPHER_SEED with PREEMPT. Seed wins regardless of what nEmacs is doing. Operator releases TERM; cipher.c calls oled_row4_release(CIPHER_SEED). REPL claim is restored automatically.
  • Legacy Terminal F-key bar + nothing else. Legacy pushes LEGACY_TERMINAL. No competing claims; it renders. On overlay clear, Legacy releases its claim and the bare-deck default returns.
  • Cart-active and REPL simultaneously. Operator opens the REPL while a cart was rendering Row 4. REPL is pushed, becomes topmost regular. When REPL exits and is released, the cart’s older-but-still-live claim re-wins.

Defined in kn86-emulator/src/oled_row4.h:

bool oled_row4_push(OledRow4Owner owner,
OledRow4Priority priority,
OledRow4RenderFn render_fn,
const char *static_text,
void *userdata);
bool oled_row4_release(OledRow4Owner owner);
void oled_row4_release_all(void);
OledRow4Owner oled_row4_top_owner(void);
uint8_t oled_row4_depth(void);
bool oled_row4_has_claim(OledRow4Owner owner);
void oled_row4_set_default(OledRow4RenderFn render_fn,
const char *static_text,
void *userdata);
void oled_row4_dispatch(void);

Render callbacks have the signature:

typedef void (*OledRow4RenderFn)(char *out, size_t out_len, void *userdata);

The dispatcher provides a 33-byte buffer (OLED_COLS + 1); callbacks write up to 32 chars + NUL. Strings longer than 32 chars are truncated by oled_write_row per ADR-0015 (ticker scrolling for overflow is a future extension; the v0.1 contract is hard truncation).

render_fn and static_text are mutually exclusive: if render_fn is non-NULL, it is called every dispatch; otherwise static_text (which must be NUL-terminated, ≤32 chars + NUL) is used verbatim. Pass static_text == NULL and render_fn == NULL for a “blank when winning” claim (rarely useful — usually means a logic bug).

Two new FFI primitives, exposed via nosh_lisp_bridge.c:

BuiltinSignatureBehaviour
row4-claim(string-or-thunk) → nilPush (or replace) a CART_ACTIVE claim with REGULAR priority. Argument is either a static string (≤32 chars) or a thunk taking no args that returns a string each frame.
row4-release() → nilRelease the CART_ACTIVE claim. Idempotent. Implicitly called on cart unload.

Carts may not push PREEMPT claims and may not target other owner ids. Seed capture is reserved to the runtime.

bare_deck.c registers its default render fn at startup via oled_row4_set_default. The default emits the bare-deck mission meta line (next-up mission summary, idle “READY” text, or session credit/reputation chip per bare-deck-content-brief.md). The default fn is invoked only when oled_row4_depth() == 0.

oled_row4_dispatch() is called once per frame from main.c after every per-surface render pass has had a chance to push or release. Order:

cipher_render(); /* pushes/releases timer, eject, seed */
nemacs_render(); /* pushes/releases REPL, nEmacs */
legacy_terminal_render(); /* pushes/releases legacy-terminal */
/* ... carts via nOSh runtime ... */
oled_row4_dispatch(); /* paints OLED_ROW_CONTEXTUAL exactly once */

cipher.c keeps writing rows 0–2 (status + scrollback) directly. It no longer writes Row 4 — it only push/releases on the dispatcher. This is the load-bearing change.

  • CIPHER OLED-exclusive (Spec Hygiene Rule 6). Row 4 content never leaks to the main 80×25 grid. The dispatcher writes via oled_write_row(OLED_ROW_CONTEXTUAL) which targets only the auxiliary OLED.
  • 32 chars at native 8x8. Ticker / scroll for overflow is the long-term path per ADR-0015; v0.1 truncates.
  • No malloc. Static array of 8 claimants. If we ever exhaust 8, the bug is a missing release; the static cap forces the issue to surface immediately rather than allocating around it.
  • Single-threaded. Dispatch runs on the main loop. Carts call row4-claim from inside Lisp handlers, which run on the main loop.

kn86-emulator/tests/test_oled_row4.c covers:

  1. Empty stack + no default → row blank.
  2. Empty stack + default static text → default rendered.
  3. Empty stack + default render fn → fn invoked, output rendered.
  4. Single regular claim → renders that claim.
  5. Two regular claims → topmost (latest push) wins.
  6. Re-pushing same owner replaces in place; depth stays constant; latest text wins.
  7. Preempt claim beats every regular claim regardless of seq order.
  8. Releasing preempt restores topmost regular.
  9. Releasing topmost regular restores previous regular.
  10. Releasing all claims falls back to default.
  11. Stack-full push returns false; existing claims unchanged.
  12. oled_row4_top_owner() and oled_row4_has_claim() reflect state correctly.
  13. Render fn receives the correct userdata pointer.
  14. oled_row4_release_all() clears every slot but leaves default intact.

End-to-end (covered by existing test_cipher.c, test_oled_eject_countdown.c migrations): seed preempts timer; seed release restores eject countdown if FSM still in AWAITING_SWAP; REPL push during active timer makes REPL win.

Migration notes (consumers updated by GWP-326)

Section titled “Migration notes (consumers updated by GWP-326)”
ConsumerBeforeAfter
cipher.c (timer)oled_write_row(oled, OLED_ROW_CONTEXTUAL, "TIMER MM:SS") inside cipher_renderoled_row4_push(OLED_ROW4_OWNER_CIPHER_TIMER, REGULAR, render_timer, NULL, NULL) and release when no timer is live.
cipher.c (eject)direct write inside cipher_renderoled_row4_push(OLED_ROW4_OWNER_CIPHER_EJECT, REGULAR, render_eject, NULL, NULL) on AWAITING_SWAP entry; release on exit.
cipher.c (seed)direct write inside cipher_show_seed; contextual_mode flagoled_row4_push(OLED_ROW4_OWNER_CIPHER_SEED, PREEMPT, NULL, "SEED:XXXXXXXX", NULL); release on cipher_show_seed(0).
nemacs.cdirect oled_write_row(... OLED_ROW_CONTEXTUAL ...) in nemacs_renderoled_row4_push(OLED_ROW4_OWNER_REPL, REGULAR, render_nemacs_row, NULL, &g_nemacs) while editor is active; release on exit.
legacy_terminal/fkey_overlay.cdirect write in legacy_terminal_fkey_overlay_renderoled_row4_push(OLED_ROW4_OWNER_LEGACY_TERMINAL, REGULAR, render_fkey_bar, NULL, NULL); release on overlay clear.
bare_deck.cnone (Row 4 was unused at bare deck)oled_row4_set_default(render_bare_deck_meta, NULL, NULL) at boot.

The legacy CipherContextualMode enum (CIPHER_CTX_NONE/TIMER/SEED/EJECT_COUNTDOWN) and the cipher.contextual_mode field are retained for now to keep the GWP-326 PR surgical. They become diagnostic-only state — the dispatcher is the source of truth. A follow-up sweep can delete them once no consumer reads cipher_contextual_mode().

  • Ticker for overflow. ADR-0015 mentions ticker/scroll for >32-char content. v0.1 dispatcher truncates; ticker support is a follow-on. The render-fn signature already returns a string per frame, so a ticker-aware claimant just rotates its own buffer per frame without API changes.
  • Cart preempt. Carts cannot push PREEMPT today. If a future cartridge needs hard-takes-the-row semantics (e.g., a TIME-CRITICAL alert that must beat the eject countdown), the dispatcher will need a third class or a per-cart whitelist. Out of scope for GWP-326.