Skip to main content

rustynes_cpu/
snapshot.rs

1//! Save-state encoding / decoding for the [`Cpu`].
2//!
3//! Per `CLAUDE.md` §Open questions: tagged-section per chip, version byte
4//! up front, best-effort cross-version compatibility. This module owns the
5//! CPU section's schema, currently version 3 (ADR 0028) — see
6//! [`CPU_SNAPSHOT_VERSION`] for the full version history and the v2.0.0
7//! MAJOR-boundary rejection policy.
8//!
9//! The encoding is hand-rolled little-endian binary so this crate stays
10//! free of `serde` / `bincode` (and so `bitflags` doesn't need its
11//! `serde` feature). The container format used by the bus to wrap this
12//! blob into a tagged section lives in `rustynes_core::save_state`.
13
14use alloc::vec::Vec;
15use thiserror::Error;
16
17use crate::cpu::Cpu;
18use crate::status::Status;
19
20/// Schema version for the CPU snapshot blob.
21///
22/// - v1 (v0.9.0 ..): registers + interrupt latches + cycle bookkeeping.
23/// - v2 (W3-Stage-4 promotion, 2026-06-10): appends the master-clock
24///   substrate pipeline — `master_clock` (u64) + the `mc_need_nmi` /
25///   `mc_prev_need_nmi` / `mc_run_irq` / `mc_prev_run_irq` /
26///   `mc_prev_nmi_line` latches (1 byte each).
27/// - **v3 (v2.0.0 "Timebase" rc.1, ADR 0028)**: the byte layout is
28///   IDENTICAL to v2 — `cycles` and `master_clock` are both still
29///   written, unchanged. What changes is the *guarantee*: as of the
30///   beta.1–beta.4 one-clock promote, `cycles` is no longer an
31///   independently-tracked counter (it is assigned from
32///   `Bus::cycle_count()` at every `start_cycle`, see `cpu.rs`), so a v3
33///   blob's `cycles`/`master_clock` pair is guaranteed internally
34///   consistent by construction in a way a pre-promote v1/v2 blob was
35///   only *coincidentally* consistent (kept in sync by parallel
36///   increments, not derivation). The version bump exists to make that
37///   distinction an explicit, checked contract rather than an implicit
38///   assumption — see ADR 0028 for the full MAJOR-boundary decision.
39///   v1/v2 blobs are no longer upconverted; [`Cpu::restore`] rejects any
40///   version other than [`CPU_SNAPSHOT_VERSION`] (the caller-side
41///   `Nes::restore_inner` already enforced this via a strict per-section
42///   equality check before this bump — the upconvert path removed here
43///   was dead code, unreachable through the only real caller).
44/// - **v4 (v2.6.7 "Detent")**: appends `skip_irq_sample_q` (1 byte), the
45///   one-cycle delay of `skip_irq_sample` that `handle_interrupts` reads.
46///   nesdev's `CPU_interrupts` states that interrupts are polled before an
47///   instruction's second cycle but *not* before the third cycle of a TAKEN
48///   BRANCH, so suppressing the poll needs the flag's value on the previous
49///   cycle as well as this one. It is genuine emulation state read back on
50///   the next tick, not derivable from the rest of the blob — a restore that
51///   dropped it would resume with the NMI dispatch gate (the
52///   `mc_prev_need_nmi` copy; the edge latch `mc_need_nmi` runs every cycle
53///   regardless) re-opened a cycle early on any snapshot landing inside a
54///   taken branch. Serialized rather
55///   than allowlisted, which `snapshot_schema_audit` says has been the right
56///   answer every time it has come up.
57pub const CPU_SNAPSHOT_VERSION: u8 = 4;
58
59/// Encoded byte length of the version-1 CPU snapshot.
60///
61/// Layout: `version(1)` + 8 byte-fields for `a`/`x`/`y`/`s`/`p`/flags,
62/// 2 bytes for `pc`, 8 bytes for `cycles`, plus `jammed`,
63/// `pending_nmi`, `armed_nmi`, `pending_irq`, `armed_irq`,
64/// `nmi_first_tick`, `irq_first_tick`, `irq_sample_i_flag`,
65/// `cycles_emitted`, `skip_irq_sample` — all 1 byte each.
66const ENCODED_LEN_V1: usize = 1 + 1 + 1 + 1 + 2 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1;
67
68/// Encoded byte length of the version-2 CPU snapshot
69/// (v1 + `master_clock` u64 + 5 R1 pipeline latches).
70const ENCODED_LEN_V2: usize = ENCODED_LEN_V1 + 8 + 5;
71
72/// Encoded byte length of the current (v4) CPU snapshot
73/// (v2 + `skip_irq_sample_q`).
74const ENCODED_LEN: usize = ENCODED_LEN_V2 + 1;
75
76/// Errors returned by [`Cpu::restore`].
77#[derive(Debug, Error)]
78#[non_exhaustive]
79pub enum CpuSnapshotError {
80    /// Blob length doesn't match the schema, short or long. (Until v2.9.9
81    /// the message said "truncated" for a long blob too; NC-14.)
82    #[error("CPU snapshot has the wrong length: expected {expected} bytes, got {got}")]
83    Truncated {
84        /// Expected byte count.
85        expected: usize,
86        /// Actual byte count.
87        got: usize,
88    },
89    /// The blob's version byte is not understood by this build.
90    #[error("CPU snapshot unsupported version {0}")]
91    UnsupportedVersion(u8),
92}
93
94impl Cpu {
95    /// Encode the CPU's mutable state into a versioned binary blob.
96    ///
97    /// Format is little-endian, version-tagged at offset 0. See
98    /// [`CPU_SNAPSHOT_VERSION`] for the current schema number.
99    #[must_use]
100    pub fn snapshot(&self) -> Vec<u8> {
101        let mut out = Vec::with_capacity(ENCODED_LEN);
102        out.push(CPU_SNAPSHOT_VERSION);
103        out.push(self.a);
104        out.push(self.x);
105        out.push(self.y);
106        out.extend_from_slice(&self.pc.to_le_bytes());
107        out.push(self.s);
108        out.push(self.p.bits());
109        out.extend_from_slice(&self.cycles.to_le_bytes());
110        out.push(u8::from(self.jammed));
111        out.push(u8::from(self.pending_nmi));
112        out.push(u8::from(self.armed_nmi));
113        out.push(u8::from(self.pending_irq));
114        out.push(u8::from(self.armed_irq));
115        out.push(self.nmi_first_tick);
116        out.push(self.irq_first_tick);
117        out.push(u8::from(self.irq_sample_i_flag));
118        out.push(self.cycles_emitted);
119        out.push(u8::from(self.skip_irq_sample));
120        // v2 (W3-Stage-4): the R1 master-clock substrate pipeline. Written
121        // unconditionally (zeros when `mc-r1-substrate` is off) so the blob
122        // layout is identical across feature builds.
123        {
124            out.extend_from_slice(&self.master_clock.to_le_bytes());
125            out.push(u8::from(self.mc_need_nmi));
126            out.push(u8::from(self.mc_prev_need_nmi));
127            out.push(u8::from(self.mc_run_irq));
128            out.push(u8::from(self.mc_prev_run_irq));
129            out.push(u8::from(self.mc_prev_nmi_line));
130        }
131        // v4 (v2.6.7): the taken-branch interrupt-poll suppressor's previous
132        // value. Appended rather than inserted so the preceding layout, which
133        // three schema versions have now agreed on, is untouched.
134        out.push(u8::from(self.skip_irq_sample_q));
135        out
136    }
137
138    /// Decode a previously [`Cpu::snapshot`]ed blob back into `self`.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`CpuSnapshotError`] if the blob is the wrong length or
143    /// carries an unrecognized version.
144    pub fn restore(&mut self, data: &[u8]) -> Result<(), CpuSnapshotError> {
145        // Check the full expected length FIRST: a short-and-garbled blob
146        // (e.g. truncated mid-write) is a truncation error, not a version
147        // error, even if the one byte that happens to be present doesn't
148        // match CPU_SNAPSHOT_VERSION -- checking length first makes that
149        // the error callers see, which is the more useful diagnosis.
150        if data.len() != ENCODED_LEN {
151            return Err(CpuSnapshotError::Truncated {
152                expected: ENCODED_LEN,
153                got: data.len(),
154            });
155        }
156        let version = data[0];
157        // ADR 0028 (v2.0.0 rc.1): the v1/v2 upconvert path is retired. The
158        // ONLY real caller, `Nes::restore_inner`, already rejects a
159        // non-matching CPU section version via a strict equality check
160        // before this function is ever reached — so accepting v1 here was
161        // dead code. `Cpu::restore` now enforces the same strict-equality
162        // contract directly, matching ADR 0003's MAJOR-boundary policy
163        // ("no migration code paths are required... a v2.x line ... will
164        // define explicit migration" — the explicit decision here IS
165        // rejection, not a data transform).
166        if version != CPU_SNAPSHOT_VERSION {
167            return Err(CpuSnapshotError::UnsupportedVersion(version));
168        }
169        let mut p = 1;
170        self.a = data[p];
171        p += 1;
172        self.x = data[p];
173        p += 1;
174        self.y = data[p];
175        p += 1;
176        self.pc = u16::from_le_bytes([data[p], data[p + 1]]);
177        p += 2;
178        self.s = data[p];
179        p += 1;
180        self.p = Status::from_bits_truncate(data[p]);
181        p += 1;
182        let mut c = [0u8; 8];
183        c.copy_from_slice(&data[p..p + 8]);
184        self.cycles = u64::from_le_bytes(c);
185        p += 8;
186        self.jammed = data[p] != 0;
187        p += 1;
188        self.pending_nmi = data[p] != 0;
189        p += 1;
190        self.armed_nmi = data[p] != 0;
191        p += 1;
192        self.pending_irq = data[p] != 0;
193        p += 1;
194        self.armed_irq = data[p] != 0;
195        p += 1;
196        self.nmi_first_tick = data[p];
197        p += 1;
198        self.irq_first_tick = data[p];
199        p += 1;
200        self.irq_sample_i_flag = data[p] != 0;
201        p += 1;
202        self.cycles_emitted = data[p];
203        p += 1;
204        self.skip_irq_sample = data[p] != 0;
205        p += 1;
206        // The master-clock substrate pipeline (unchanged layout since v2 —
207        // see the CPU_SNAPSHOT_VERSION doc for what v3 actually changes).
208        let mut mc = [0u8; 8];
209        mc.copy_from_slice(&data[p..p + 8]);
210        self.master_clock = u64::from_le_bytes(mc);
211        self.mc_need_nmi = data[p + 8] != 0;
212        self.mc_prev_need_nmi = data[p + 9] != 0;
213        self.mc_run_irq = data[p + 10] != 0;
214        self.mc_prev_run_irq = data[p + 11] != 0;
215        self.mc_prev_nmi_line = data[p + 12] != 0;
216        self.skip_irq_sample_q = data[p + 13] != 0;
217        Ok(())
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use alloc::vec;
225
226    #[test]
227    fn snapshot_round_trip() {
228        let mut cpu = Cpu::new();
229        cpu.a = 0xAB;
230        cpu.x = 0x12;
231        cpu.y = 0x34;
232        cpu.pc = 0xC0DE;
233        cpu.s = 0xF7;
234        cpu.p = Status::from_bits_truncate(0xA4);
235        cpu.cycles = 1_234_567;
236        cpu.jammed = true;
237        let blob = cpu.snapshot();
238        assert_eq!(blob.len(), ENCODED_LEN);
239
240        let mut other = Cpu::new();
241        other.restore(&blob).unwrap();
242        assert_eq!(other.a, 0xAB);
243        assert_eq!(other.x, 0x12);
244        assert_eq!(other.y, 0x34);
245        assert_eq!(other.pc, 0xC0DE);
246        assert_eq!(other.s, 0xF7);
247        assert_eq!(other.p.bits(), 0xA4);
248        assert_eq!(other.cycles, 1_234_567);
249        assert!(other.jammed);
250    }
251
252    #[test]
253    fn snapshot_rejects_short_blob() {
254        let mut cpu = Cpu::new();
255        let err = cpu.restore(&[CPU_SNAPSHOT_VERSION]).unwrap_err();
256        assert!(matches!(err, CpuSnapshotError::Truncated { .. }));
257    }
258
259    #[test]
260    fn snapshot_rejects_bad_version() {
261        let mut cpu = Cpu::new();
262        let err = cpu.restore(&[0xFF; ENCODED_LEN]).unwrap_err();
263        assert!(matches!(err, CpuSnapshotError::UnsupportedVersion(0xFF)));
264    }
265
266    #[test]
267    fn snapshot_rejects_pre_v3_versions() {
268        // ADR 0028: the v2.0.0 MAJOR-boundary decision is clean rejection,
269        // not an upconvert. A same-length blob tagged v1 or v2 (the two
270        // schema versions that predate the one-clock promote) must be
271        // rejected, not silently accepted as if it were v3.
272        let mut cpu = Cpu::new();
273        for old_version in [1u8, 2u8] {
274            let mut blob = vec![old_version; ENCODED_LEN];
275            blob[0] = old_version;
276            let err = cpu.restore(&blob).unwrap_err();
277            assert!(
278                matches!(err, CpuSnapshotError::UnsupportedVersion(v) if v == old_version),
279                "version {old_version} must be rejected, not upconverted"
280            );
281        }
282    }
283
284    #[test]
285    fn snapshot_is_deterministic() {
286        let mut cpu = Cpu::new();
287        cpu.a = 0x42;
288        let a = cpu.snapshot();
289        let b = cpu.snapshot();
290        assert_eq!(a, b);
291    }
292}