Skip to content

Sprint 3 Design Pack — GWP-259 — Cart lifecycle FSM + phase-chain serialization

Notion task: GWP-259
Recovered: 2026-04-26 (lost to worktree contention; restored from Notion block children API)


Implement the phase-chain hot-swap finite state machine that orchestrates multi-phase contracts across cartridge swaps. ABSENT -> MOUNTED -> REGISTERED -> ACTIVE -> UNMOUNTING -> ABSENT per docs/software/runtime/cartridge-lifecycle.md.

The lifecycle doc is design-complete and explicit about state transitions, runtime actions per state, and the rollback behavior on UNMOUNTING. GWP-167 is parked at Low priority “deferred” as a single umbrella. Decomposing into this dispatchable engineering story.

References: docs/software/runtime/cartridge-lifecycle.md, docs/software/runtime/orchestration.md §“Multi-Phase Missions & Cartridge Swapping”.

  • New module src/cart_lifecycle.c and .h with the 5-state FSM
  • udev event subscriber (Linux) and SDL drag-drop event simulator (emulator) feed insert/remove
  • State transitions match the lifecycle doc exactly: header validation, grammar merge, phase-handler registration, save-file open/close, coprocessor session open/close
  • UNMOUNTING serializes phase_chain to deck-state SRAM, flushes save-file, rolls back grammar overlay, unregisters phase handlers
  • Hot Swap path: ACTIVE cart removed mid-phase pauses the phase, displays Row 1 banner “INSERT ”, awaits re-insert; phase resumes with serialized state intact
  • Test test_cart_lifecycle covers full state cycle, mid-phase swap with serialization, header-rejection path, save-file flush
  • Cart-side hooks (cart-init, cart-unload) called at REGISTERED entry / UNMOUNTING entry respectively
  • Owns: src/cart_lifecycle.c and .h (new).
  • Touches additively: tests/test_cart_lifecycle.c, CMakeLists.txt, cartridge.c (delegate to lifecycle FSM), main.c (event wiring).
  • Must NOT modify: deck.c (DeckState), nosh_save.c, provenance.c.
  • TDD; cite cartridge-lifecycle.md in commit messages.

GWP-249 (cart_save filesystem), GWP-{provenance} — both wire into REGISTERED / UNMOUNTING transitions.

GWP-167 (existing deferred umbrella) gets retired as duplicate when this lands.


Authored by PM/BA + Gameplay Designer. Full pack on disk at docs/sprints/2026-04-26-sprint3-gwp-259-design.md. This appendix is the structured summary; the on-disk file is load-bearing.

Universal Deck State already reserves phase_chain[256] + phase_chain_len, and the lifecycle spec describes the swap protocol prose-wise; what we lack is a runtime FSM that drives the transitions, owns serialization, enforces the swap-prompt UI on Row 0/24, and bounds the swap window. This story stands up that FSM as the canonical owner of cartridge presence — five named states, one event taxonomy (udev add/remove + phase boundaries + timeout ticks), and one set of side-effect hooks. Six existing lifecycle consumers (mission board, Cipher engine, save subsystem, coprocessor session, Row 0/24 renderer, Universal Deck State persister) become subscribers. Prerequisite for honest multi-cart campaign play (NeonGrid → Black Ledger → ICE Breaker chains).

2. Gameplay Designer — Player-facing semantics

Section titled “2. Gameplay Designer — Player-facing semantics”

FSM states (5 + 1 sub-state): ABSENT, MOUNTED, REGISTERED, ACTIVE, UNMOUNTING; plus AWAITING_SWAP (transient ABSENT after a phase boundary, with the swap prompt visible).

