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 stores the emulated machine in a single file. With the same external media and repeatable inputs, restoring it reproduces the guest timeline. Host files, physical devices, and live services have separate replay limits; they are not rolled back with RAM.

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, and the control protocol’s state.save, state.load, and state.info methods in the control protocol reference. This chapter is the implementation and format reference.

Design

Most state uses serde derives on the live structs, including Bus and the published m68k core. Types with file handles, decoder internals, or feature-dependent board variants use custom serialization. CpuCore serializes architectural state, prefetch state, MMU state, and timing configuration; runtime-only decoded-op, FastMem, and trace-JIT caches are skipped and rebuilt after deserialization. New fields are picked up by the derives automatically, and because a state file names its fields, most struct changes need no version at all (see Versioning). In a file the Bus is split into one chunk per subsystem; in memory it is one struct, and the split is a serialization-time view.

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 CPU cache models (Option<Box<CpuCache>>, None when absent or opted out). When the state carries None, load keeps the running machine’s cache model, if it has one, and re-derives its enable and freeze bits from the restored CACR
Buschip, slow, motherboard, and accelerator 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 and its PCMCIA card (an SRAM card’s contents included), 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:

Alongside the machine descriptor (described below), a state carries a StateMeta (savestate/meta.rs) in its own clear-text META chunk:

The metadata is host information, not machine state: Emulator::state_meta builds it at save time and nothing reads it on load. Its wall clock is the one thing two saves of the same machine at the same instant are expected to differ in (see Verification).

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 restores the saved machine model.

To make that takeover visible and to keep host-side derived values in step, the header carries a MachineDescriptor (config/mod.rs): the machine “shape” -- CPU model, chip, fast, slow, motherboard, and accelerator 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, adopting the state’s descriptor as the running one:

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): emulator::build_machine stamps the shape from 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

Hard-drive and CD backends store enough metadata to reopen their backing files during deserialization. Missing or moved images cause a load-time error:

