Skip to main content

rustynes_core/
save_state.rs

1//! Save-state container format for `RustyNES` v2.
2//!
3//! Per `CLAUDE.md` "Open questions worth knowing": tagged-section per chip
4//! with version byte; cross-version compatibility is best-effort, not
5//! guaranteed.
6//!
7//! # On-wire layout
8//!
9//! ```text
10//! HEADER (16 bytes):
11//!     magic       : "RUSTYNES"  (8 bytes)
12//!     format ver  : u16 little-endian  (currently 2 -- see [`FORMAT_VERSION`])
13//!     rom sha-256 : truncated to 6 bytes (sanity tag, not authoritative)
14//!
15//! BODY (sections in any order, each):
16//!     tag         : [u8; 4]  e.g. b"CPU ", b"PPU ", b"APU ", b"MAP "
17//!     version     : u8       per-section schema version
18//!     length      : u32 little-endian (body bytes after this length field)
19//!     body        : `length` bytes
20//! ```
21//!
22//! Determinism: every `snapshot()` for a given `(seed, ROM, input sequence)`
23//! produces bit-identical bytes. Loading is order-independent.
24
25use alloc::{string::String, vec::Vec};
26use thiserror::Error;
27
28/// Magic header bytes — first 8 bytes of every `.rns` file.
29pub const MAGIC: &[u8; 8] = b"RUSTYNES";
30
31/// Current container-format version.
32///
33/// - v1 (v0.9.0 ..): the tagged-section container as documented above.
34/// - **v2 (v2.0.0 "Timebase" rc.1, ADR 0028)**: marks the MAJOR-boundary
35///   line. The container's own on-wire layout is unchanged (this field
36///   only guards forward-compat -- a reader rejects any blob whose
37///   `format_version` exceeds its own [`FORMAT_VERSION`]); the real
38///   v2.0.0-line rejection happens per-section, via the strict version
39///   equality checks each chip's snapshot module already enforces (see
40///   `rustynes_cpu::CPU_SNAPSHOT_VERSION`'s v3 bump for the concrete
41///   example). This bump exists so a `.rns` file's header alone signals
42///   which release line produced it, without needing to inspect every
43///   section.
44pub const FORMAT_VERSION: u16 = 2;
45
46/// Length of the truncated ROM SHA-256 we embed in the header (sanity tag).
47pub const ROM_HASH_TAG_LEN: usize = 6;
48
49/// Header byte length.
50pub const HEADER_LEN: usize = 8 + 2 + ROM_HASH_TAG_LEN;
51
52/// Section tags. The fixed-width 4-byte format keeps parsing trivial and
53/// avoids string allocation on the hot path.
54pub mod tag {
55    /// CPU (2A03 / 6502) state.
56    pub const CPU: [u8; 4] = *b"CPU ";
57    /// PPU (2C02) state.
58    pub const PPU: [u8; 4] = *b"PPU ";
59    /// APU (2A03 audio) state.
60    pub const APU: [u8; 4] = *b"APU ";
61    /// Mapper state (delegates to `Mapper::save_state`).
62    pub const MAP: [u8; 4] = *b"MAP ";
63    /// Bus / scheduler state (RAM, DMA, NMI edge latches, cycle counter).
64    pub const BUS: [u8; 4] = *b"BUS ";
65    /// Optional UI thumbnail (128x120 RGBA8 nearest-neighbor of the current
66    /// framebuffer). NOT part of the deterministic save-state contract --
67    /// frontends use it for slot pickers. See ADR 0003.
68    pub const THM: [u8; 4] = *b"THM ";
69}
70
71/// Thumbnail width in pixels (1/2 native NES width).
72pub const THUMBNAIL_WIDTH: usize = 128;
73/// Thumbnail height in pixels (1/2 native NES height).
74pub const THUMBNAIL_HEIGHT: usize = 120;
75/// Thumbnail byte length (`THUMBNAIL_WIDTH * THUMBNAIL_HEIGHT * 4`, RGBA8).
76pub const THUMBNAIL_LEN: usize = THUMBNAIL_WIDTH * THUMBNAIL_HEIGHT * 4;
77/// Body version byte for the `THM ` section.
78pub const THUMBNAIL_VERSION: u8 = 1;
79
80/// Errors produced by save-state encode / decode.
81#[derive(Debug, Error)]
82#[non_exhaustive]
83pub enum SnapshotError {
84    /// The blob is shorter than the header.
85    #[error("save state truncated: header needs {expected} bytes, got {got}")]
86    HeaderTruncated {
87        /// Expected byte count.
88        expected: usize,
89        /// Actual byte count.
90        got: usize,
91    },
92
93    /// The magic prefix is wrong.
94    #[error("save state magic mismatch: expected {:?}, got {got:?}", MAGIC)]
95    BadMagic {
96        /// Bytes observed at the magic offset.
97        got: [u8; 8],
98    },
99
100    /// The container format version is outside the range we understand.
101    #[error("save state container format version {got} not supported (max {max})")]
102    UnsupportedFormat {
103        /// Version we read.
104        got: u16,
105        /// Highest version we accept.
106        max: u16,
107    },
108
109    /// A section body is shorter than its declared length.
110    #[error("save state section {tag} body truncated: declared {declared} bytes, got {got}")]
111    SectionTruncated {
112        /// 4-byte tag (printable ASCII).
113        tag: String,
114        /// Declared length.
115        declared: usize,
116        /// Bytes actually available.
117        got: usize,
118    },
119
120    /// A section had a version this build does not handle.
121    #[error(
122        "save state section {tag} version {file_version} not supported (chip supports {chip_supports})"
123    )]
124    VersionMismatch {
125        /// 4-byte tag (printable ASCII).
126        tag: String,
127        /// Version recorded in the file.
128        file_version: u8,
129        /// Highest version the running chip accepts.
130        chip_supports: u8,
131    },
132
133    /// A section's body failed internal consistency checks.
134    #[error("save state section {tag}: {reason}")]
135    SectionInvalid {
136        /// 4-byte tag (printable ASCII).
137        tag: String,
138        /// Free-form reason.
139        reason: String,
140    },
141
142    /// A required section was missing.
143    #[error("save state missing required section {0}")]
144    MissingSection(String),
145
146    /// A section blob ran past EOF.
147    #[error("save state truncated mid-section at offset {0}")]
148    Eof(usize),
149}
150
151impl SnapshotError {
152    /// Construct a [`Self::SectionInvalid`] with a borrowed tag.
153    pub fn invalid(tag: [u8; 4], reason: impl Into<String>) -> Self {
154        Self::SectionInvalid {
155            tag: tag_string(tag),
156            reason: reason.into(),
157        }
158    }
159}
160
161/// Render a 4-byte tag back into a `String` (lossy — non-ASCII becomes `?`).
162#[must_use]
163pub fn tag_string(t: [u8; 4]) -> String {
164    let mut s = String::with_capacity(4);
165    for b in t {
166        s.push(if (0x20..=0x7E).contains(&b) {
167            char::from(b)
168        } else {
169            '?'
170        });
171    }
172    s
173}
174
175/// Cursor-style binary writer used by chip snapshot encoders.
176///
177/// Little-endian for all multi-byte integers; bools are 1 byte (`0` / `1`);
178/// optional values are tagged with a presence byte.
179#[derive(Debug, Default)]
180pub struct BinWriter {
181    buf: Vec<u8>,
182}
183
184impl BinWriter {
185    /// Empty writer.
186    #[must_use]
187    pub const fn new() -> Self {
188        Self { buf: Vec::new() }
189    }
190
191    /// Pre-sized writer.
192    #[must_use]
193    pub fn with_capacity(cap: usize) -> Self {
194        Self {
195            buf: Vec::with_capacity(cap),
196        }
197    }
198
199    /// Take the inner buffer.
200    #[must_use]
201    pub fn into_vec(self) -> Vec<u8> {
202        self.buf
203    }
204
205    /// Currently-accumulated byte count.
206    #[must_use]
207    pub const fn len(&self) -> usize {
208        self.buf.len()
209    }
210
211    /// `true` if no bytes have been written.
212    #[must_use]
213    pub const fn is_empty(&self) -> bool {
214        self.buf.is_empty()
215    }
216
217    /// Append one byte.
218    pub fn u8(&mut self, v: u8) {
219        self.buf.push(v);
220    }
221
222    /// Append a u16 little-endian.
223    pub fn u16(&mut self, v: u16) {
224        self.buf.extend_from_slice(&v.to_le_bytes());
225    }
226
227    /// Append a u32 little-endian.
228    pub fn u32(&mut self, v: u32) {
229        self.buf.extend_from_slice(&v.to_le_bytes());
230    }
231
232    /// Append a u64 little-endian.
233    pub fn u64(&mut self, v: u64) {
234        self.buf.extend_from_slice(&v.to_le_bytes());
235    }
236
237    /// Append an i16 little-endian.
238    pub fn i16(&mut self, v: i16) {
239        self.buf.extend_from_slice(&v.to_le_bytes());
240    }
241
242    /// Append a bool as 1 byte.
243    pub fn bool(&mut self, v: bool) {
244        self.buf.push(u8::from(v));
245    }
246
247    /// Append a raw byte slice.
248    pub fn bytes(&mut self, v: &[u8]) {
249        self.buf.extend_from_slice(v);
250    }
251
252    /// Append a length-prefixed byte slice (u32 le length).
253    pub fn lp_bytes(&mut self, v: &[u8]) {
254        self.u32(u32::try_from(v.len()).expect("slice too large for save state"));
255        self.bytes(v);
256    }
257}
258
259/// Cursor-style binary reader, the inverse of [`BinWriter`].
260#[derive(Debug)]
261pub struct BinReader<'a> {
262    src: &'a [u8],
263    pos: usize,
264}
265
266impl<'a> BinReader<'a> {
267    /// New reader.
268    #[must_use]
269    pub const fn new(src: &'a [u8]) -> Self {
270        Self { src, pos: 0 }
271    }
272
273    /// Bytes remaining.
274    #[must_use]
275    pub const fn remaining(&self) -> usize {
276        self.src.len() - self.pos
277    }
278
279    /// `true` if the cursor is at end-of-input.
280    #[must_use]
281    pub const fn is_empty(&self) -> bool {
282        self.remaining() == 0
283    }
284
285    /// Current byte offset.
286    #[must_use]
287    pub const fn pos(&self) -> usize {
288        self.pos
289    }
290
291    const fn need(&self, n: usize) -> Result<(), SnapshotError> {
292        if self.remaining() < n {
293            return Err(SnapshotError::Eof(self.pos));
294        }
295        Ok(())
296    }
297
298    /// Read one byte.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`SnapshotError::Eof`] on EOF.
303    pub fn u8(&mut self) -> Result<u8, SnapshotError> {
304        self.need(1)?;
305        let v = self.src[self.pos];
306        self.pos += 1;
307        Ok(v)
308    }
309
310    /// Read a u16 little-endian.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`SnapshotError::Eof`] on EOF.
315    pub fn u16(&mut self) -> Result<u16, SnapshotError> {
316        self.need(2)?;
317        let v = u16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
318        self.pos += 2;
319        Ok(v)
320    }
321
322    /// Read a u32 little-endian.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`SnapshotError::Eof`] on EOF.
327    pub fn u32(&mut self) -> Result<u32, SnapshotError> {
328        self.need(4)?;
329        let mut a = [0u8; 4];
330        a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
331        self.pos += 4;
332        Ok(u32::from_le_bytes(a))
333    }
334
335    /// Read a u64 little-endian.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`SnapshotError::Eof`] on EOF.
340    pub fn u64(&mut self) -> Result<u64, SnapshotError> {
341        self.need(8)?;
342        let mut a = [0u8; 8];
343        a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
344        self.pos += 8;
345        Ok(u64::from_le_bytes(a))
346    }
347
348    /// Read an i16 little-endian.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`SnapshotError::Eof`] on EOF.
353    pub fn i16(&mut self) -> Result<i16, SnapshotError> {
354        self.need(2)?;
355        let v = i16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
356        self.pos += 2;
357        Ok(v)
358    }
359
360    /// Read a bool (any non-zero byte counts as true).
361    ///
362    /// # Errors
363    ///
364    /// Returns [`SnapshotError::Eof`] on EOF.
365    pub fn bool(&mut self) -> Result<bool, SnapshotError> {
366        Ok(self.u8()? != 0)
367    }
368
369    /// Read `n` bytes (returns a borrowed slice).
370    ///
371    /// # Errors
372    ///
373    /// Returns [`SnapshotError::Eof`] on EOF.
374    pub fn take(&mut self, n: usize) -> Result<&'a [u8], SnapshotError> {
375        self.need(n)?;
376        let s = &self.src[self.pos..self.pos + n];
377        self.pos += n;
378        Ok(s)
379    }
380
381    /// Read into a fixed-length destination.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`SnapshotError::Eof`] on EOF.
386    pub fn read_into(&mut self, dst: &mut [u8]) -> Result<(), SnapshotError> {
387        let s = self.take(dst.len())?;
388        dst.copy_from_slice(s);
389        Ok(())
390    }
391
392    /// Read a length-prefixed byte slice (u32 le length followed by the bytes).
393    ///
394    /// # Errors
395    ///
396    /// Returns [`SnapshotError::Eof`] on EOF.
397    pub fn lp_bytes(&mut self) -> Result<&'a [u8], SnapshotError> {
398        let n = self.u32()? as usize;
399        self.take(n)
400    }
401}
402
403/// Encode a section header (`tag` + `version` + `length`) into `out` and
404/// then append the body bytes.
405pub fn write_section(out: &mut Vec<u8>, tag: [u8; 4], version: u8, body: &[u8]) {
406    out.extend_from_slice(&tag);
407    out.push(version);
408    let len = u32::try_from(body.len()).expect("section body too large");
409    out.extend_from_slice(&len.to_le_bytes());
410    out.extend_from_slice(body);
411}
412
413/// Decoded view of one section's metadata + body slice.
414#[derive(Debug, Clone, Copy)]
415pub struct Section<'a> {
416    /// Tag bytes (e.g. `b"CPU "`).
417    pub tag: [u8; 4],
418    /// Per-section schema version.
419    pub version: u8,
420    /// Body slice (does NOT include the header itself).
421    pub body: &'a [u8],
422}
423
424/// Header decoded from the start of the blob.
425#[derive(Debug, Clone)]
426pub struct Header {
427    /// Container format version (matches [`FORMAT_VERSION`] when written by
428    /// this build).
429    pub format_version: u16,
430    /// Truncated ROM SHA-256 sanity tag.
431    pub rom_hash_tag: [u8; ROM_HASH_TAG_LEN],
432}
433
434/// Parse the 16-byte header.
435///
436/// # Errors
437///
438/// - [`SnapshotError::HeaderTruncated`] if `bytes` is shorter than 16 bytes.
439/// - [`SnapshotError::BadMagic`] on a bad prefix.
440/// - [`SnapshotError::UnsupportedFormat`] if the format version is past
441///   [`FORMAT_VERSION`].
442pub fn parse_header(bytes: &[u8]) -> Result<(Header, usize), SnapshotError> {
443    if bytes.len() < HEADER_LEN {
444        return Err(SnapshotError::HeaderTruncated {
445            expected: HEADER_LEN,
446            got: bytes.len(),
447        });
448    }
449    let mut magic = [0u8; 8];
450    magic.copy_from_slice(&bytes[..8]);
451    if &magic != MAGIC {
452        return Err(SnapshotError::BadMagic { got: magic });
453    }
454    let format_version = u16::from_le_bytes([bytes[8], bytes[9]]);
455    if format_version > FORMAT_VERSION {
456        return Err(SnapshotError::UnsupportedFormat {
457            got: format_version,
458            max: FORMAT_VERSION,
459        });
460    }
461    let mut rom_hash_tag = [0u8; ROM_HASH_TAG_LEN];
462    rom_hash_tag.copy_from_slice(&bytes[10..16]);
463    Ok((
464        Header {
465            format_version,
466            rom_hash_tag,
467        },
468        HEADER_LEN,
469    ))
470}
471
472/// Iterate sections starting at `bytes` (which should begin immediately
473/// after the header).
474pub struct SectionIter<'a> {
475    src: &'a [u8],
476    pos: usize,
477}
478
479impl<'a> SectionIter<'a> {
480    /// New section iterator at the start of the body.
481    #[must_use]
482    pub const fn new(body: &'a [u8]) -> Self {
483        Self { src: body, pos: 0 }
484    }
485}
486
487impl<'a> Iterator for SectionIter<'a> {
488    type Item = Result<Section<'a>, SnapshotError>;
489
490    fn next(&mut self) -> Option<Self::Item> {
491        if self.pos >= self.src.len() {
492            return None;
493        }
494        // tag(4) + version(1) + len(4) = 9-byte section header.
495        if self.src.len() - self.pos < 9 {
496            return Some(Err(SnapshotError::Eof(self.pos)));
497        }
498        let mut tag = [0u8; 4];
499        tag.copy_from_slice(&self.src[self.pos..self.pos + 4]);
500        let version = self.src[self.pos + 4];
501        let mut len_bytes = [0u8; 4];
502        len_bytes.copy_from_slice(&self.src[self.pos + 5..self.pos + 9]);
503        let len = u32::from_le_bytes(len_bytes) as usize;
504        let body_start = self.pos + 9;
505        let body_end = body_start + len;
506        if body_end > self.src.len() {
507            return Some(Err(SnapshotError::SectionTruncated {
508                tag: tag_string(tag),
509                declared: len,
510                got: self.src.len() - body_start,
511            }));
512        }
513        let body = &self.src[body_start..body_end];
514        self.pos = body_end;
515        Some(Ok(Section { tag, version, body }))
516    }
517}
518
519/// Build the 16-byte header into `out`.
520pub fn write_header(out: &mut Vec<u8>, rom_hash_tag: [u8; ROM_HASH_TAG_LEN]) {
521    out.extend_from_slice(MAGIC);
522    out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
523    out.extend_from_slice(&rom_hash_tag);
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn header_round_trip() {
532        let mut out = Vec::new();
533        write_header(&mut out, [1, 2, 3, 4, 5, 6]);
534        assert_eq!(out.len(), HEADER_LEN);
535        let (h, off) = parse_header(&out).unwrap();
536        assert_eq!(h.format_version, FORMAT_VERSION);
537        assert_eq!(h.rom_hash_tag, [1, 2, 3, 4, 5, 6]);
538        assert_eq!(off, HEADER_LEN);
539    }
540
541    #[test]
542    fn header_rejects_bad_magic() {
543        let mut out = Vec::new();
544        out.extend_from_slice(b"NOTRUSTY");
545        out.extend_from_slice(&[0u8; HEADER_LEN - 8]);
546        assert!(matches!(
547            parse_header(&out),
548            Err(SnapshotError::BadMagic { .. })
549        ));
550    }
551
552    #[test]
553    fn header_rejects_too_new_format() {
554        let mut out = Vec::new();
555        out.extend_from_slice(MAGIC);
556        out.extend_from_slice(&u16::MAX.to_le_bytes());
557        out.extend_from_slice(&[0u8; ROM_HASH_TAG_LEN]);
558        assert!(matches!(
559            parse_header(&out),
560            Err(SnapshotError::UnsupportedFormat { .. })
561        ));
562    }
563
564    #[test]
565    fn section_iter_round_trip() {
566        let mut out = Vec::new();
567        write_section(&mut out, *b"AAAA", 1, &[1, 2, 3]);
568        write_section(&mut out, *b"BBBB", 7, &[4, 5, 6, 7, 8]);
569        let mut it = SectionIter::new(&out);
570        let s1 = it.next().unwrap().unwrap();
571        assert_eq!(&s1.tag, b"AAAA");
572        assert_eq!(s1.version, 1);
573        assert_eq!(s1.body, &[1, 2, 3]);
574        let s2 = it.next().unwrap().unwrap();
575        assert_eq!(&s2.tag, b"BBBB");
576        assert_eq!(s2.version, 7);
577        assert_eq!(s2.body, &[4, 5, 6, 7, 8]);
578        assert!(it.next().is_none());
579    }
580
581    #[test]
582    fn binwriter_round_trip() {
583        let mut w = BinWriter::new();
584        w.u8(0x12);
585        w.u16(0x3456);
586        w.u32(0x789A_BCDE);
587        w.u64(0xFEED_FACE_CAFE_BEEF);
588        w.bool(true);
589        w.bool(false);
590        w.bytes(&[0xAA, 0xBB]);
591        let buf = w.into_vec();
592        let mut r = BinReader::new(&buf);
593        assert_eq!(r.u8().unwrap(), 0x12);
594        assert_eq!(r.u16().unwrap(), 0x3456);
595        assert_eq!(r.u32().unwrap(), 0x789A_BCDE);
596        assert_eq!(r.u64().unwrap(), 0xFEED_FACE_CAFE_BEEF);
597        assert!(r.bool().unwrap());
598        assert!(!r.bool().unwrap());
599        assert_eq!(r.take(2).unwrap(), &[0xAA, 0xBB]);
600        assert!(r.is_empty());
601    }
602
603    #[test]
604    fn binreader_eof_errors() {
605        let buf = [0x12u8];
606        let mut r = BinReader::new(&buf);
607        assert!(r.u8().is_ok());
608        assert!(matches!(r.u8(), Err(SnapshotError::Eof(_))));
609    }
610}