Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Save states (savestate.rs)

A save state snapshots the whole emulated machine to a single file and restores it exactly. Because the core is deterministic (Determinism and the host boundary), a restored run is byte-for-byte identical to one that was never interrupted -- the regression gate for the feature literally cmps screenshots taken on either side of a save/restore cycle.

User-facing behaviour (shortcuts, menu items, the --save-state-after / --load-state flags, and the operational caveats) is documented in the interactive UI guide and the headless-runs guide. This chapter is the implementation and format reference.

Design

The state is produced by serde derives on the live state structs themselves -- there is no hand-maintained parallel “snapshot” schema. Bus and everything it owns derive Serialize/Deserialize, as does the vendored CPU core (crates/m68k’s CpuCore is one flat struct of plain registers and configuration; it has no lazily built decode tables, so the whole struct round-trips). New fields are picked up by the derives automatically; the cost of that convenience is the versioning rule below.

What is captured:

ComponentContents
CpuCoreregisters, SR flags, prefetch queue, pending interrupt/stop state, MMU/CACR state, cycle-timing configuration, cpu_type and address mask
MachineRuntimeStatethe M68kMachine fields outside the core: last_cacr, sync_cck_on, cpu_clocks_per_cck, cpu_clock_carry
icache / dcachethe 020/030 cache models (Option<Box<CpuCache>>, None when the model has no such cache or it is opted out). A snapshot with None for a cache the running CPU has -- an older state, or one taken before the caches defaulted on -- re-establishes that cache cold on load (enable bits re-derived from the restored CACR) instead of dropping it
Buschip/slow RAM, ROM and extended ROM, Zorro boards (including their RAM), both CIAs, RTC, Agnus/Copper/Denise/Paula/blitter state, floppy controller with in-memory disk images, Gayle IDE, A2091 SCSI, Akiko/CDTV with NVRAM, beam-event capture buffers, DMA pointers, interrupt latches, and the bus-arbitration counters

Deliberately excluded, with the mechanism in parentheses:

The ROM bytes are embedded in the state, not loaded from a path: a state is self-contained with respect to everything that was in memory, so loading one always rebuilds its own machine -- restoring the Bus and CPU restores the machine model along with them (the CPU bus adapter’s address-mask copy is re-synced from the restored core’s address_mask). A state loaded under a different config therefore does not corrupt emulation; it silently becomes the machine the state was taken on.

To make that takeover visible and to keep host-side derived values in step, the header carries a MachineDescriptor (config.rs): the machine “shape” -- CPU model, chip/fast/slow RAM sizes, chipset (OCS/ECS/AGA), video standard, and machine profile -- plus a fingerprint of the boot and extended ROM (RomId = byte length + CRC-32 via flate2::Crc). It is not a correctness gate (the Bus is authoritative); it is the human-readable identity used to detect that a load swapped in a different machine. On a mismatch Emulator::load_state logs the field-by-field difference and reconfigures the host to match the state rather than the now-stale running config:

The ROM fingerprint is taken from the in-memory image (post normalization -- a 256 KiB Kickstart 1.x mirrored up to 512 KiB), so the running descriptor matches the bytes a save would embed. It is computed from the Bus, not the Config (which holds only a path): main builds the shape with Config::descriptor() and Emulator::set_machine_descriptor fills the ROM fields from the live Bus via MachineDescriptor::set_rom_fingerprint; reload_rom refreshes them when the Kickstart is hot-swapped. Consequently a state taken on the same machine shape but a different Kickstart is flagged on load (e.g. “ROM 512K:f6290043 -> 512K:fc24ae0d”). Storage image paths are deliberately not fingerprinted, but missing storage is still caught on load: HDF/CD images reopen by path and fail the load cleanly if absent (see below).

File-backed images

Two subsystems hold open File handles inside otherwise serializable state. Both get manual serde implementations through small shadow structs, and both reopen the file during deserialization, which turns a missing or moved image into a clean load-time error instead of a later I/O panic:

Floppy images need no special handling: FloppyImage keeps its data in memory (StandardAdf(Vec<u8>) or per-track structures), so inserted disks travel inside the state, unsaved track writes included.

File format

offset  size  contents
0       8     magic, ASCII "CLSSTATE"
8       4     format version, u32 little-endian (STATE_VERSION)
12      ...   MachineDescriptor, bincode (uncompressed)
...     ...   zlib stream (RFC 1950) containing the payload

The MachineDescriptor sits uncompressed ahead of the zlib stream so a load can read it (and detect a machine mismatch) without inflating the whole machine; bincode consumes exactly its encoded bytes, leaving the reader positioned at the start of the zlib stream. savestate::load returns the descriptor to Emulator::load_state for the comparison.