Floppy images need no special handling: FloppyImage keeps its data in memory (FloppyImageData::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     container version, u32 little-endian (STATE_VERSION, 82)
12      ...   DESC chunk: the MachineDescriptor, uncompressed
...     ...   META chunk: the StateMeta, uncompressed (optional; none in v81)
...     ...   zlib stream (RFC 1950) of chunks, ending in an END chunk

Every chunk, in the clear or inside the zlib stream, is framed the same way:

offset  size  contents
0       4     tag, four ASCII bytes ("PAUL", "BUS ", "END ")
4       4     chunk version, u32 little-endian
8       8     payload length, u64 little-endian, or all ones
16      ...   payload

A payload length of all ones (chunk::STREAMED) means the writer did not know the length up front and the payload follows as a run of blocks, each a u32 little-endian length followed by that many bytes, ended by a zero-length block. Blocks are at most 1 MiB. The Bus chunks are written this way; the descriptor, the metadata, and the value chunks carry their length. A reader accepts either form for any chunk.

The DESC chunk sits uncompressed ahead of the zlib stream so a load can read it (and detect a machine mismatch) without inflating the whole machine; savestate::load returns the descriptor to Emulator::load_state for the comparison. The META chunk follows it in the clear for the same reason: savestate::peek (and peek_path) reads the header, the descriptor, and the metadata and stops, so a state browser can read every file in a folder for the cost of a few kilobytes each; it never inflates the machine. A reader tells the chunk from the zlib stream by the four-byte probe after the descriptor: the META tag means a metadata chunk, anything else (including the 0x78 a zlib stream starts with, and the end of a truncated file) is handed on as the body. A state written without the chunk therefore reads exactly as it did before the chunk existed, which is how version 81 is still read.

The clear-text layout is part of the container framing: a reader must know every tag it may meet ahead of the stream, so META is the only clear-text chunk besides DESC, and adding another there (as opposed to inside the stream, where unknown chunks are skipped) is a STATE_VERSION change. META itself moved the version to 82 for exactly that reason: the chunks either side of it are unchanged, but a version-81 reader expects the zlib stream directly after DESC, and a file that claims 81 without one there would be a version number describing two incompatible layouts. A load that meets a META chunk it cannot decode (damaged, or written at a version this build has no migration for) logs a warning and restores the machine anyway, since the machine does not depend on it. peek reports the same condition as an error, and the state browser lists the file without a picture.

Three more readers stop short of a load. savestate::read_descriptor checks the header and returns the descriptor, so a frontend with a fixed configuration can reject a different machine before loading it. savestate::inspect reports the header, the descriptor, the metadata, and the chunk directory of a file without restoring anything; it inflates the body to walk it, checking the framing and end marker as a load does. savestate::machine_body_offset gives the offset of the zlib stream in a file’s bytes, for comparing the machine of two files regardless of their metadata.

The zlib stream holds the chunks M68kMachine::write_chunks produces, in this order (the Bus chunks follow the order of their fields in Bus, with the catch-all last), then the END marker: version 0, empty, and followed by nothing but the end of the zlib stream. A stream cut short before the marker is refused; data after it is refused; reading up to the end of the stream is what makes the zlib decoder verify its trailer checksum. A file cut off after the marker still loads, since every chunk before it arrived complete.

TagPayloadContents
DESCvalue, in the clearMachineDescriptor
METAvalue, in the clear, optionalStateMeta: thumbnail PNG, emulated and wall-clock save times, machine summary, media names
CPU valueCpuCore
MACHvalueMachineRuntimeState
ICAC, DCACvalueOption<Box<CpuCache>>, nil when absent
MEM bus fieldsmem (chip/slow/motherboard/accelerator RAM, ROMs, WCS, and the Zorro autoconfig chain with its RAM boards), ram_init
CIAA, CIABbus fieldscia_a, cia_b
PAULbus fieldspaula
AGNSbus fieldsagnus
COPRbus fieldscopper
DENIbus fieldsdenise, denise_revision
BLITbus fieldsblitter
FLOPbus fieldsfloppy (controller, drives, in-memory disk images)
RTC bus fieldsrtc, rtc_present
GAYLbus fieldsgayle
PCMCbus fieldspcmcia (the card in Gayle’s slot: a CF card’s ATA state and configuration registers, or an SRAM card’s RAM and backing path); optional, absent from states written before the slot existed, which load with an empty socket
MOBObus fieldsramsey, gary, sdmac, ide_a4000
UAELbus fieldsuaelib
CARTbus fieldscartridge
AKIKbus fieldsakiko
CDTVbus fieldscdtv
ZORRbus fieldsdevices (every BoardDevice)
KEYBbus fieldskeyboard (the 6500/1 MCU model)
INPTbus fieldsinput (controller ports)
BUS bus restevery other Bus field: DMA arbitration, interrupt latches, beam-event capture, presentation windows, diagnostics

The chunks for optional hardware (GAYL, PCMC, MOBO, UAEL, CART, AKIK, CDTV) hold only Option fields and are not required: a state without one loads with that hardware absent. Apart from META, a state missing any other chunk is refused with a message naming it.

Bus keeps a single derived Serialize/Deserialize; the split is done by two serde adapters in savestate/split.rs. BusSplitter is a Serializer that only accepts a struct and streams each field into the chunk that claims it (savestate/chunk.rs holds the table): a chunk opens when its first field arrives and closes when a field of another chunk does, so a chunk’s fields must be adjacent in Bus (the splitter refuses a chunk that closes short), and only the catch-all BUS chunk, whose fields are scattered through the struct, is buffered until the end. A field added to Bus lands there with no table edit. BusJoiner is the Deserializer counterpart: it pulls chunks from the stream as the derived visitor asks for the next field, decoding each Bus chunk’s field map straight from the stream and handing the value chunks to the machine, in whatever order the chunks appear, so Bus::deserialize sees what a single-map encoding would have given it. Each bus fields payload is therefore a map from field name to value; a value payload is the value itself.

Neither side holds a copy of the state: saving keeps at most one block of the chunk being written plus the catch-all buffer (a few MiB of glue), and loading keeps at most one block of the chunk being read plus what has been decoded so far. A machine with gigabytes of memory-backed disk images therefore saves and loads with the same footprint the flat format had.

Chunk payloads are MessagePack in a fixed dialect (chunk::encode):

A Bus chunk decoded from the stream runs under the decoder’s own nesting limit (64 levels), and its framing must end exactly with its last field. A payload that is decoded from memory (the descriptor, the value chunks, and any chunk a migration rewrites, whose value-tree decoder has no limit of its own) is first walked by chunk::check_shape without building anything: it must be exactly one well-formed value that ends at the end of the payload and nests no deeper than 64 levels. So a crafted payload cannot drive a recursive decoder off the stack. Payload buffers grow one MiB at a time as bytes arrive, so a header or block claiming more than the stream holds fails at the end of the stream having allocated no more than one MiB past what exists; a state’s legitimate size is unbounded by design (memory-backed disk images ride in the payload), so there is deliberately no fixed cap.

Compression is flate2 at Compression::fast(); any standard zlib inflater reads it regardless of level.

Container versions 1 through 80 were a different layout: the descriptor (from version 5) and five bincode components (CpuCore, MachineRuntimeState, the two caches, the whole Bus) written positionally, with no field names and one global version. Those files are refused with a message saying the state must be saved again from a current build; there is no reader for them.

In-process snapshots do not use this container. M68kMachine::write_state writes the same five components as unframed positional bincode for the reverse-debugging ring and run-ahead (emulator.rs), where speed matters and only the build that wrote a blob ever reads it. Netplay rollback checkpoints use write_rollback_state, which prefixes the same blob with the rollback latches file states omit (the Bus rollback state and the CPU’s sampled interrupt level). write_chunks is the file-format twin; all of them go through the same adopt_state tail on restore.

Versioning

Three things are versioned, at three rates:

In this build every chunk, DESC and META included, is at version 1 and chunk::MIGRATIONS is empty; the migration tests supply their own step table through savestate::load_with_migrations.

The rule for a change to a serialized struct is therefore:

  1. Adding a field: give it #[serde(default)] (or #[serde(default = "fn")], or make it an Option) when the default is the right reading of a state that predates it. No version changes. Without a default the field is required and every older state fails to load naming the chunk and field, which is only acceptable while the format is on a development branch.

  2. Removing or reordering a field: nothing to do.

  3. Renaming a field whose old value must carry over: #[serde(alias = "old_name")].

  4. Changing what a field means, or its representation in a way the MessagePack reader cannot bridge (an integer that became a struct, a tuple whose elements changed roles, a widened enum whose old variants were renumbered): bump the owning chunk’s version in chunk::CHUNKS, note it in the version comment there, and add a Migration for the previous version that rewrites the old shape. Add a test that loads a payload of the old shape through it.

  5. Adding a subsystem: give it a chunk (table entry naming its Bus fields, which must sit next to each other in Bus) rather than letting it grow the BUS catch-all.

savestate::SCHEMA_FINGERPRINT hashes the crate version, the container version, and every chunk’s tag and version; the netplay handshake (netplay/wire.rs) carries it so two peers agree they serialize a machine identically before trusting each other’s frame checksums.

savestate::tests cover each of these paths against a real machine: unknown chunks and reordered chunks (unknown_chunks_are_skipped_and_chunk_order_does_not_matter), fields a struct no longer has or did not have yet (states_survive_fields_a_struct_no_longer_has_or_did_not_have_yet), migrations and newer-chunk refusal (older_chunk_versions_load_through_migrations_and_newer_ones_are_refused), the table naming real Bus fields (every_bus_chunk_holds_exactly_the_fields_it_claims), the end marker and trailer checks (the_end_marker_and_the_compressed_trailer_are_verified), hostile lengths and nesting (hostile_chunk_lengths_and_nesting_fail_instead_of_exhausting_memory), and the metadata chunk: two checked-in fixtures of the test machine, tests/fixtures/state-v81-no-meta.clstate (a real version-81 state, never re-blessed, so the container this build writes is proven not to have orphaned the one before it) and state-v82-meta.clstate, both of which must keep loading and peeking as expected (fixture_states_with_and_without_metadata_load_and_peek; COPPERLINE_BLESS_STATE_FIXTURES=1 rewrites the current-version one from this build), peek stopping at the clear chunks (peek_reads_the_metadata_from_the_clear_chunks_alone), the body’s independence from the metadata (metadata_is_outside_the_byte_identity_of_the_machine_body), and the warn-on-load / fail-on-peek split for unreadable metadata (unreadable_metadata_warns_on_load_but_fails_peek).

Snapshot point and atomicity

File saves write through a buffered compressor into a unique sibling temporary file. After compression and flushing succeed, the file is synced and atomically renamed over the destination. Returned errors leave an existing save untouched and remove the temporary file. This guarantees complete-file replacement, not power-loss durability of the directory entry: the containing directory is not fsynced. The browser build saves through save_to_writer (Emulator::save_state_bytes) and manages publication itself.

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. Emulator::save_state first quiesces a copperhf.device board, so its worker-thread I/O is drained before the board is serialized. savestate::load parses fully before applying, then moves host resources across and clears transient video capture buffers; Emulator::load_state then drops any queued live-audio presentation frames from the old timeline. 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 resets its render pipeline (reset_render_pipeline, which clears last_rendered_emulated_frame), so the next presentation re-renders from the restored Bus.

Verification

The regression checks cover serialization, failure recovery and replay:

Reverse debugging (timetravel.rs)

Reverse debugging keeps a ring of recent machine states. To reach an earlier point, it restores the nearest preceding snapshot and replays forward. The user-facing controls (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, cck, blob } entries, captured by Emulator::tt_capture_if_due at frame boundaries -- the same quiescent point save states require. cck is the emulated colour-clock count, the coordinate the remaining history is reported in. 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 at most 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. A debugger can also force a capture at a stop (Emulator::debug_time_travel_anchor_now, the control protocol’s reverse_anchor), so a later step back replays from the stop rather than from an older frame boundary. Repositioning to an earlier point discards the snapshots and logged input after it (Emulator::tt_discard_after), since they describe a future that will not happen again.

Position coordinate

Reverse ops navigate by Emulator::retired_instructions, a monotonic count bumped per retired instruction in execute_cpu_slice. It is stored alongside each snapshot in the ring, outside the serialized machine blob. 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, pot, and light-pen helpers, through which scripted, window, and control-protocol input funnel, and from the freezer cartridge button). 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

