CIPHER-LINE Row 4 Contextual Dispatch
CIPHER-LINE is the auxiliary 256x64 OLED. ADR-0015 carves it into four logical rows:
| Row | Content | Owner |
|---|---|---|
| 1 | Status strip (battery / timer / mode chip / TERM hint) | nOSh runtime |
| 2 | CIPHER scrollback — current fragment | CIPHER engine |
| 3 | CIPHER scrollback — previous echo | CIPHER engine |
| 4 | Contextual | Many 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:
- 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.
- 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.
Model — stack of claimants
Section titled “Model — stack of claimants”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.
Per-owner replacement
Section titled “Per-owner replacement”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.
Priority
Section titled “Priority”Two classes only:
| Class | Used by | Semantics |
|---|---|---|
REGULAR | every owner except seed | Stacks last-push-wins. |
PREEMPT | seed 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.
Default fallback
Section titled “Default fallback”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.
Per-surface enumeration
Section titled “Per-surface enumeration”| Owner | Owner id | Priority | Surface / Source | Notes |
|---|---|---|---|---|
| Bare-deck mission meta | OLED_ROW4_OWNER_BARE_DECK | regular | bare_deck.c | Default fallback registered at boot. Stays at stack-bottom; never explicitly pushed/popped. |
| CIPHER timer | OLED_ROW4_OWNER_CIPHER_TIMER | regular | cipher.c | ”TIMER MM:SS” countdown when any timer slot is active (ADR-0015 §4). Released when no timer is live. |
| Eject countdown | OLED_ROW4_OWNER_CIPHER_EJECT | regular | cipher.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 capture | OLED_ROW4_OWNER_CIPHER_SEED | preempt | cipher.c | ”SEED:XXXXXXXX” while TERM hold is active. Released by cipher_show_seed(0). |
| REPL | OLED_ROW4_OWNER_REPL | regular | nemacs.c (REPL buffer) | Arena gauge + cursor context (GWP-315). Pushed when the editor is in REPL buffer mode; released on exit. |
| nEmacs | OLED_ROW4_OWNER_NEMACS | regular | nemacs.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 session | OLED_ROW4_OWNER_MISSION_SESSION | regular | nosh_runtime.c (future) | Reserved for multi-phase mission summary text. Not yet wired; the slot is reserved so the test fixtures stay stable. |
| Cart-active | OLED_ROW4_OWNER_CART_ACTIVE | regular | Lisp builtin (row4-claim) | Carts can claim Row 4 via the FFI bridge for cart-defined contextual content. Released on cart unload. |
| Legacy Terminal | OLED_ROW4_OWNER_LEGACY_TERMINAL | regular | legacy_terminal/fkey_overlay.c | F-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.
Conflict resolution
Section titled “Conflict resolution”The dispatcher’s rules are deterministic:
- If any preempt claim exists, the highest-seq preempt wins.
- Else the highest-seq regular wins.
- Else the default render fn runs.
- Else Row 4 is cleared.
Concrete scenarios:
- Cart-active timer + eject mid-mission. Cart pushes
CART_ACTIVE. Cart-FSM entersAWAITING_SWAP; cipher.c pushesCIPHER_EJECT. Eject is the most-recent regular claim → wins. When swap completes, cipher.c releasesCIPHER_EJECTand 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 pushesCIPHER_SEEDwithPREEMPT. Seed wins regardless of what nEmacs is doing. Operator releases TERM; cipher.c callsoled_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.
Claim/release API
Section titled “Claim/release API”C bridge (nOSh surfaces)
Section titled “C bridge (nOSh surfaces)”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).
Lisp builtins (carts)
Section titled “Lisp builtins (carts)”Two new FFI primitives, exposed via nosh_lisp_bridge.c:
| Builtin | Signature | Behaviour |
|---|---|---|
row4-claim | (string-or-thunk) → nil | Push (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 | () → nil | Release 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.
Default fallback
Section titled “Default fallback”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.
Render-loop integration
Section titled “Render-loop integration”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.
Constraints
Section titled “Constraints”- 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-claimfrom inside Lisp handlers, which run on the main loop.
Test plan
Section titled “Test plan”kn86-emulator/tests/test_oled_row4.c covers:
- Empty stack + no default → row blank.
- Empty stack + default static text → default rendered.
- Empty stack + default render fn → fn invoked, output rendered.
- Single regular claim → renders that claim.
- Two regular claims → topmost (latest push) wins.
- Re-pushing same owner replaces in place; depth stays constant; latest text wins.
- Preempt claim beats every regular claim regardless of seq order.
- Releasing preempt restores topmost regular.
- Releasing topmost regular restores previous regular.
- Releasing all claims falls back to default.
- Stack-full push returns false; existing claims unchanged.
oled_row4_top_owner()andoled_row4_has_claim()reflect state correctly.- Render fn receives the correct userdata pointer.
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)”| Consumer | Before | After |
|---|---|---|
cipher.c (timer) | oled_write_row(oled, OLED_ROW_CONTEXTUAL, "TIMER MM:SS") inside cipher_render | oled_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_render | oled_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 flag | oled_row4_push(OLED_ROW4_OWNER_CIPHER_SEED, PREEMPT, NULL, "SEED:XXXXXXXX", NULL); release on cipher_show_seed(0). |
nemacs.c | direct oled_write_row(... OLED_ROW_CONTEXTUAL ...) in nemacs_render | oled_row4_push(OLED_ROW4_OWNER_REPL, REGULAR, render_nemacs_row, NULL, &g_nemacs) while editor is active; release on exit. |
legacy_terminal/fkey_overlay.c | direct write in legacy_terminal_fkey_overlay_render | oled_row4_push(OLED_ROW4_OWNER_LEGACY_TERMINAL, REGULAR, render_fkey_bar, NULL, NULL); release on overlay clear. |
bare_deck.c | none (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().
Open questions
Section titled “Open questions”- 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
PREEMPTtoday. 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.