Operator is mid-Phase 1 of NeonGrid → Black Ledger campaign. ACTIVE state, active_cart=neon-grid, phase_chain_len=12 (phase index 0, partial sentry-position map, threat +2, partial payout 800¤). Row 0: ’> NEONGRID PHASE 1/2 TRACE 2 800¤’. Operator pulls cart unexpectedly.

  • udev REMOVE → UNMOUNTING. Phase chain serialized (DeckState.phase_chain ← working buffer, marked :suspended-mid-phase, :expected-cart=neon-grid). Save flush+close, grammar rollback, phase handlers unregistered, coprocessor session closed.
  • Cipher fires :anomalous (cart-removed-unsafe) — CIPHER-LINE may render ‘that pull. wrong moment.’ next tick.
  • Row 0: ’> NO CART PHASE 1/2 SUSPENDED 800¤’. Row 24: ’> RE-INSERT NEONGRID TO RESUME / SYS+ABANDON to forfeit’. FSM → ABSENT.
  • Re-insert SAME cart: ABSENT → MOUNTED → REGISTERED. Subscriber notices phase_chain.expected_cart matches, emits :resume-mid-phase, suppresses mission-board regen, calls phase handler with :resume-from-suspend + deserialized buffer. CIPHER-LINE: ’> RESUMED.’
  • Re-insert DIFFERENT cart (black-ledger): expected_cart mismatch. Row 24 modal — CAR proceed (forfeit Phase 1, -3 rep), CDR re-insert NEONGRID. Scheduled phase-boundary swap (canonical Hot Swap): phase handler returns :phase-complete; ACTIVE → AWAITING_SWAP. Row 0 ’> PHASE 1 COMPLETE PHASE 2 REQUIRES: FORENSIC ACCOUNTING’. Row 24 ’> INSERT MODULE: BLACK LEDGER / 5:00 swap window’. Cipher emits :phase-advance (:significant). 5-min timer ticks Row 24 countdown. On timeout: SWAP_OFFER (CAR Suspend / CDR Abandon -12 rep, partial payout).
  1. FSM module at kn86-emulator/src/cartridge_fsm.{c,h}. Explicit CartFsmState enum (CART_FSM_ABSENT, _MOUNTED, _REGISTERED, _ACTIVE, _UNMOUNTING, _AWAITING_SWAP) + cart_fsm_dispatch_event() driving every legal transition. Illegal transitions log + drop (defensive — never assert).
  2. Phase-chain serialization is deterministic — cart_fsm_serialize_phase_chain() writes a stable byte layout into DeckState.phase_chain. phase_chain_len ≤ 256; overflow returns :phase-chain-too-large and offers Suspend/Abandon early.
  3. Swap timer at 1 Hz wall clock during AWAITING_SWAP, ticks Row 24 countdown, expiry → SWAP_OFFER. Pauses while wrong-cart prompt is up.
  4. Subscriber callbacks via cart_fsm_on_state_change(callback, ctx). v0.1 subscribers: nosh_cipher_*, mission-board regen, save subsystem, coprocessor session manager, Row 0/24 renderer, DeckState persister.
  5. Resume detection — MOUNTED→REGISTERED with phase_chain_len > 0 + expected_cart match emits CART_FSM_RESUME_HINT (a notification, not a state). Different-cart insertion during AWAITING_SWAP triggers Row 24 proceed-vs-reinsert modal.
  6. Tests at kn86-emulator/tests/test_cartridge_fsm.c covering: 12 legal transitions, 3 illegal-transition rejections, resume-same-cart, resume-different-cart proceed, resume-different-cart re-insert, swap-timeout-Suspend, swap-timeout-Abandon, mid-phase unsafe pull, rapid insert/remove (≥5 events in 10ms), low-voltage UNMOUNTING. Target ≥90% line coverage.
  7. Phase-chain serialization round-trips through DeckState.phase_chain — load+save 100x asserts byte-identical.
  8. No regression in test_cross_cart_deck_state.c, test_deck_persistence.c, test_cartridge_load.c, test_cartridge_v2_loader.c.
  • Rapid udev storm (insert/remove bounce within ~10ms): coalesce via 1-event input queue; queue 1 second event, drop a third with warning.
  • Power-off during ACTIVE: low-voltage interrupt drives UNMOUNTING with tight budget (phase chain serialize <50ms, save flush best-effort up to 200ms then abort). Next boot: FSM resumes ABSENT with phase_chain_len > 0 + :expected-cart set; Bare Deck shows ’> RESUME PENDING — INSERT ’.
  • Corrupted save file: MOUNTED→REGISTERED succeeds, save subsystem at ACTIVE entry detects corruption, falls back to fresh save, renames original to <cart_id>.sav.corrupt. Cipher fires :anomalous (save-corrupt).
  • Header valid but capability_bits declares unknown capability (cart compiled against future runtime): MOUNTED is sticky; bare-deck shows ’> CARTRIDGE REQUIRES NEWER FIRMWARE’. No advance to REGISTERED.
  • phase_chain overflow: cart phase handler keeps payload bounded; if it overflows, FSM returns :phase-chain-too-large, cart can compress/drop optional fields/abort cleanly.
  • Cart re-inserted with expected_cart match but Phase 1 already complete: FSM does NOT re-enter Phase 1 — returns to mission-board state. phase_chain stays attached to current Phase-2 cart’s expected-cart slot.