WinUAE interchange (uss.rs)

USS import is separate from native serialization and does not change STATE_VERSION. UssFile parses and validates the complete ASF chunk stream before installing RAM, CPU registers and hardware latches in a newly built machine. Lengths include the 12-byte chunk header; bit 0 requests zlib with a big-endian inflated length prefix. Padding is 4 - (payload_length % 4), including four bytes for an aligned chunk. END has an eight-byte header. Input and total inflated bytes are limited to 256 MiB, individual chunks to 128 MiB, and the stream to 4096 chunks.

The compact CHIP layout omits audio and sprite register blocks, which are restored from AUD0-3 and SPR0-7. Custom writes skip command/trigger registers such as BLTSIZE, COPJMP and DSKLEN; importing them as ordinary writes would start transfers absent from the snapshot. CIA counters use big-endian words while their saved latches and TOD bytes use little-endian ordering. ROM matching uses the declared CRC over the normalized 256/512 KiB image, accepting a mirrored 256 KiB ROM and naming a mismatch through romdb. The validated CHPX flag word restores the live boot-ROM overlay at address zero; absent CHPX data retains the older chip-RAM mapping convention.

WinUAE’s event queue, CPU prefetch/cache contents, Copper phase and shift register pipelines have no direct Copperline representation. Import starts at a reconstructed beam boundary, restarts Copper from COP1LC, and advances one discarded frame through the normal CPU/chipset path. This is an interchange approximation, not byte-identical resumption of WinUAE timing. Unsupported active blitter/disk operations and device chunks are rejected; other omitted chunks are reported. See the coverage assessment before extending it.