The payload inside the zlib stream is five bincode values written back-to-back by M68kMachine::write_state, in this fixed order:

  1. CpuCore

  2. MachineRuntimeState

  3. icache: Option<Box<CpuCache>>

  4. dcache: Option<Box<CpuCache>>

  5. Bus

M68kMachine::apply_state reads them back in the same order and only swaps the machine onto the parsed state after every component has deserialized, so a truncated or corrupt file leaves the live machine untouched (savestate::tests::truncated_payload_leaves_the_machine_untouched).

Encoding details, for anyone reading a state file from outside:

Versioning

STATE_VERSION (in savestate.rs) is compared exactly on load; a mismatch fails with a message naming both versions. Because the payload is positional bincode of the live structs, any shape change to any serialized struct -- a field added, removed, reordered, or retyped anywhere under Bus, the chipset modules, CpuCore, floppy or expansion state, or the header MachineDescriptor -- silently changes the wire layout. The rule is therefore: bump STATE_VERSION whenever such a change lands, so stale files are refused with a clear version message instead of failing with a confusing decode error (or worse, decoding into nonsense). There is no migration machinery; old states are simply invalidated.

Snapshot point and atomicity

The app-level contract is that states are taken at presentation-quantum boundaries: the window event loop and the headless timers both act only after step_frame returns, and --save-state-after fires at the first quantum past its deadline. A quantum can land inside an emulated field, so the serialized surface must round-trip any inter-instruction point (the unit test saves mid-frame after arbitrary step_slice counts). When a load resumes anywhere other than the start of a field, the hardware state continues exactly from that beam position, but the renderer marks that partly reconstructed field non-presentable and waits for the next complete field before updating screenshots, frame dumps, or the window.

savestate::save takes &M68kMachine and does not mutate emulated state. savestate::load parses fully before applying, then moves host resources across, resets any queued live-audio presentation frames from the old timeline, and clears transient video capture buffers. The restored guest RAM, custom registers, and beam event journal stay intact, while Agnus rebuilds sprite control/data latches from the restored pointer context under the current descriptor rules. Register-armed sprite streams whose transient descriptor latch was not serialized are reconstructed from Denise’s retained SPRxPOS/SPRxCTL/data-armed state and the next after-slot SPRxPT low-word write in the rendered field, so the first complete field after load follows the same data-stream rule as a live run. On success the window forces power on, clears any CPU halt latch, and invalidates last_rendered_emulated_frame so the next presentation re-renders from the restored Bus.

Verification

The determinism gate lives at three levels:

Reverse debugging (timetravel.rs)

Reverse debugging is built directly on this determinism. Where rr records every nondeterministic syscall and signal to make replay reproducible, Copperline already has that for free, so going backwards is just snapshot + replay: keep a ring of recent machine states, and to reach an earlier point restore the nearest one at or before it and replay forward. The user-facing surfaces (headless COPPERLINE_DBG_RWATCH, the window’s < Step / < Run) are documented in Reverse debugging; this section is the model.

Snapshot ring

SnapshotRing (in timetravel.rs) holds Snapshot { pos, frame, blob } entries, captured by Emulator::tt_capture_if_due at frame boundaries -- the same quiescent point save states require. The blob is produced by M68kMachine::write_state into a Vec, bypassing the zlib + magic + version framing of a file save state: snapshots live and die inside one process running one binary, so format compatibility is a non-issue and skipping it keeps capture cheap. Captures are taken every COPPERLINE_DBG_RR_INTERVAL frames and the oldest are evicted once the total blob size passes COPPERLINE_DBG_RR_BUDGET_MB; the ring never drops below one anchor.

Position coordinate

Reverse ops navigate by Emulator::retired_instructions, a monotonic count bumped per retired instruction in execute_cpu_slice. It lives outside the serialized state -- paired with each snapshot in the ring, not inside the blob -- so it costs nothing to capture and, importantly, does not change the save-state shape: reverse debugging needs no STATE_VERSION bump. A reverse step to position P restores the nearest snapshot with pos <= P and single-steps to P through run_one_step, the exact per-instruction body the forward step_real loop uses (factored out so replay reproduces the forward run instruction-for-instruction, including the STOP-state idle fast-forward).

Input replay (inputsched.rs)

Replay is only byte-identical if input is reproduced at the position it was applied. The live forward run keeps applying input exactly as before; when reverse mode is armed it also records each action into a position-keyed ReplayInputLog (Emulator::tt_note_input, called from the central keyboard / mouse-button / mouse-motion / joystick helpers, through which both scripted and window input funnel). During replay the engine re-applies logged actions as it reaches their positions. A floppy media change is logged as a marker that warns on replay rather than silently diverging (the inserted image is host-file state, not in the log).

Determinism boundaries

The same host boundary as save states applies, plus the requirement that time-dependent inputs be pinned, since replay re-executes them:

Verification