ADRs: ADR-0019 (SD-via-USB-MSC source of udev events), ADR-0015 (:cart-swap event in event ring; coherence stack persists), ADR-0017 (coprocessor session lifecycle hooks at ACTIVE/UNMOUNTING), ADR-0006 (.kn86 header parsing at MOUNTED→REGISTERED), ADR-0011 (/home/shared p6 isolation).

Module specs: docs/software/cartridges/modules/ice-breaker.md (Hot Swap is core decision framework), neongrid-blackledger-campaign.md (canonical 2-phase swap test), neon-grid.md, black-ledger.md.

Runtime docs: cartridge-lifecycle.md (the FSM IS the executable form), orchestration.md §Multi-Phase Missions, deck-state.md (phase_chain[256] / phase_chain_len owned by this FSM).

Files to create: kn86-emulator/src/cartridge_fsm.{h,c}; kn86-emulator/tests/test_cartridge_fsm.c. Files to touch: cartridge.c (replace ad-hoc state checks), deck.c (subscribe + render Row 0/24 prompts), cipher.c (subscribe; push :cart-swap), coproc.c (subscribe; close cart-owned sessions), CMakeLists.txt (add sources + test target).

Expected PR size: 600–900 LOC honest (FSM ~250 + subscriber wire-ups ~150 + tests ~400). Two days impl + one day test matrix. Not small. >1200 LOC = subscribers should be split into a follow-up.

Test strategy: TDD per repo convention. Write test_cartridge_fsm.c first against the FSM header API; implement until green. Then wire subscribers and re-run cartridge integration tests. The FSM module has no SDL2 / display dependency — pure unit-test mode.

Scope guardrail (NOT in scope): emulator udev-event simulation redesign; new screens/tabs in Bare Deck Terminal HUD beyond Row 0/24 strings; Pi-side udev integration on hardware (Platform Engineering follow-up); CIPHER-LINE Row 4 timer countdown rendering on the OLED (covered by aux-timer-start FFI).

Suspend persistence model: surface suspended-mission prompt immediately on bare-deck terminal, or only when operator drills into MISSIONS tab? Default assumed: surface immediately on bare-deck with SYS option to dismiss. Confirm or override.

Blocks: multi-cart launch campaign carts (neongrid-blackledger-campaign) cannot be QA’d end-to-end without real FSM. Unblocks: Platform Engineering hardware udev integration; QA’s swap-test matrix becomes deterministic. Parallel-safe with GWP-260 (CIPHER grammar; share :cart-swap event taxonomy only) and GWP-244 (split-view; entirely independent). Suggested wave: Sprint 3 Wave 1 (high priority — unblocks two follow-on stories + one campaign PR). Pair with QA early.