An externally forced debugger PC write invalidates the instruction prefetch queue before execution resumes at the new address. Native snapshot restores retain their saved queue for exact replay; USS imports start with a cold queue at the imported PC.

Bartman GDB sessions

The Bartman binary profile writer (profile/bartman.rs) is a driver-owned bounded operation shared by headless and GUI GDB and the offline CLI (copperline-ctl). It uses the normal precise CPU sampler and full bus trace, translates wire record fields explicitly, and embeds the real framebuffer. Its temporary file, progress transport and instrumentation are host state and never enter native snapshots.

Windowed Bartman --run sessions start paused before the first GDB client connects. The initial stop query then drives the shared core’s LoadSeg handshake and saves the program-entry state that monitor reset restores: an in-memory state file (Emulator::save_state_bytes, restored with load_state_bytes). Starting paused keeps debugger startup latency from consuming the load event before the per-connection library tracker is armed. Other GUI GDB launches keep their normal run-until-attach behaviour.

Libretro checkpoints

Libretro uses the same descriptor, subsystem chunks, MessagePack codec and transactional component adoption as file states. Its CLFRONT1 envelope contains the schema fingerprint, DESC, a versioned FLAT chunk holding CPU interrupt-sampling and Bus rollback latches, then the ordinary machine chunks and end marker. The machine chunks are uncompressed because RetroArch captures them every field and handles transport/storage compression itself. A checkpoint loads only into a build with the same schema fingerprint and a running machine whose descriptor matches exactly (savestate::load_frontend). The ordinary desktop .clstate format is unchanged by this frontend.

The libretro adapter adds its content identity, checksum, playlist writes, pending input and presentation state around that checkpoint. A scoped CD path resolver maps verified immutable sources to content-derived names; WHDLoad disks reference deterministic session bases and carry their sector writes. Loading parses all chunks before adopting either the machine or its rollback latches. Netplay excludes host audio backlog and persistent writes.