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}