feat(core): add whole-machine save states with F5/F9 window slots

Machine::save_state and load_state encode the CPU, every RAM, and every device
through Snapshot impls into a versioned, checksummed format. Rejected loads leave
the machine unchanged. The ROM window saves to and loads from one state file
with F5 and F9.
This commit is contained in:
0xrsydn 2026-10-08 21:39:16 +07:00
commit fd73788735
55 changed files with 5157 additions and 20 deletions

View file

@ -99,6 +99,10 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events |
| `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs |
| `crates/gba-core/src/machine.rs` | Timed CPU/DMA/device stepping and IRQ delivery |
| `crates/gba-core/src/snapshot.rs` | Save-state binary primitives (writer, reader, tags, FNV fingerprint) and `SnapshotError` |
| `crates/gba-core/src/machine/snapshot.rs` | `Machine::save_state`/`load_state`: header, image and cartridge identity, checksum, verify-before-mutate |
| `crates/gba-core/src/memory/snapshot.rs`, `crates/gba-core/src/io/snapshot.rs` | Memory bus and I/O register encoders; every other state type implements `Snapshot` beside its definition |
| `crates/gba-core/tests/save_state.rs` | Restore-equals-uninterrupted determinism, in-flight Flash operation, and rejected-input tests |
| `crates/gba-core/src/timing.rs` | Bus widths, wait-state costs, ordered CPU timing transactions, and timing breakdowns |
| `crates/gba-core/src/timing/prefetch.rs` | Nominal eight-halfword opcode queue, partial transfers, capacity stops, and cancellation |
| `crates/gba-core/src/timing/prefetch/tests.rs` | Wait-state matrices, partial words, cancellation phases, boundaries, configuration, and progress consistency |
@ -175,6 +179,7 @@ The repository is a Cargo workspace with three crates:
| `crates/desktop/tests/save_cli.rs` | Original program/save/restart/load tests, diagnostic isolation, and native-window persistence |
| `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests |
| `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits |
| `crates/desktop/src/cartridge/state_file.rs` | Atomic save-state file writes and bounded reads |
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, optional audio draining/muting, speed-scaled host pacing, and frame-limited exit |
| `crates/desktop/examples/write_rom_demo.rs` | Non-overwriting export of the original input-test ROM |

View file

@ -97,6 +97,7 @@ Controls use the same focus correction and key mapping as the existing demos:
| Backspace | Select |
Escape or the window close control ends execution. Enter does not reset the emulator.
F5 writes and F9 loads a [save state](save-states.md) in the file chosen by `--state-file PATH` (default: the ROM path with a `.state` extension).
Focus loss releases all buttons but does not pause execution.
The host samples buttons before each execution slice, including while stopped.

View file

@ -0,0 +1,62 @@
# Save states
A save state freezes the emulated machine at a step boundary and restores it later.
It is separate from the game's own save (Flash) and does not replace it: [Flash persistence](saves.md) still needs the game's SAVE command and a clean exit.
## Desktop use
Window mode binds two keys while the window has focus:
| Key | Action |
| --- | --- |
| F5 | Write the state file |
| F9 | Load the state file |
The slot is `--state-file PATH`. Without it, the ROM path with its extension replaced by `.state` is used (`roms/pokemon-emerald.gba` becomes `roms/pokemon-emerald.state`).
Only one slot exists. F5 replaces it, via a unique sibling temporary file that is synced and renamed, so an interrupted write keeps the previous state.
State files are created with mode `0600`. The option is accepted only with `--window`.
A rejected load (missing file, wrong ROM, corrupt or older-format file) is reported in the terminal and the game keeps running unchanged.
Loading discards queued host audio and shows the state's last captured frame immediately.
The Flash image inside the state replaces the running one. It is written to `--save-file` only on a clean exit, and only if it differs from the last persisted bytes.
The RTC resumes at the saved calendar value and then follows host elapsed time again.
Quitting with Escape does not write a state. Press F5 first.
## What a state holds
`Machine::save_state` and `Machine::load_state` (`crates/gba-core/src/machine/snapshot.rs`) encode:
- CPU registers, banks, status, instruction buffer, and retained fetch history.
- All RAMs, VRAM, palette, and OAM, plus the Flash image and its modified flag.
- Every device: timers and the shared prescaler, DMA channels and latches, display position, interrupts, keypad, serial, audio (FIFOs, PSG channels, wave RAM), Flash and RTC/GPIO state machines.
- Video latches: affine accumulators, mosaic and window counters, sprite row pipeline, and the double-buffered scanline capture.
- Game Pak prefetch, IWRAM bus lanes, BIOS protected-read snapshot, the device cycle count, and the last step timing.
It does not hold the ROM or firmware bytes, the host's choice of cartridge hardware and save device, row-capture and audio-capture enablement, or host audio queues and drop counters.
An enabled audio capture keeps running and resumes its sample phase.
## Format and checks
The layout is `GBAS`, a format version, ROM length and fingerprint, firmware presence and fingerprint, cartridge setup, a length-prefixed payload, and a trailing checksum.
Fingerprints and the checksum are 64-bit FNV-1a: they catch mismatched images and accidental corruption, not tampering.
The payload is positional with a four-byte tag per section, so a reader out of step fails at the next boundary.
Integers are little-endian. Allocations are bounded by fixed memory sizes; no length field drives an unbounded allocation.
`load_state` verifies the magic, version, checksum, and ROM/firmware/cartridge identity before changing anything.
These failures leave the machine untouched.
A failure after that point returns `RestoreFailed`; it indicates an encoder defect, and the machine must be rebuilt.
Any format change bumps the version. Older states are rejected, not migrated.
## Limits
- `save_state` fails with `MidStep` inside a step and `Unsupported` while the machine holds a deferred diagnostic error (a stored failed instruction fetch or failed captured frame). Saving at a later step boundary normally works.
- States are for the same build line: any change to encoded hardware state needs a version bump and invalidates old files.
- Checksum and fingerprints are not authentication. Do not load untrusted state files expecting more than memory safety.
- A state restores hardware, not the host: window frame counters, audio device buffers, and the host RTC baseline are not part of it.
## Evidence
`crates/gba-core/tests/save_state.rs` runs original programs, snapshots mid-run, restores into a freshly built machine, and compares per-frame hashes of pixels, captured audio, registers, and the device clock, then compares the final state bytes.
It also restores a Flash program operation in flight and checks that every rejected input leaves the machine unchanged.
Each encoded type destructures its struct exhaustively, so a new field is a compile error until it is encoded or excluded with a reason.
Per-type unit tests check round trips, rejection of every truncated prefix, and invalid discriminants.

View file

@ -130,7 +130,7 @@ The frontend persists changed bytes only on a clean exit:
An incomplete Flash command/operation refuses persistence and returns an error, even if no bytes changed yet.
CPU, DMA, rendering, window, or reported audio errors do not persist the session image.
There is no autosave, crash recovery, save-state support, or RTC persistence.
There is no autosave, crash recovery, or RTC persistence. [Save states](save-states.md) are a separate, whole-machine feature and do not replace the game's own save.
The emulator cannot detect a game's multi-command save transaction. Exiting between completed commands can still save incomplete game data.
A crash or forced termination loses changes since startup and can leave a stale lock.

View file

@ -84,6 +84,7 @@ Update this file when a feature lands or a limit is removed.
- Transactional Flash completion and modification tracking, with exact-size core image loading and inspection.
- Optional desktop `--save-file` loading and clean-exit persistence, with exclusive locks, atomic installation, and unique previous-image backups.
- Original ROM save/restart/load checks across processes, including native-window persistence on Darwin arm64.
- Whole-machine [save states](hardware/save-states.md): `Machine::save_state`/`load_state` with image and cartridge identity checks, a checksum, and verify-before-mutate loading; ROM windows bind F5 (save) and F9 (load) to a `--state-file` slot.
- Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports.
- Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices.
- RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
@ -192,7 +193,7 @@ That run used no scripted input or pixel assertions.
A [fixed-time input probe](research/emerald-wave.md) found a wave trigger with its gate disabled.
Wave support now passes that point and reaches 1,320 frames with scheduled Start/A presses and releases.
Output differs from a no-input control, but readable menus and gameplay remain unverified.
Flash writing and host persistence now pass original save/restart/load tests. Emerald saving and reloading remain unverified.
Flash writing and host persistence now pass original save/restart/load tests. Emerald saving and reloading remain unverified; save states offer a separate restore point.
A user reached starter selection, then reported a BIOS invalid-argument trap.
An [isolated reproduction](research/emerald-empty-lz.md) identified a zero-length LZ77 header rejected before the length check.
The replacement BIOS now returns for that empty stream. Full starter-selection progress after this correction needs user retesting.