rustysnes_savestate/lib.rs
1//! `rustysnes-savestate` — the shared save-state wire-format primitives (savestate).
2//!
3//! A leaf crate in the one-directional chip-crate graph (see `docs/architecture.md`): every
4//! chip crate (`rustysnes-cpu`/`-ppu`/`-apu`/`-cart`) and `rustysnes-core` depend on this for a
5//! single, versioned binary format, per `docs/adr/0006-save-state-format.md`. No dependents
6//! among the chip crates — this only ever gets DEPENDED ON, keeping the graph acyclic.
7//!
8//! Deliberately no `serde`/reflection: every `Board`/`Cpu`/`Ppu`/`Apu` implementation writes an
9//! explicit `save_state`/`load_state` pair using the primitives here (ADR 0006's "no derive
10//! magic" decision — keeps `#![no_std]` targets byte-identical to native and keeps each
11//! component's on-disk format change local + auditable, matching this project's existing style
12//! for the `Board` trait itself).
13//!
14//! # Format
15//!
16//! This crate defines the **section framing** a save-state's payload is built from — a full
17//! save-state additionally has a top-level header (magic bytes + format version + crate-version
18//! string, ADR 0006) that `rustysnes-core::System::save_state`/`load_state` writes/checks before
19//! ever touching a section; that envelope is out of scope here (see [`SaveStateError::BadMagic`]/
20//! [`SaveStateError::UnsupportedVersion`], which exist for that caller to use).
21//!
22//! A section is **tagged and length-prefixed** — a 4-byte ASCII tag, a `u32` little-endian byte
23//! length, then that many bytes of section-defined content. Wrapping a component's state as one
24//! such section (via [`SaveWriter::section`] / [`SaveReader::section`]) is what lets a newer
25//! format skip a section it doesn't recognize (a bumped-version load) rather than corrupting the
26//! rest of the load, and lets a struct that changed its own internal layout stay self-describing
27//! without a central schema registry.
28
29#![no_std]
30#![forbid(unsafe_code)]
31extern crate alloc;
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use thiserror::Error;
37
38/// Errors from decoding a save-state.
39///
40/// `docs/adr/0006`'s "reject loudly on an unrecognized/corrupt format, never silently truncate
41/// or zero-fill" honesty posture — the same posture `docs/adr/0003` already applies to
42/// coprocessor accuracy.
43#[derive(Debug, Error, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum SaveStateError {
46 /// The buffer ended before the expected number of bytes were available.
47 #[error("truncated save-state data (expected {expected} more bytes, {available} available)")]
48 Truncated {
49 /// Bytes the read attempted to consume.
50 expected: usize,
51 /// Bytes actually remaining in the buffer.
52 available: usize,
53 },
54 /// A section's declared tag didn't match what the caller expected at this point in the
55 /// format — either genuine corruption, or a format version whose section order changed.
56 #[error("unexpected section tag: expected {expected:?}, found {found:?}")]
57 UnexpectedTag {
58 /// The 4-byte ASCII tag the caller expected.
59 expected: [u8; 4],
60 /// The 4-byte ASCII tag actually present.
61 found: [u8; 4],
62 },
63 /// The blob's leading magic bytes didn't match — not a RustySNES save-state at all.
64 #[error("not a RustySNES save-state (bad magic)")]
65 BadMagic,
66 /// The blob's format-version major number is newer than this build understands.
67 #[error("save-state format version {found} is newer than this build supports (max {max})")]
68 UnsupportedVersion {
69 /// The format major version found in the blob.
70 found: u16,
71 /// The highest format major version this build can load.
72 max: u16,
73 },
74 /// A component rejected the decoded bytes as semantically invalid (e.g. an enum discriminant
75 /// with no matching variant) even though the section framing itself was well-formed.
76 #[error("invalid save-state content: {0}")]
77 Invalid(String),
78}
79
80/// A growable little-endian binary writer over an in-memory buffer (`#![no_std]` + `alloc`, no
81/// `std::io::Write` — every chip crate this feeds is `no_std`).
82#[derive(Debug, Default)]
83pub struct SaveWriter {
84 buf: Vec<u8>,
85}
86
87impl SaveWriter {
88 /// A fresh, empty writer.
89 #[must_use]
90 pub const fn new() -> Self {
91 Self { buf: Vec::new() }
92 }
93
94 /// Consume the writer, returning the assembled bytes.
95 #[must_use]
96 pub fn into_bytes(self) -> Vec<u8> {
97 self.buf
98 }
99
100 /// The bytes written so far (for a nested writer being spliced into a parent — see
101 /// [`Self::section`]).
102 #[must_use]
103 pub fn as_bytes(&self) -> &[u8] {
104 &self.buf
105 }
106
107 /// Append raw bytes verbatim (for a component embedding another's already-serialized bytes,
108 /// e.g. a coprocessor's own sub-engine).
109 pub fn write_bytes(&mut self, bytes: &[u8]) {
110 self.buf.extend_from_slice(bytes);
111 }
112
113 /// Write one byte.
114 pub fn write_u8(&mut self, v: u8) {
115 self.buf.push(v);
116 }
117
118 /// Write a `bool` as one byte (`0`/`1`).
119 pub fn write_bool(&mut self, v: bool) {
120 self.write_u8(u8::from(v));
121 }
122
123 /// Write a little-endian `u16`.
124 pub fn write_u16(&mut self, v: u16) {
125 self.buf.extend_from_slice(&v.to_le_bytes());
126 }
127
128 /// Write a little-endian `u32`.
129 pub fn write_u32(&mut self, v: u32) {
130 self.buf.extend_from_slice(&v.to_le_bytes());
131 }
132
133 /// Write a little-endian `u64`.
134 pub fn write_u64(&mut self, v: u64) {
135 self.buf.extend_from_slice(&v.to_le_bytes());
136 }
137
138 /// Write a byte slice prefixed with its `u32` length (for a variable-length payload, e.g. an
139 /// SRAM image — distinct from [`Self::section`], which additionally carries a 4-byte tag for
140 /// forward-compatible skipping; a length-prefixed blob has no tag because its meaning is
141 /// implied by its fixed position in the caller's own section).
142 pub fn write_len_prefixed(&mut self, bytes: &[u8]) {
143 #[allow(clippy::cast_possible_truncation)] // save-state payloads never approach 4 GiB
144 self.write_u32(bytes.len() as u32);
145 self.write_bytes(bytes);
146 }
147
148 /// Write a nested, self-describing section: a 4-byte ASCII `tag`, a `u32` byte length, then
149 /// whatever `body` writes. `tag` should be a short, stable, human-legible identifier (e.g.
150 /// `*b"CPU0"`, `*b"BRD1"`) — stable across releases even if the section's internal layout
151 /// changes, since [`SaveReader::section`] uses it to skip a section it doesn't recognize.
152 ///
153 /// `body` writes directly into this writer's own buffer (no nested `Vec` allocation — a
154 /// save-state gets created and restored repeatedly during rewind/run-ahead, so allocating one
155 /// throwaway buffer per (possibly nested) section on every such call would add up); the length
156 /// header is a zero placeholder written up front and patched in place once `body` returns and
157 /// the section's true length is known.
158 pub fn section(&mut self, tag: [u8; 4], body: impl FnOnce(&mut Self)) {
159 self.write_bytes(&tag);
160 let len_pos = self.buf.len();
161 self.write_u32(0); // placeholder, patched below
162 let start = self.buf.len();
163 body(self);
164 let len = self.buf.len() - start;
165 #[allow(clippy::cast_possible_truncation)] // save-state sections never approach 4 GiB
166 self.buf[len_pos..len_pos + 4].copy_from_slice(&(len as u32).to_le_bytes());
167 }
168}
169
170/// A little-endian binary cursor reader over a borrowed byte slice.
171///
172/// Bounds-checked reads return [`SaveStateError::Truncated`] instead of panicking on
173/// malformed/corrupt input (external, untrusted data — a save-state file the user can hand-edit
174/// or a corrupted disk write) — "validate at boundaries" / "never `unwrap` on untrusted data"
175/// (project-wide house rules, `master-core/modules/60-security.md`).
176#[derive(Debug, Clone, Copy)]
177pub struct SaveReader<'a> {
178 buf: &'a [u8],
179 pos: usize,
180}
181
182impl<'a> SaveReader<'a> {
183 /// A reader positioned at the start of `buf`.
184 #[must_use]
185 pub const fn new(buf: &'a [u8]) -> Self {
186 Self { buf, pos: 0 }
187 }
188
189 /// Bytes remaining unread.
190 #[must_use]
191 pub const fn remaining(&self) -> usize {
192 self.buf.len() - self.pos
193 }
194
195 fn take(&mut self, n: usize) -> Result<&'a [u8], SaveStateError> {
196 if self.remaining() < n {
197 return Err(SaveStateError::Truncated {
198 expected: n,
199 available: self.remaining(),
200 });
201 }
202 let out = &self.buf[self.pos..self.pos + n];
203 self.pos += n;
204 Ok(out)
205 }
206
207 /// Read raw bytes verbatim (the counterpart to [`SaveWriter::write_bytes`] for a component
208 /// that embeds another's already-serialized bytes at a caller-known fixed length).
209 ///
210 /// # Errors
211 /// [`SaveStateError::Truncated`] if fewer than `n` bytes remain.
212 pub fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], SaveStateError> {
213 self.take(n)
214 }
215
216 /// Read one byte.
217 ///
218 /// # Errors
219 /// [`SaveStateError::Truncated`] if the buffer is exhausted.
220 pub fn read_u8(&mut self) -> Result<u8, SaveStateError> {
221 Ok(self.take(1)?[0])
222 }
223
224 /// Read a `bool` (any nonzero byte is `true`, matching how `write_bool` only ever emits 0/1
225 /// but a hand-corrupted file might not — never a hard error for either value).
226 ///
227 /// # Errors
228 /// [`SaveStateError::Truncated`] if the buffer is exhausted.
229 pub fn read_bool(&mut self) -> Result<bool, SaveStateError> {
230 Ok(self.read_u8()? != 0)
231 }
232
233 /// Read a little-endian `u16`.
234 ///
235 /// # Errors
236 /// [`SaveStateError::Truncated`] if fewer than 2 bytes remain.
237 pub fn read_u16(&mut self) -> Result<u16, SaveStateError> {
238 let b = self.take(2)?;
239 Ok(u16::from_le_bytes([b[0], b[1]]))
240 }
241
242 /// Read a little-endian `u32`.
243 ///
244 /// # Errors
245 /// [`SaveStateError::Truncated`] if fewer than 4 bytes remain.
246 pub fn read_u32(&mut self) -> Result<u32, SaveStateError> {
247 let b = self.take(4)?;
248 Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
249 }
250
251 /// Read a little-endian `u64`.
252 ///
253 /// # Errors
254 /// [`SaveStateError::Truncated`] if fewer than 8 bytes remain.
255 pub fn read_u64(&mut self) -> Result<u64, SaveStateError> {
256 let b = self.take(8)?;
257 Ok(u64::from_le_bytes([
258 b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
259 ]))
260 }
261
262 /// Read a `u32`-length-prefixed byte slice (the counterpart to
263 /// [`SaveWriter::write_len_prefixed`]).
264 ///
265 /// # Errors
266 /// [`SaveStateError::Truncated`] if the declared length exceeds what remains.
267 pub fn read_len_prefixed(&mut self) -> Result<&'a [u8], SaveStateError> {
268 let len = self.read_u32()? as usize;
269 self.take(len)
270 }
271
272 /// Read a nested section written by [`SaveWriter::section`]: consumes its 4-byte tag + `u32`
273 /// length, and returns `(tag, sub_reader)` scoped to exactly that section's bytes — reading
274 /// past the sub-reader's end fails with [`SaveStateError::Truncated`] even if the OUTER
275 /// buffer has more data, which is what makes an unrecognized/truncated-by-a-bug section fail
276 /// locally instead of desynchronizing every section after it.
277 ///
278 /// # Errors
279 /// [`SaveStateError::Truncated`] if the tag/length header or the declared body is truncated.
280 pub fn section(&mut self) -> Result<([u8; 4], Self), SaveStateError> {
281 let tag_bytes = self.take(4)?;
282 let tag = [tag_bytes[0], tag_bytes[1], tag_bytes[2], tag_bytes[3]];
283 let body = self.read_len_prefixed()?;
284 Ok((tag, Self::new(body)))
285 }
286
287 /// [`Self::section`], additionally checking the tag matches `expected` — the common case
288 /// where the caller knows exactly which section should come next (most of the format is a
289 /// fixed sequence; only the top-level `Board`-family dispatch genuinely branches on tag).
290 ///
291 /// # Errors
292 /// [`SaveStateError::Truncated`] per [`Self::section`], or [`SaveStateError::UnexpectedTag`]
293 /// if the next section's tag doesn't match `expected`.
294 pub fn expect_section(&mut self, expected: [u8; 4]) -> Result<Self, SaveStateError> {
295 let (found, reader) = self.section()?;
296 if found == expected {
297 Ok(reader)
298 } else {
299 Err(SaveStateError::UnexpectedTag { expected, found })
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn primitives_round_trip() {
310 let mut w = SaveWriter::new();
311 w.write_u8(0xAB);
312 w.write_bool(true);
313 w.write_u16(0x1234);
314 w.write_u32(0xDEAD_BEEF);
315 w.write_u64(0x1122_3344_5566_7788);
316 w.write_len_prefixed(&[1, 2, 3]);
317 let bytes = w.into_bytes();
318
319 let mut r = SaveReader::new(&bytes);
320 assert_eq!(r.read_u8().unwrap(), 0xAB);
321 assert!(r.read_bool().unwrap());
322 assert_eq!(r.read_u16().unwrap(), 0x1234);
323 assert_eq!(r.read_u32().unwrap(), 0xDEAD_BEEF);
324 assert_eq!(r.read_u64().unwrap(), 0x1122_3344_5566_7788);
325 assert_eq!(r.read_len_prefixed().unwrap(), &[1, 2, 3]);
326 assert_eq!(r.remaining(), 0);
327 }
328
329 #[test]
330 fn truncated_read_errors_instead_of_panicking() {
331 let mut r = SaveReader::new(&[0x01, 0x02]);
332 assert_eq!(
333 r.read_u32(),
334 Err(SaveStateError::Truncated {
335 expected: 4,
336 available: 2
337 })
338 );
339 }
340
341 #[test]
342 fn nested_sections_round_trip_and_stay_scoped() {
343 let mut w = SaveWriter::new();
344 w.section(*b"AAAA", |s| s.write_u32(1));
345 w.section(*b"BBBB", |s| s.write_u32(2));
346 let bytes = w.into_bytes();
347
348 let mut r = SaveReader::new(&bytes);
349 let mut a = r.expect_section(*b"AAAA").unwrap();
350 assert_eq!(a.read_u32().unwrap(), 1);
351 assert_eq!(a.remaining(), 0);
352 // Reading past a sub-section's own bound fails locally...
353 assert!(a.read_u8().is_err());
354 // ...without desynchronizing the outer reader's position for the next section.
355 let mut b = r.expect_section(*b"BBBB").unwrap();
356 assert_eq!(b.read_u32().unwrap(), 2);
357 }
358
359 #[test]
360 fn unexpected_tag_is_reported_not_silently_accepted() {
361 let mut w = SaveWriter::new();
362 w.section(*b"AAAA", |s| s.write_u8(1));
363 let bytes = w.into_bytes();
364 let mut r = SaveReader::new(&bytes);
365 assert_eq!(
366 r.expect_section(*b"ZZZZ").unwrap_err(),
367 SaveStateError::UnexpectedTag {
368 expected: *b"ZZZZ",
369 found: *b"AAAA"
370 }
371 );
372 }
373}