Skip to content

Phase-Chain Serialization Format

Wire layout for the variable-length multi-phase mission continuity buffer that the cartridge lifecycle FSM (GWP-259) writes into DeckState.phase_chain[] whenever a mission is suspended mid-flight or crosses a Hot Swap boundary. On re-insertion of the matching cart the runtime deserializes the buffer to resume the mission with phase context intact.

  • cartridge-lifecycle.md — the FSM that owns this buffer.
  • deck-state.mdphase_chain[256] / phase_chain_len field semantics inside Universal Deck State.
  • adr/ADR-0019 — the SD-card-via-USB-MSC physical model that drives insert/eject events.

The phase chain is the only piece of cross-phase state that has to survive a cartridge swap, so the layout must be:

  1. Byte-stable across compilers and toolchains. The same PhaseChain value must serialize to the same bytes whether built on macOS clang, Linux gcc, or the Pi cross-toolchain. No struct packing tricks, no compiler-defined alignment, no host-endian reads.
  2. Versioned. A byte-level format version lets the runtime recognize and reject buffers it cannot interpret (and a future v2 layout can grandfather v1 detection cleanly).
  3. Self-describing. Magic bytes guard against a fresh-deck buffer (zeroed flash) being misinterpreted as a chain.
  4. Bounded. Total serialized size must fit in the existing 256-byte DeckState.phase_chain[] so no UDS layout change is required.
Offset Size Field Notes
------ ---- ---------------- ------------------------------------------
0x00 2 magic 0x504C ("PL" — Phase Link)
0x02 1 version 0x01 (PHASE_CHAIN_FORMAT_VERSION)
0x03 1 count 0..15 records (PHASE_CHAIN_MAX_RECORDS)
0x04 4 expected_cart_id le32; 0 = no cart pinned
0x08 1 suspended_flag 1 = unsafe pull mid-mission
0 = clean phase-boundary suspend
0x09 3 reserved must be zero (deserializer rejects nonzero)
0x0C N×R records[] each PHASE_CHAIN_RECORD_SIZE = 16 bytes
Offset Size Field Notes
------ ---- ------------ ----------------------------------------------
0x00 1 phase_index 0..255; cart-defined
0x01 1 phase_kind cart-defined; 0 = generic intermediate
0x02 2 payload_len le16; <= PHASE_CHAIN_PAYLOAD_BYTES (12)
0x04 12 payload[] fixed-width opaque blob; bytes past
payload_len are zero on a properly
constructed record

Records always occupy 16 bytes regardless of payload_len — the fixed-width record keeps the layout byte-stable and sidesteps the “variable-stride records inside a fixed-size buffer” footgun.

FieldBytes
Header12
Records (15 × 16)240
Total at maximum252
DeckState.phase_chain[] capacity256
Headroom4

The ADR-0006-stamped phase_chain_len field is uint8_t (0..255), so the 252-byte ceiling is itself just inside phase_chain_len’s representable range.

The deserializer rejects:

  • buf_size < PHASE_CHAIN_HEADER_SIZE (12)
  • magic != 0x504C
  • version != 0x01
  • count > PHASE_CHAIN_MAX_RECORDS (15)
  • Any nonzero byte in the reserved region (0x09..0x0B)
  • buf_size < 12 + count * 16
  • Any record with payload_len > 12

On any rejection the deserializer zeroes the caller’s PhaseChain and returns false. Garbage in, defined-output zeros — never a partial fill, never an out-of-bounds read.

SymbolValue
PHASE_CHAIN_MAGIC0x504C
PHASE_CHAIN_FORMAT_VERSION0x01
PHASE_CHAIN_HEADER_SIZE12
PHASE_CHAIN_RECORD_SIZE16
PHASE_CHAIN_PAYLOAD_BYTES12
PHASE_CHAIN_MAX_RECORDS15

Authoritative C header: kn86-emulator/src/cartridge_fsm.h.

The test suite includes a 100-iteration stability check (phase_chain_serialization_stable_100x) that asserts byte-identical re-encoding after each decode. Any future change to the layout must keep this test green or bump PHASE_CHAIN_FORMAT_VERSION.