Files
ewm/notes/STATE.md
Stefan ArentzandClaude Fable 5 de6abcd1ce Flag retirement: the CLI after config sources (F1–F3) (#286)
* Flag retirement F1: the quiet seven

Plan 20260719-01: --scanlines, --fps, --strict, --debug, --boot-delay,
--trace/--trace= and --state are gone from `ewm two` — they were
single-value duplicates of config keys (display.scanlines, display.fps,
cpu.strict, debug.enabled, boot.delay, debug.trace, state.path), all
reachable via --set or any config source. Retired flags fall into the
generic usage error (owner's ruling: v0.0.1, no transition ceremony);
a test sweeps the list and pins the --set spellings.

Scanlines::parse went with its only caller (the flag's optional-value
peek); MonitorStyle::parse follows in F2 with --color. Docs swept:
README's feature blurb, STATE.md and REMOTE.md speak state.path /
--set state:path now, IDEAS.md's trace item names debug.trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Flag retirement F2: the muscle-memory trio

Plan 20260719-01: --model, --color and --aux are gone from `ewm two` —
machine.model, display.monitor and machine.aux are config keys, and
`--config builtin:2e` is as short as `--model 2e` was. The --model
alias spellings (2+, ][+, //e, iie) go with it, as does --color's
optional-value peek (bare --color meant rgb) and MonitorStyle::parse,
its last caller. The aux token survives internally (Options.aux,
rebuilt by apply_config); only the flag is gone.

Swept the consumers: main.rs's getting-started hint and subcommand
line, the README feature blurbs and quick-start (builtin-first now,
enforced by readme_two_examples_parse), JSON_CONFIG.md's schema
inventory (sources are config + --set for everything), and the
--color/--aux comment references in scr.rs, aux.rs, config.rs and
two.rs. `one` keeps its --model — it has no config surface (plan
non-goal). Historical notes (APPLE_IIE_ENHANCED, REWRITE, landed
IDEAS items) keep their period wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Flag retirement F3: --memory

Plan 20260719-01, last phase: --memory is gone from `ewm two`; memory
regions come from config or overlay files (machine.memory), the one
setting --set cannot reach (arrays) — apply_set's error text now says
so. two's parse_memory_option went with the flag (one keeps its own
copy and its --memory flag: no config surface there, plan non-goal).
The print-config round trip composes the region through an overlay
now, which is exactly the documented replacement.

With this, `ewm two`'s surface is the plan's goal shape: the four
sources (--config, --config-overlay, --set, --print-config), --serve,
and the debug tooling (--wozbug, --break, hidden --screenshot). Plan
marked complete with the kickoff decisions recorded as built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* README: wrap the quick-start commands; document the three two profiles

The "start a machine directly" commands wrap with backslash
continuations (the readme_two_examples_parse test joins them, so they
stay checked). A new section explains that bare `ewm two` boots the
default machine — a ][+ with the 16K Language Card (the classic 64K
build), Thunderclock, and Disk II — and documents all three machine
profiles in English and JSON: the default (with its config-terms
equivalent, printable via --print-config), builtin:2plus, and
builtin:2e, each with its explicit `--config builtin:<name>` boot
command. The later ready-made-configs paragraph folded into it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* README: brief Experimental section for WozBug, VNC, and the Mac app

WozBug's walkthrough and the Native Mac app section fold into a new
Experimental section at the bottom — three short bullets, each linking
to its working document (notes/DEBUGGING_TOOLS.md, notes/REMOTE.md,
notes/MAC_APP.md). The remote console gets its first README mention
there; its --serve bullet leaves the source-kinds list, which is back
to the three document sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* README: drop the cc65 tests/ sentence

The C-era assembly test programs are being removed in #284; the
Testing section no longer mentions them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* README: complete Key commands section

The keys table becomes a "Key commands" section and gains the missing
entries: Cmd-K (the Command Palette, available on every machine) and
Cmd-Shift-R (reboot, power off/on), plus a Machines column instead of
per-row parentheticals. A short paragraph documents the palette's own
keys (type to filter, arrows, Return, Esc), that everything else —
including Ctrl-A..Z control characters — goes to the emulated machine,
and drag-and-drop disk swapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:53:11 -04:00

17 KiB
Raw Permalink Blame History

Machine State — Save on Quit, Restore on Start

A working document for persistent machine state: start the emulator with --set state:path=/some/mystate (or config state.path; the --state flag was retired by plans/20260719-01-flag-retirement.md) and it restores that state at startup (when the file exists) and saves it back at quit — suspend/resume for an Apple II, the way a VM hibernates. Like REMOTE.md and VNC.md, re-read at the start of every session and updated as phases land. The tree must stay green (cargo fmt --check, clippy --all-targets -D warnings, cargo test) after every phase, and the golden-BMP tests remain the SDL tripwire.

Scope assumption (per the design brief): the emulator is restarted with the same hardware configuration (model, CPU, cards, media) that saved the state. Detecting and rejecting a mismatch is explicitly backlog (§10) — v1 may misbehave arbitrarily if you lie to it. This assumption is not incidental: it is what lets the design cleanly split construction (from config, as today) from restoration (state overlays a built machine), and it resolves device identity (§3.3).


1. Goals and UX

ewm two --set state:path=~/machines/dos33.state \
    --set machine:slots:6:drive1=disks/DOS33-SystemMaster.dsk
  • First run (file absent): normal cold boot; the file is written at quit.
  • Later runs: the machine resumes exactly where it left off — the BASIC program still in memory, the cursor mid-line, the disk arm on the same half-track, the accumulated CPU cycle count intact.
  • Works in both frontends: the SDL window (save on quit/close) and the headless remote console (save on SIGINT/SIGTERM — this is what makes the REMOTE.md "VM farm" feel real: suspend and resume headless machines).
  • Config equivalent (state.path block) so a machine's JSON config is self-sufficient; CLI overrides config, per the established pattern.
  • Non-goals for v1: multiple named snapshots, save-while-running hotkey (easy later — the machine quiesces every frame), cross-version migration, compression, one (Apple 1) support. All §10.

2. Where the state actually lives (the architectural gift)

The Rust rewrite already centralized machine state under a single ownership tree, which makes the serialization design almost fall out:

Two
 ├─ cpu: Cpu                      registers A X Y SP P PC, cycle counter
 │   └─ mem: Memory
 │       ├─ base_ram: Vec<u8>     the 48K fast path
 │       ├─ regions: Vec<Region>  Backing::Ram(Vec<u8>) | Rom(..) | Io(idx)
 │       └─ devices: Vec<Box<dyn Device>>   ← every stateful peripheral:
 │             TwoIo / IouE       soft switches, key latch, paddles,
 │                                speaker toggles; IouE owns the
 │                                aux: Box<dyn AuxCard> (aux RAM, RamWorks)
 │             Alc / Saturn       language-card & Saturn banking + RAM
 │             Dsk                Disk II: drives, arm, motor, latch, media
 │             Hdd / Liron        block devices: registers (+ see §5)
 │             Clk                Thunderclock (reads the host clock)
 ├─ model, slot0, DeviceHandle<…> construction data — NOT state
 └─ io: MachineIo                 a handle — NOT state

Everything mutable at runtime is reachable as Cpu → Memory → (base_ram | Ram regions | devices[i]). Two's own fields are construction-time wiring. The ownership tree is the serialization tree; no cross-cutting state collector is needed, and no component's internals leak out of its module — which is exactly the "keep store/load local to the component" property the design brief asks for.


3. The trait

3.1 Shape

One trait, both directions (the brief's StateRestoration idea; suggested name Persist, since it covers save and restore — StateRestoration names only half, and State collides with the file/module noun. Final bikeshed at implementation time):

/// Component-local machine-state persistence (notes/STATE.md). Lives in
/// ewm-core next to `Device`; zero dependencies, like everything else.
pub trait Persist {
    /// Append this component's state to the writer. Only *runtime* state:
    /// anything reconstructible from config/ROMs is not written.
    fn save(&self, w: &mut state::Writer);

    /// Restore from a reader positioned at this component's payload.
    /// Must fully overwrite every field that `save` recorded; on `Err` the
    /// machine is considered unusable (§6, all-or-nothing).
    fn restore(&mut self, r: &mut state::Reader) -> Result<(), state::Error>;
}

3.2 Enforcement: make it a supertrait

pub trait Device: Any + Persist { … }     // ewm-core
pub trait AuxCard: … + Persist { … }      // ewm (IouE delegates to it)

This is the key "consistent restore" mechanism: the compiler refuses any new card or device that has not answered the persistence question. A stateless device (Thunderclock) writes an explicitly empty impl — a visible, reviewable decision rather than a silent omission. The known residual gap: nothing forces state outside the device tree into the snapshot — mitigated by the fact that there essentially isn't any (§2), and by the determinism gate (§8), which fails loudly when something observable was missed.

Consequence to accept up front: the supertrait makes "implement persistence one device per PR" impossible — the moment Device: Persist lands, every impl must exist. §9 sequences around this: the trait and the structural (CPU/RAM) work land first without the supertrait bound; one mechanical PR then adds the bound plus all device impls.

3.3 Composite orchestration and identity

Persist composes down the ownership tree; each level orchestrates its children in a fixed, explicit order:

  • Two::save → a small INFO chunk (model name, media paths — advisory today, the §10 fingerprint later) + the CPU chunk.
  • Cpu::save → registers/counter + the MEM chunk.
  • Memory::save → base_ram, each Backing::Ram region (by region index; Rom regions are skipped — immutable, rebuilt at construction), then devices[i] for every i, each as an index-tagged DEV chunk.
  • Owners delegate: IouE::save includes its aux card's payload, Dsk::save its drives and media.

Device identity is the construction index. Construction is deterministic from the config, and same-config is the stated precondition — so index identity is sound, needs no registry of names, and can never disagree with the machine actually built. (The backlog fingerprint (§10) is what will turn "you lied about the config" from undefined behavior into a clean error.)

Restore mirrors save exactly: the machine is built from config first, exactly as today (ROMs loaded, cards constructed, media inserted), then state overlays it in the same fixed order, replacing the initial cpu.reset(). The state file never constructs anything — it is an overlay, not a machine description. That separation is what keeps this feature small.


4. The container format (state.rs, hand-rolled)

In the house style (BMP, PNG, WOZ, DES, SHA-1…): a tagged-chunk binary container, no serde, no compression, everything little-endian. serde/JSON was considered and rejected: the payload is dominated by RAM and media images (hundreds of KB to MB), which JSON+base64 handles embarrassingly and which would drag mirror "state structs" alongside every component.

file   := magic "EWMS" | u32 version (=1) | chunk*
chunk  := tag [u8;4] | u32 length | payload (length bytes)
  • Chunks nest freely (CPU contains MEM contains DEVs); Writer and Reader are a Vec builder and a bounds-checked slice cursor with typed helpers (u8/u16/u32/u64/bytes/str). Corrupt or truncated input is an Err, never a panic — state files are local and trusted, but the parser is still total.
  • Strict v1: version must match; unknown or missing chunks are errors. (Skip-unknown forward compatibility is a §10 decision, not an accident.)
  • Atomic save: write path.tmp, then rename over path. A crash mid-save leaves the previous state intact.
  • Expected sizes: ][+ ≈ 80–100 KB + ~230 KB per loaded floppy; a //e with RamWorks: up to a few MB. Fine uncompressed.

5. Component inventory — what saves, what pointedly does not

Component Saves Skips (and why)
Cpu A X Y SP P PC, counter breakpoints, trace, strict — debug/session config, not machine state
Memory base_ram, Ram regions, cycles mirror, all devices Rom regions (immutable), watchpoints (debug)
TwoIo / IouE every soft switch, key latch, paddle/button state, pending speaker toggles, aux card payload —
AuxCard impls aux RAM banks, bank-select (RamWorks) —
Alc / Saturn banking state, banked RAM —
Dsk selected drive, half-track, motor + spin-down stamp, latch/mode, full media contents + dirty flags — (see below)
Hdd / Liron controller registers/command state media — writes already flush to the backing file, which is the durable copy; restore re-reads it at construction
Clk nothing (empty impl) it answers from the host clock — "now" is correct by definition
Scr, Snd, palette, frontends nothing — not in the tree pure/derived state; after restore the frontend marks the screen dirty and rendering/audio re-derive from the machine

The Disk II is the one component where media belongs in the snapshot: floppy writes are in-memory only (nothing in dsk.rs writes the image file back), so a resumed session must carry the modified image or silently lose writes. This also documents an existing sharp edge worth its own backlog line: today, quitting without a state path discards floppy writes.

Cycle-stamp rule: several components hold absolute cycle timestamps (motor spin-down, speaker toggles, paddle timers) relative to cpu.counter. The counter is saved and restored verbatim, so stamps are never rebased — save them as-is and the arithmetic stays coherent. No component may store wall-clock-derived state (only Clk touches the host clock, and it stores nothing).

Quiescence rule: save and restore only happen between CPU steps — the frame boundary, where both frontends already sit. There is no mid-instruction state anywhere, so a frame-boundary snapshot is complete by construction.


6. Lifecycle wiring

  • CLI: --state <path> on ewm two (and the config block "state": { "path": … }, resolved relative to the config file like other paths; CLI overrides config).
  • Startup: build_machine() exactly as today → if the state file exists, two.restore_state(path) instead of cpu.reset(); absent file → normal reset. Restore failure is fatal: print why and exit — never run a half-restored machine. (All-or-nothing comes free: restore happens before the first step, so the failure path has nothing to unwind.)
  • Paused start (SDL): a machine restored from a state file starts paused, with the pause screen naming the save it resumed from ("RESTORED FROM SAVE STATE" + the file's save time, taken from its mtime — which the atomic write-then-rename stamps at the moment of saving). The first unpause (Cmd-P or the command palette) dismisses it; later manual pauses show the plain PAUSED box. Headless serve has no pause UI, so a restored machine runs immediately there.
  • Quit paths:
    • SDL frontend: the existing quit event / window close → save, then exit.
    • Headless serve loop: install SIGINT/SIGTERM handlers that set an AtomicBool; the frame loop checks it, saves, and exits. No new dependency: declare the two libc functions with a direct unsafe extern "C" block (the platform libc is already linked) — the handler only stores a relaxed atomic, which is async-signal-safe.
    • Save failure at quit: report and exit nonzero, leaving any previous state file untouched (§4 atomicity).
  • Remote synergy: with the serve path wired, REMOTE.md Phase 6 orchestration gets VM-style suspend/resume for free (systemctl stop ewm-vnc@dos33 hibernates the machine).

7. Design decisions in one place

Decision Choice Rejected alternative
Trait shape one Persist trait, save+restore, composed down the ownership tree separate Save/Restore traits (two halves that must agree anyway); visitor walking a machine description (duplicates the ownership tree)
Enforcement Device: Persist, AuxCard: Persist supertraits opt-in registry (new devices silently skipped — the bug class this feature least wants)
Identity device construction index stable name registry (more machinery; solves only the config-mismatch case that is explicitly backlog)
Format hand-rolled LE chunk container serde: mirror-struct boilerplate + pathological on big binary payloads; bincode-style crate: dependency
Restore model config builds, state overlays state file describes the machine (turns the file into a second config format; huge surface)
Failure all-or-nothing, fatal at startup best-effort partial restore (undebuggable half-machines)
Media floppies in the snapshot; block devices via their backing files all media in snapshot (bloats; block files are already the durable copy)

8. Testing

  • state.rs unit tests: chunk round-trips, nesting, truncation, bad magic/version, the typed reader against hand-written byte vectors.
  • Per-component round-trips: for each Persist impl, save → restore into a freshly constructed twin → assert observable equality (registers, RAM, text_screen(), media bytes, switch states via the existing accessors).
  • The determinism gate (the state analogue of the golden-BMP tests): boot DOS 3.3 headless for N frames → save → restore into a fresh machine → (a) framebuffer and text_screen() identical to the original at the save point, and (b) run both machines K further frames — still identical. (The machine is deterministic modulo host-clock reads and input; the gate runs input-free with no Thunderclock dependency.) This is the test that catches any state a component forgot to save.
  • CLI end-to-end: run with --state, type X=42, quit; restart; PRINT X → 42.

9. Phases (one PR each, into a state integration branch or straight to main — decide at kickoff)

Phase Description Size Status
S0 This plan; --state / config state.path parsing S Done (in S4)
S1 state.rs container: Writer/Reader, chunk nesting, atomic file save, error type; unit vectors S Done
S2 Persist trait; impls for Cpu + Memory (base RAM, Ram regions) — no supertrait yet, devices skipped; round-trip test of a cardless machine M Done
S3 The supertrait flip: Device: Persist + AuxCard: Persist and all device impls; full-machine round-trips L Done
S4 Lifecycle: startup restore-instead-of-reset, SDL quit save, serve SIGINT/SIGTERM save; fatal-error paths; docs M Done
S5 The determinism gate + end-to-end test; REMOTE.md note on suspend/resume S Done

As built (deviations worth recording)

  • The S0 stub never existed. All phases landed in one PR, so the "state persistence not built yet" placeholder was pointless; the CLI and config surface shipped inside S4, where it works.
  • A model seatbelt shipped early. The INFO chunk names the model and restore rejects a mismatch — a two-line edge of the backlog fingerprint that was too cheap to defer. The full config fingerprint remains backlog.
  • Clk and Pia save slightly more than planned: the Thunderclock's latched time string mid-read (a suspended ProDOS clock read resumes correctly) and the PIA's undrained output queue. Both cost bytes, not complexity.
  • The determinism gate saves mid-boot (motor on, arm seeking) rather than at the idle prompt — the harsher variant of §8, and it passes: the restored twin stays cycle- and pixel-identical through millions of further cycles and both machines finish booting to identical screens.

10. Backlog (explicitly out of v1)

  • Config fingerprint: hash the effective machine config (model, slots, aux, media identity) into INFO; refuse restore on mismatch with a clear diff-style message. This converts the v1 precondition into a checked error.
  • Skip-unknown chunks / versioned migration (forward compatibility).
  • one (Apple 1 / Replica 1) through the same trait — Pia and TTY are tiny; blocked only by prioritization.
  • Save-while-running: palette command and/or periodic autosave (the frame boundary makes this nearly free once S4 lands).
  • Named snapshots (state.path a directory; save slots).
  • Compression (worth revisiting only if RamWorks-sized states annoy).
  • Floppy write-back as a feature of its own (today's quit discards floppy writes even without a state path — surprising, and orthogonal to state).
  • WozBug: dump/inspect a state file offline.