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).
44pub const CPU_SNAPSHOT_VERSION: u8 = 3;
45
46/// Encoded byte length of the version-1 CPU snapshot.
47///
48/// Layout: `version(1)` + 8 byte-fields for `a`/`x`/`y`/`s`/`p`/flags,
49/// 2 bytes for `pc`, 8 bytes for `cycles`, plus `jammed`,
50/// `pending_nmi`, `armed_nmi`, `pending_irq`, `armed_irq`,
51/// `nmi_first_tick`, `irq_first_tick`, `irq_sample_i_flag`,
52/// `cycles_emitted`, `skip_irq_sample` — all 1 byte each.
53const ENCODED_LEN_V1: usize = 1 + 1 + 1 + 1 + 2 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1;
54
55/// Encoded byte length of the version-2 CPU snapshot
56/// (v1 + `master_clock` u64 + 5 R1 pipeline latches).
57const ENCODED_LEN: usize = ENCODED_LEN_V1 + 8 + 5;
58
59/// Errors returned by [`Cpu::restore`].
60#[derive(Debug, Error)]
61#[non_exhaustive]
62pub enum CpuSnapshotError {
63 /// Blob length doesn't match the schema for the version tag we read.
64 #[error("CPU snapshot truncated: expected {expected} bytes, got {got}")]
65 Truncated {
66 /// Expected byte count.
67 expected: usize,
68 /// Actual byte count.
69 got: usize,
70 },
71 /// The blob's version byte is not understood by this build.
72 #[error("CPU snapshot unsupported version {0}")]
73 UnsupportedVersion(u8),
74}
75
76impl Cpu {
77 /// Encode the CPU's mutable state into a versioned binary blob.
78 ///
79 /// Format is little-endian, version-tagged at offset 0. See
80 /// [`CPU_SNAPSHOT_VERSION`] for the current schema number.
81 #[must_use]
82 pub fn snapshot(&self) -> Vec<u8> {
83 let mut out = Vec::with_capacity(ENCODED_LEN);
84 out.push(CPU_SNAPSHOT_VERSION);
85 out.push(self.a);
86 out.push(self.x);
87 out.push(self.y);
88 out.extend_from_slice(&self.pc.to_le_bytes());
89 out.push(self.s);
90 out.push(self.p.bits());
91 out.extend_from_slice(&self.cycles.to_le_bytes());
92 out.push(u8::from(self.jammed));
93 out.push(u8::from(self.pending_nmi));
94 out.push(u8::from(self.armed_nmi));
95 out.push(u8::from(self.pending_irq));
96 out.push(u8::from(self.armed_irq));
97 out.push(self.nmi_first_tick);
98 out.push(self.irq_first_tick);
99 out.push(u8::from(self.irq_sample_i_flag));
100 out.push(self.cycles_emitted);
101 out.push(u8::from(self.skip_irq_sample));
102 // v2 (W3-Stage-4): the R1 master-clock substrate pipeline. Written
103 // unconditionally (zeros when `mc-r1-substrate` is off) so the blob
104 // layout is identical across feature builds.
105 {
106 out.extend_from_slice(&self.master_clock.to_le_bytes());
107 out.push(u8::from(self.mc_need_nmi));
108 out.push(u8::from(self.mc_prev_need_nmi));
109 out.push(u8::from(self.mc_run_irq));
110 out.push(u8::from(self.mc_prev_run_irq));
111 out.push(u8::from(self.mc_prev_nmi_line));
112 }
113 out
114 }
115
116 /// Decode a previously [`Cpu::snapshot`]ed blob back into `self`.
117 ///
118 /// # Errors
119 ///
120 /// Returns [`CpuSnapshotError`] if the blob is the wrong length or
121 /// carries an unrecognized version.
122 pub fn restore(&mut self, data: &[u8]) -> Result<(), CpuSnapshotError> {
123 // Check the full expected length FIRST: a short-and-garbled blob
124 // (e.g. truncated mid-write) is a truncation error, not a version
125 // error, even if the one byte that happens to be present doesn't
126 // match CPU_SNAPSHOT_VERSION -- checking length first makes that
127 // the error callers see, which is the more useful diagnosis.
128 if data.len() != ENCODED_LEN {
129 return Err(CpuSnapshotError::Truncated {
130 expected: ENCODED_LEN,
131 got: data.len(),
132 });
133 }
134 let version = data[0];
135 // ADR 0028 (v2.0.0 rc.1): the v1/v2 upconvert path is retired. The
136 // ONLY real caller, `Nes::restore_inner`, already rejects a
137 // non-matching CPU section version via a strict equality check
138 // before this function is ever reached — so accepting v1 here was
139 // dead code. `Cpu::restore` now enforces the same strict-equality
140 // contract directly, matching ADR 0003's MAJOR-boundary policy
141 // ("no migration code paths are required... a v2.x line ... will
142 // define explicit migration" — the explicit decision here IS
143 // rejection, not a data transform).
144 if version != CPU_SNAPSHOT_VERSION {
145 return Err(CpuSnapshotError::UnsupportedVersion(version));
146 }
147 let mut p = 1;
148 self.a = data[p];
149 p += 1;
150 self.x = data[p];
151 p += 1;
152 self.y = data[p];
153 p += 1;
154 self.pc = u16::from_le_bytes([data[p], data[p + 1]]);
155 p += 2;
156 self.s = data[p];
157 p += 1;
158 self.p = Status::from_bits_truncate(data[p]);
159 p += 1;
160 let mut c = [0u8; 8];
161 c.copy_from_slice(&data[p..p + 8]);
162 self.cycles = u64::from_le_bytes(c);
163 p += 8;
164 self.jammed = data[p] != 0;
165 p += 1;
166 self.pending_nmi = data[p] != 0;
167 p += 1;
168 self.armed_nmi = data[p] != 0;
169 p += 1;
170 self.pending_irq = data[p] != 0;
171 p += 1;
172 self.armed_irq = data[p] != 0;
173 p += 1;
174 self.nmi_first_tick = data[p];
175 p += 1;
176 self.irq_first_tick = data[p];
177 p += 1;
178 self.irq_sample_i_flag = data[p] != 0;
179 p += 1;
180 self.cycles_emitted = data[p];
181 p += 1;
182 self.skip_irq_sample = data[p] != 0;
183 p += 1;
184 // The master-clock substrate pipeline (unchanged layout since v2 —
185 // see the CPU_SNAPSHOT_VERSION doc for what v3 actually changes).
186 let mut mc = [0u8; 8];
187 mc.copy_from_slice(&data[p..p + 8]);
188 self.master_clock = u64::from_le_bytes(mc);
189 self.mc_need_nmi = data[p + 8] != 0;
190 self.mc_prev_need_nmi = data[p + 9] != 0;
191 self.mc_run_irq = data[p + 10] != 0;
192 self.mc_prev_run_irq = data[p + 11] != 0;
193 self.mc_prev_nmi_line = data[p + 12] != 0;
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use alloc::vec;
202
203 #[test]
204 fn snapshot_round_trip() {
205 let mut cpu = Cpu::new();
206 cpu.a = 0xAB;
207 cpu.x = 0x12;
208 cpu.y = 0x34;
209 cpu.pc = 0xC0DE;
210 cpu.s = 0xF7;
211 cpu.p = Status::from_bits_truncate(0xA4);
212 cpu.cycles = 1_234_567;
213 cpu.jammed = true;
214 let blob = cpu.snapshot();
215 assert_eq!(blob.len(), ENCODED_LEN);
216
217 let mut other = Cpu::new();
218 other.restore(&blob).unwrap();
219 assert_eq!(other.a, 0xAB);
220 assert_eq!(other.x, 0x12);
221 assert_eq!(other.y, 0x34);
222 assert_eq!(other.pc, 0xC0DE);
223 assert_eq!(other.s, 0xF7);
224 assert_eq!(other.p.bits(), 0xA4);
225 assert_eq!(other.cycles, 1_234_567);
226 assert!(other.jammed);
227 }
228
229 #[test]
230 fn snapshot_rejects_short_blob() {
231 let mut cpu = Cpu::new();
232 let err = cpu.restore(&[CPU_SNAPSHOT_VERSION]).unwrap_err();
233 assert!(matches!(err, CpuSnapshotError::Truncated { .. }));
234 }
235
236 #[test]
237 fn snapshot_rejects_bad_version() {
238 let mut cpu = Cpu::new();
239 let err = cpu.restore(&[0xFF; ENCODED_LEN]).unwrap_err();
240 assert!(matches!(err, CpuSnapshotError::UnsupportedVersion(0xFF)));
241 }
242
243 #[test]
244 fn snapshot_rejects_pre_v3_versions() {
245 // ADR 0028: the v2.0.0 MAJOR-boundary decision is clean rejection,
246 // not an upconvert. A same-length blob tagged v1 or v2 (the two
247 // schema versions that predate the one-clock promote) must be
248 // rejected, not silently accepted as if it were v3.
249 let mut cpu = Cpu::new();
250 for old_version in [1u8, 2u8] {
251 let mut blob = vec![old_version; ENCODED_LEN];
252 blob[0] = old_version;
253 let err = cpu.restore(&blob).unwrap_err();
254 assert!(
255 matches!(err, CpuSnapshotError::UnsupportedVersion(v) if v == old_version),
256 "version {old_version} must be rejected, not upconverted"
257 );
258 }
259 }
260
261 #[test]
262 fn snapshot_is_deterministic() {
263 let mut cpu = Cpu::new();
264 cpu.a = 0x42;
265 let a = cpu.snapshot();
266 let b = cpu.snapshot();
267 assert_eq!(a, b);
268 }
269}