Skip to main content

rusty2600_core/
save_state.rs

1//! Save-states — a versioned binary snapshot of an entire [`System`].
2//!
3//! [`System`] (and everything it owns — `Cpu`, `Bus`, `Tia`, `Riot`,
4//! `Cartridge`) already derives `serde::Serialize`/`Deserialize`, so this
5//! module is a thin wrapper: a small header (magic, format version, a
6//! caller-supplied ROM identity tag) plus the `System` itself, encoded with
7//! `postcard` (a compact, `no_std`+`alloc`-friendly binary serde format — no
8//! hand-rolled tagged-section encoder needed, unlike a codebase that has to
9//! avoid `serde` in its core).
10//!
11//! `rusty2600-core` doesn't know how a ROM's identity should be computed
12//! (that's a frontend/tooling concern — a full SHA-256, a fast FNV-1a, or
13//! anything else); callers supply an opaque `rom_tag: u64` and
14//! [`SaveState::restore`] simply checks it matches what was captured, so a
15//! save file can't silently be loaded against the wrong cartridge.
16//!
17//! See `docs/adr/0007-save-state-versioning.md` for the version-compatibility
18//! policy this header format implements.
19
20use alloc::vec::Vec;
21
22use crate::scheduler::System;
23
24/// The save-state file magic (`"R26S"`).
25const MAGIC: [u8; 4] = *b"R26S";
26
27/// The current save-state format version. Bump this only per the migration
28/// policy in ADR 0007 (additive changes within a MINOR release may keep this
29/// the same, relying on `#[serde(default)]` on new fields; anything else
30/// bumps it and must be handled explicitly by [`SaveState::restore`]).
31const FORMAT_VERSION: u16 = 1;
32
33/// The oldest format version this build can still read.
34const MIN_SUPPORTED_FORMAT_VERSION: u16 = 1;
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37struct SaveStateHeader {
38    magic: [u8; 4],
39    format_version: u16,
40    rom_tag: u64,
41}
42
43/// A captured snapshot of a [`System`], ready to be encoded to bytes or
44/// restored back into a running system.
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct SaveState {
47    header: SaveStateHeader,
48    system: System,
49}
50
51/// Everything that can go wrong loading a save-state.
52#[derive(Debug)]
53pub enum SaveStateError {
54    /// The byte stream didn't decode as a `SaveState` at all (truncated,
55    /// corrupt, or not a save-state file).
56    Malformed,
57    /// The decoded header's magic didn't match — this isn't a Rusty2600
58    /// save-state file.
59    BadMagic,
60    /// The save-state's `rom_tag` doesn't match the ROM currently loaded.
61    RomMismatch,
62    /// The save-state's format version is older than this build can read.
63    UnsupportedFormat {
64        /// The format version stored in the file.
65        file_version: u16,
66        /// The oldest format version this build supports.
67        min_supported: u16,
68    },
69}
70
71impl core::fmt::Display for SaveStateError {
72    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
73        match self {
74            Self::Malformed => write!(f, "save-state data is malformed or truncated"),
75            Self::BadMagic => write!(f, "not a Rusty2600 save-state file"),
76            Self::RomMismatch => write!(f, "save-state was captured for a different ROM"),
77            Self::UnsupportedFormat {
78                file_version,
79                min_supported,
80            } => write!(
81                f,
82                "save-state format v{file_version} is older than the minimum \
83                 supported format v{min_supported}"
84            ),
85        }
86    }
87}
88
89impl SaveState {
90    /// Captures `system` as a save-state tagged with `rom_tag` (an opaque
91    /// caller-supplied ROM identity, e.g. a hash of the loaded ROM image).
92    #[must_use]
93    pub fn capture(system: &System, rom_tag: u64) -> Self {
94        Self {
95            header: SaveStateHeader {
96                magic: MAGIC,
97                format_version: FORMAT_VERSION,
98                rom_tag,
99            },
100            system: system.clone(),
101        }
102    }
103
104    /// Encodes this save-state to its binary wire format.
105    #[must_use]
106    pub fn encode(&self) -> Vec<u8> {
107        // A `SaveState` built via `capture` always serializes successfully —
108        // every field is a plain derive over data this crate itself owns, no
109        // trait objects or external I/O to fail on.
110        postcard::to_allocvec(self).unwrap_or_default()
111    }
112
113    /// Decodes a save-state from its binary wire format, without checking it
114    /// against any particular ROM yet (see [`Self::restore`] for that).
115    pub fn decode(bytes: &[u8]) -> Result<Self, SaveStateError> {
116        let state: Self = postcard::from_bytes(bytes).map_err(|_| SaveStateError::Malformed)?;
117        if state.header.magic != MAGIC {
118            return Err(SaveStateError::BadMagic);
119        }
120        if state.header.format_version < MIN_SUPPORTED_FORMAT_VERSION {
121            return Err(SaveStateError::UnsupportedFormat {
122                file_version: state.header.format_version,
123                min_supported: MIN_SUPPORTED_FORMAT_VERSION,
124            });
125        }
126        Ok(state)
127    }
128
129    /// Decodes and validates a save-state against `rom_tag`, returning the
130    /// [`System`] it captured. Fails with [`SaveStateError::RomMismatch`] if
131    /// the save-state was captured for a different ROM.
132    pub fn restore(bytes: &[u8], rom_tag: u64) -> Result<System, SaveStateError> {
133        let state = Self::decode(bytes)?;
134        if state.header.rom_tag != rom_tag {
135            return Err(SaveStateError::RomMismatch);
136        }
137        Ok(state.system)
138    }
139
140    /// The `System` this save-state captured, without any ROM-tag check —
141    /// for callers (like the rewind ring) that already know the ROM can't
142    /// have changed since capture.
143    #[must_use]
144    pub fn into_system(self) -> System {
145        self.system
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn round_trip_is_byte_identical() {
155        let mut system = System::new(42);
156        system.step_instruction();
157        system.step_instruction();
158
159        let saved = SaveState::capture(&system, 0xDEAD_BEEF);
160        let bytes = saved.encode();
161        let restored = SaveState::restore(&bytes, 0xDEAD_BEEF).expect("round-trip should succeed");
162
163        assert_eq!(system.color_clocks(), restored.color_clocks());
164        assert_eq!(
165            (system.cpu.a, system.cpu.x, system.cpu.y, system.cpu.pc),
166            (
167                restored.cpu.a,
168                restored.cpu.x,
169                restored.cpu.y,
170                restored.cpu.pc
171            )
172        );
173        assert_eq!(system.bus.riot.ram, restored.bus.riot.ram);
174    }
175
176    #[test]
177    fn rom_tag_mismatch_is_rejected() {
178        let system = System::new(1);
179        let saved = SaveState::capture(&system, 1);
180        let bytes = saved.encode();
181        let err = SaveState::restore(&bytes, 2).expect_err("mismatched rom_tag must fail");
182        assert!(matches!(err, SaveStateError::RomMismatch));
183    }
184
185    #[test]
186    fn bad_magic_is_rejected() {
187        let system = System::new(1);
188        let saved = SaveState::capture(&system, 1);
189        let mut bytes = saved.encode();
190        // Corrupt the header's magic bytes (first 4 bytes of the postcard
191        // stream, since `SaveStateHeader` is the first field serialized).
192        bytes[0] ^= 0xFF;
193        let err = SaveState::decode(&bytes);
194        assert!(err.is_err());
195    }
196
197    #[test]
198    fn truncated_bytes_are_rejected() {
199        let err = SaveState::decode(&[0u8; 2]);
200        assert!(matches!(err, Err(SaveStateError::Malformed)));
201    }
202}