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}