rustynes_ppu/snapshot.rs
1//! Save-state encoding / decoding for the [`Ppu`].
2//!
3//! Hand-rolled little-endian binary so the crate stays free of `serde` and
4//! `bincode`. The container format used by the bus to wrap this blob into
5//! a tagged section lives in `rustynes_core::save_state`.
6//!
7//! Schema version 1 layout (all little-endian, top-down):
8//!
9//! - `version` u8
10//! - region tag u8 (0=NTSC, 1=PAL, 2=Dendy)
11//! - `ctrl` u8 / `mask` u8 / `mask_for_skip_check` u8 / `mask_skip_pipe1` u8 / `status` u8
12//! - `oam_addr` u8 / `data_buffer` u8
13//! - loopy: `v` u16 / `t` u16 / `x` u8 / `w` bool
14//! - 2 KiB CIRAM (raw bytes)
15//! - 256 B OAM (raw bytes)
16//! - 32 B secondary OAM (raw bytes)
17//! - 32 B palette RAM (raw bytes)
18//! - `open_bus` u8 / 3× `open_bus_decay[i]` u32
19//! - `nmi_line` / `suppress_vbl_this_frame` / `last_a12_level` u8
20//! - `dot` u16 / `scanline` i16 / `frame` u64 / `frame_complete` bool
21//! - `post_reset_mask_remaining` u32
22//! - BG latches: `nt_latch` u8 / `at_latch` u8 / `bg_lo_latch` u8 / `bg_hi_latch` u8
23//! - BG shifts (v2): `bg_shift_lo` u16 / `bg_shift_hi` u16 / `at_shift_lo` u16 /
24//! `at_shift_hi` u16. (v1 stored `at_shift_*` as u8 + two 1-bit feed bytes.)
25//!
26//! The versions' tails follow (see [`PPU_SNAPSHOT_VERSION`]). Since v2.9.8
27//! (ADR 0042) only the current version is read; the per-version upconversion
28//! this module used to carry is gone.
29//! - `ex_attr_latch` (presence u8 + `palette` u8 + `chr_bank` u16)
30//! - `bg_split_latch` (presence u8 + `nt_addr` u16 + `at_addr` u16 + `fine_y` u8 + `chr_bank` u8)
31//! - sprite arrays: 8× `shift_lo` / `shift_hi` / `attr` / `x` / `spr_count` u8 / `spr_zero_in_line` bool
32//! - `256*240*4` framebuffer bytes
33
34use alloc::vec::Vec;
35use thiserror::Error;
36
37use crate::bus::{BgSplitState, ExAttribute};
38use crate::ppu::{FRAMEBUFFER_LEN, Ppu, PpuRegion};
39use crate::registers::{PpuCtrl, PpuMask, PpuStatus};
40
41/// Schema version for the PPU snapshot blob.
42///
43/// - v1: 8-bit `at_shift_lo`/`at_shift_hi` + 1-bit `at_feed_lo`/`at_feed_hi`.
44/// - v2: 16-bit `at_shift_lo`/`at_shift_hi` (lockstep with the pattern
45/// shifters); the feed fields are gone.
46///
47/// **Since v2.9.8 (ADR 0042) `Ppu::restore` reads the current version only.**
48/// The notes below on how each older version "upconverts" describe the
49/// behaviour before then.
50/// - v3 (W3-Stage-4 promotion, 2026-06-10): appends the
51/// `mc-ppu-2007-render-buffer` rendering-time `$2007` PPUDATA
52/// state-machine tail — `render_data_bus`, `ppudata_sm_countdown`,
53/// `ppudata_v_inc_pending`, the raw (pre-h-flip) sprite pattern fetch
54/// bytes, and the slot-0 garbage-NT ALE latch. Written unconditionally
55/// (zeros when the feature is off) so the layout is identical across
56/// feature builds; v1/v2 blobs upconvert with the tail at the inactive
57/// defaults (the state the old clear-on-restore assumption imposed).
58/// - v4 (v1.7.0 F3, 2026-06-18): appends the `extra_lines_remaining`
59/// countdown for the in-flight PPU extra-scanlines overclock insertion.
60/// At the default `extra_scanlines == 0` this is always `0`, so the
61/// blob merely gains a zero `u16` and restore is behaviourally identical
62/// to v3; a non-default countdown taken mid-insertion now round-trips
63/// instead of restoring as `0` (which desynced). v1/v2/v3 blobs upconvert
64/// with `extra_lines_remaining = 0` (no insertion in flight).
65/// - v5 (v2.0.3, ADR 0030): appends the 2-cycle-ALE fetch model's in-flight
66/// multiplexed-bus / octal-latch state — `octal_latch`, `address_bus`,
67/// `ale_armed`, `pattern_latch_stale`, and the delayed-`CopyV` `copy_v_delay`
68/// countdown. These fields carry a background fetch's ALE→read state and the
69/// two modeled `$2006`/`$2007` corruption one-shots; a mid-render save/restore
70/// (netplay rollback checkpoints, TAS/save-states) that landed with any of
71/// them live restored them to the wrong value and desynced the re-simulated
72/// framebuffer — exactly the class of bug the v3/v4 tails already fixed for
73/// the `$2007` state machine and the overclock countdown. This is an ADDITIVE
74/// save-state format change (a `.rns` gains the tail): v1..=4 blobs upconvert
75/// with all five at their inactive defaults (`0`/`false`), i.e. the
76/// "no fetch in flight" rest state, which is correct for any pre-v5 save taken
77/// at rest. Not a *load-break* — a pre-v5 `.rns` still restores.
78/// - v6 (v2.1.1, "Fathom"): appends the per-sprite shifter-halt flags
79/// (`spr_halted[8]`), the 1-dot-delayed rendering gate
80/// (`prev_rendering_enabled` / `rendering_enabled_delayed`), and the
81/// OAM-corruption arming state (`oam_corruption_pending`,
82/// `oam_corruption_index`, `oam_corruption_disabled`,
83/// `oam_corruption_disabled_instant`). These fields were previously not
84/// serialized; a frontend run-ahead `snapshot`/`restore` round-trip
85/// landing with any of them live (mid-frame sprite-0 split, OAM
86/// corruption arming) would restore stale constructor values and corrupt
87/// rendering — the Wizards & Warriors half-blank playfield / stalled
88/// audio class. v1..=5 blobs upconvert with `spr_halted = [true; 8]`
89/// (halted = power-on default) and all others at `false`/`0`. Not a
90/// *load-break* — a pre-v6 `.rns` still restores.
91/// - v7 (v2.1.4 F2.3): appends the optional OAM-decay model's per-8-byte-row
92/// last-touch timestamps (`oam_decay_cycles[32]`), stored as a **relative age**
93/// (`now - timestamp` in CPU cycles) rather than the raw absolute value. The
94/// absolute timestamps reference the free-running `dot_counter`, which is
95/// deliberately NOT part of the save-state (it is cosmetic / re-derived); a raw
96/// absolute value would be meaningless after a rollback/restore rebased that
97/// counter, and would desync the decay clock. Encoding the age and reconstructing
98/// `now - age` against the *live* counter on load makes a run-ahead / netplay
99/// `snapshot`→`restore` byte-identical to the forward run (the property the
100/// v3/v4/v5/v6 tails secured for the other mid-frame state). The enable flag
101/// itself is a frontend/config knob (re-applied on load like `region` /
102/// `active_palette`), so — matching the `extra_scanlines` precedent — it is NOT
103/// serialized; only the in-flight ages are. At the default (decay off) the ages
104/// are inert dead state, so the blob merely grows by 256 bytes and restore is
105/// behaviourally identical. v1..=6 blobs upconvert by stamping every row as
106/// freshly-touched at the live cycle (age 0), i.e. the rest state — correct for
107/// any pre-v7 save. Not a *load-break* — a pre-v7 `.rns` still restores.
108/// - v8 (run-ahead sprite-evaluation fix): appends the per-dot **sprite-evaluation
109/// FSM** (`sprite_eval_read_latch`, `_n`, `_m`, `_found`, `_sec_idx`,
110/// `_copying`, `_done`, `_overflow_search`, `_zero_found`, `_first_iter`), the
111/// parallel **OAM-data-bus model** (`oam_bus_copybuffer`, `oam_bus_secondary[32]`,
112/// `oam_bus_addr_h`, `oam_bus_addr_l`, `oam_bus_secondary_addr`,
113/// `oam_bus_copy_done`, `oam_bus_sprite_in_range`, `oam_bus_overflow_counter`),
114/// and the secondary-OAM clear-window write pointer `oam2_addr`. 50 bytes.
115///
116/// These are the mid-scanline working registers of dots 65..=256: the eval pass
117/// walks primary OAM through `_n`/`_m`, stages each byte through
118/// `sprite_eval_read_latch`, and commits in-range sprites into `secondary_oam`
119/// at `_sec_idx`. `secondary_oam` itself was already serialized — but the
120/// *pointers and phase* driving it were not, so a snapshot taken with an eval
121/// pass in flight restored a full secondary-OAM buffer alongside a
122/// power-on-default FSM. The frontend's run-ahead (`snapshot_core_into` →
123/// N frames → `restore_quiet`, every visible frame, and ON BY DEFAULT at
124/// `run_ahead = 1`) hit this every frame: the hidden frames advanced the FSM
125/// and the rollback left that advance behind. Measured effect on the
126/// `AccuracyCoin` battery: 141/141 headless but 138/141 through the desktop
127/// frontend, failing exactly `Sprite Evaluation :: Arbitrary Sprite zero`
128/// (error 2), `Sprite Evaluation :: Misaligned OAM behavior` (error 1), and
129/// `PPU Behavior :: Rendering Flag Behavior` (error 2). Same bug class as the
130/// v6 tail (which closed the Wizards & Warriors half-blank playfield), a
131/// different uncovered field set.
132///
133/// Mesen2 serializes the same set — `_spriteIndex`, `_sprite0Added`,
134/// `_sprite0Visible`, `_oamCopybuffer`, `_secondaryOamAddr`, `_spriteInRange`,
135/// `_oamCopyDone`, `_overflowBugCounter` (`Core/NES/NesPpu.cpp`
136/// `NesPpu<T>::Serialize`) — independent confirmation that this is live state,
137/// not derived.
138///
139/// v1..=7 blobs upconvert to the constructor defaults (`0xFF` for the two read
140/// latches, `[0xFF; 32]` for the parallel secondary OAM, `0`/`false` elsewhere)
141/// — the at-rest state, and exactly what a pre-v8 restore left behind. NOTE
142/// that `rustynes_core`'s `.rns` container is version-EXACT per section, so an
143/// existing save state fails to load with a clear `VersionMismatch` rather than
144/// silently misreading (ADR 0028); the in-function upconvert path serves direct
145/// `Ppu::restore` callers.
146///
147/// Also on restore (ALL versions): the scanline-classification cache
148/// (`cached_visible` / `cached_pre_render` / `cached_render_line`, keyed by
149/// `flags_cached_scanline`) is INVALIDATED rather than serialized. It is a pure
150/// function of `scanline` + `region`, both of which are serialized, so
151/// recomputing it is equivalent and cheaper than carrying derived bytes — the
152/// same choice Mesen2 makes in its `if(!s.IsSaving())` post-load fixup block.
153/// - v11 (v2.9.5 "Caliper"): appends `spr_rearm_deferred` (1 byte), the dot-339
154/// sprite re-arm an odd-frame skip defers past scanline 0's first pixel. It is
155/// live only across the frame boundary, which is exactly where run-ahead and
156/// save states snapshot. This is a `.rns` epoch, because the container compares
157/// the PPU section version for equality (ADR 0028); v1..=10 blobs upconvert to
158/// `false` on the direct `Ppu::restore` path.
159/// - v12 (T-MMC3-BG-A12, for v3.0.0): appends `dot0_replaced` (1 byte), set
160/// when the odd-frame skip replaces scanline 0's idle dot 0 with the last
161/// dummy nametable tick, so that dot does not drive the background CHR
162/// address (and its A12) the way a visible line's dot 0 does. Like
163/// `spr_rearm_deferred` it is live only across the frame boundary, where
164/// run-ahead and save states snapshot; dropping it would let a restored
165/// frame raise A12 at a dot 0 the skip removed, which an MMC3 counts.
166/// - v13 (v3.1.0, `T-SPRITE-LIMIT`): appends the extra sprites the "disable
167/// sprite limit" option fetched for the next scanline: a count (1 byte,
168/// `0..=MAX_EXTRA_SPRITES`) and four 56-byte arrays (pattern low, pattern
169/// high, attributes, X). Render-only state, but it decides the next
170/// scanline's picture, and a snapshot can fall between the fetch (dots
171/// 257-320) and that line, so it is carried rather than dropped, the rule
172/// `snapshot_schema_audit.rs` exists for. All zero while the option is off.
173/// No upconvert: v3.1.0 states are refused by BUS section 3 regardless.
174pub const PPU_SNAPSHOT_VERSION: u8 = 13;
175
176/// v2.3.3 — high bit of the version byte, marking a **slim** snapshot: every
177/// field except the 245,760-byte framebuffer.
178///
179/// Exists for the rewind ring, which snapshots on *every* frame inside the
180/// frame budget and then XORs and LZ4-compresses the result. The framebuffer
181/// is 94% of those bytes and the worst possible payload for that scheme — it
182/// changes every frame, so the XOR never zeroes and the delta never
183/// compresses. Measured, rewind roughly doubled the produce-interval p95
184/// (31.17 ms against 17.12 ms with it off) and was the cause of a
185/// user-visible judder report; see `docs/performance.md` v2.3.3 F3/F4.
186///
187/// Encoded as a flag on the version byte rather than a new version number so
188/// the on-disk format is untouched: every `.rns` ever written has the high bit
189/// clear and still parses on exactly the path it always did. Slim snapshots
190/// are in-memory only and are never written to a file.
191///
192/// A slim blob restores every other field and **leaves the framebuffer
193/// untouched**, so the caller is responsible for producing an image — see
194/// `Nes::rewind_step_back`, which runs one frame to regenerate it.
195pub const PPU_SNAPSHOT_SLIM_FLAG: u8 = 0x80;
196
197const CIRAM_LEN: usize = 0x800;
198const OAM_LEN: usize = 0x100;
199const SEC_OAM_LEN: usize = 32;
200const PAL_LEN: usize = 32;
201
202/// Errors returned by [`Ppu::restore`].
203#[derive(Debug, Error)]
204#[non_exhaustive]
205pub enum PpuSnapshotError {
206 /// Blob is too short for the version-1 schema.
207 #[error("PPU snapshot truncated at offset {0}")]
208 Truncated(usize),
209 /// The blob decoded completely with bytes left over (v2.9.9, NC-14:
210 /// this used to be reported as `Truncated` at the end offset).
211 #[error("PPU snapshot has {extra} unexpected trailing bytes after offset {consumed}")]
212 TrailingBytes {
213 /// Bytes the schema consumed.
214 consumed: usize,
215 /// Bytes left over.
216 extra: usize,
217 },
218 /// The blob's version byte is not understood by this build.
219 #[error("PPU snapshot unsupported version {0}")]
220 UnsupportedVersion(u8),
221 /// Region tag was not one of `0` (NTSC), `1` (PAL), `2` (Dendy).
222 #[error("PPU snapshot has invalid region tag {0}")]
223 InvalidRegion(u8),
224 /// Optional struct presence byte was something other than 0 or 1.
225 #[error("PPU snapshot has invalid optional presence byte {0}")]
226 InvalidPresence(u8),
227 /// v9 OAM2 fetch address was outside the 5-bit hardware counter's range.
228 ///
229 /// `oam2_fetch_addr` indexes the 32-byte secondary-OAM bus, and the
230 /// hardware counter it models is 5 bits wide, so anything `>= 32` is a
231 /// corrupt or hostile blob rather than a state this emulator can reach.
232 /// Rejected at the boundary instead of masked, so a malformed file is
233 /// reported rather than silently reinterpreted as a different state.
234 #[error("PPU snapshot has out-of-range OAM2 fetch address {0} (max 31)")]
235 InvalidOam2FetchAddr(u8),
236 /// The in-range sprite count exceeded the eight sprites secondary OAM holds.
237 ///
238 /// `spr_count` bounds loops over the eight-slot sprite arrays
239 /// (`spr_x`, `spr_halted`, the shift registers), so a value above 8 would
240 /// restore without complaint and then index out of bounds on the next PPU
241 /// dot -- a deferred panic, and on a `panic = "abort"` release build a
242 /// process kill from a hand-edited save. Core audit IMP-01.
243 #[error("PPU snapshot has out-of-range sprite count {0} (max 8)")]
244 InvalidSprCount(u8),
245 /// A counter or index held a value the PPU never produces.
246 ///
247 /// Each of these is incremented without a mask or used as an index, so a
248 /// value past its range overflows or indexes out of bounds on a later dot.
249 /// The limits are the ranges the running PPU keeps them in (fine X is the
250 /// 3-bit `x` register; the sprite-evaluation and OAM-bus counters are
251 /// reset at their wrap points). Found field by field by the v2.7.0
252 /// `save_state` fuzz target (fine X: `ppu.rs:4788`, shift overflow, in
253 /// 92,496 runs) and then swept for the rest rather than left to the
254 /// fuzzer one run at a time.
255 #[error("PPU snapshot field `{field}` is {value}, above its maximum {max}")]
256 FieldOutOfRange {
257 /// Which field.
258 field: &'static str,
259 /// The value found in the blob.
260 value: u8,
261 /// The largest value the PPU keeps it at.
262 max: u8,
263 },
264 /// The raster position is outside the region's frame.
265 ///
266 /// `dot` runs 0..=340 and `scanline` -1..=the region's pre-render line
267 /// (-1 is the power-on position). The per-dot advance only wraps at
268 /// exactly those limits, so a position past either counts on until the
269 /// integer overflows (a panic in dev profiles) and never produces a
270 /// frame. Found by the v2.7.0 `save_state` fuzz target in 76,381 runs
271 /// (`ppu.rs:6078`, `dot += 1`).
272 #[error("PPU snapshot has out-of-range raster position dot {dot}, scanline {scanline}")]
273 InvalidRasterPosition {
274 /// The restored dot.
275 dot: u16,
276 /// The restored scanline.
277 scanline: i16,
278 },
279}
280
281/// Reject a restored `u8` above `max` (see [`PpuSnapshotError::FieldOutOfRange`]).
282const fn bounded(field: &'static str, value: u8, max: u8) -> Result<u8, PpuSnapshotError> {
283 if value > max {
284 Err(PpuSnapshotError::FieldOutOfRange { field, value, max })
285 } else {
286 Ok(value)
287 }
288}
289
290const fn region_to_u8(r: PpuRegion) -> u8 {
291 match r {
292 PpuRegion::Ntsc => 0,
293 PpuRegion::Pal => 1,
294 PpuRegion::Dendy => 2,
295 }
296}
297
298const fn region_from_u8(v: u8) -> Result<PpuRegion, PpuSnapshotError> {
299 match v {
300 0 => Ok(PpuRegion::Ntsc),
301 1 => Ok(PpuRegion::Pal),
302 2 => Ok(PpuRegion::Dendy),
303 other => Err(PpuSnapshotError::InvalidRegion(other)),
304 }
305}
306
307struct W {
308 buf: Vec<u8>,
309}
310impl W {
311 fn u8(&mut self, v: u8) {
312 self.buf.push(v);
313 }
314 fn u16(&mut self, v: u16) {
315 self.buf.extend_from_slice(&v.to_le_bytes());
316 }
317 fn u32(&mut self, v: u32) {
318 self.buf.extend_from_slice(&v.to_le_bytes());
319 }
320 fn u64(&mut self, v: u64) {
321 self.buf.extend_from_slice(&v.to_le_bytes());
322 }
323 fn i16(&mut self, v: i16) {
324 self.buf.extend_from_slice(&v.to_le_bytes());
325 }
326 fn bytes(&mut self, v: &[u8]) {
327 self.buf.extend_from_slice(v);
328 }
329}
330
331struct R<'a> {
332 src: &'a [u8],
333 pos: usize,
334}
335impl R<'_> {
336 const fn need(&self, n: usize) -> Result<(), PpuSnapshotError> {
337 if self.src.len() - self.pos < n {
338 return Err(PpuSnapshotError::Truncated(self.pos));
339 }
340 Ok(())
341 }
342 fn u8(&mut self) -> Result<u8, PpuSnapshotError> {
343 self.need(1)?;
344 let v = self.src[self.pos];
345 self.pos += 1;
346 Ok(v)
347 }
348 fn u16(&mut self) -> Result<u16, PpuSnapshotError> {
349 self.need(2)?;
350 let v = u16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
351 self.pos += 2;
352 Ok(v)
353 }
354 fn u32(&mut self) -> Result<u32, PpuSnapshotError> {
355 self.need(4)?;
356 let mut a = [0u8; 4];
357 a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
358 self.pos += 4;
359 Ok(u32::from_le_bytes(a))
360 }
361 fn u64(&mut self) -> Result<u64, PpuSnapshotError> {
362 self.need(8)?;
363 let mut a = [0u8; 8];
364 a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
365 self.pos += 8;
366 Ok(u64::from_le_bytes(a))
367 }
368 fn i16(&mut self) -> Result<i16, PpuSnapshotError> {
369 self.need(2)?;
370 let v = i16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
371 self.pos += 2;
372 Ok(v)
373 }
374 fn bytes_into(&mut self, dst: &mut [u8]) -> Result<(), PpuSnapshotError> {
375 self.need(dst.len())?;
376 dst.copy_from_slice(&self.src[self.pos..self.pos + dst.len()]);
377 self.pos += dst.len();
378 Ok(())
379 }
380}
381
382impl Ppu {
383 /// Encode the PPU's mutable state into a versioned binary blob.
384 // A flat, linear field-by-field encoder with per-version tail appends (v1
385 // through v8); splitting it would only scatter the schema that is clearest read
386 // top-to-bottom against the matching `restore` reader.
387 #[allow(clippy::too_many_lines)]
388 #[must_use]
389 pub fn snapshot(&self) -> Vec<u8> {
390 self.snapshot_with(false)
391 }
392
393 /// v2.3.3 — [`Ppu::snapshot`] without the framebuffer. See
394 /// [`PPU_SNAPSHOT_SLIM_FLAG`].
395 #[must_use]
396 pub fn snapshot_slim(&self) -> Vec<u8> {
397 self.snapshot_with(true)
398 }
399
400 /// Shared body writer for the full and slim encodings.
401 // One long straight-line writer: every field in schema order. Splitting it
402 // would make the field-order correspondence with the reader (and with the
403 // ADR 0034 schema audit, which parses this body) harder to verify, which is
404 // the opposite of what this code needs.
405 #[allow(clippy::too_many_lines)]
406 fn snapshot_with(&self, slim: bool) -> Vec<u8> {
407 // Capacity hint: ~256 KiB framebuffer dominates the full encoding;
408 // the slim one is ~4 KiB, so don't reserve for a buffer it omits.
409 let mut w = W {
410 buf: Vec::with_capacity(if slim { 4096 } else { FRAMEBUFFER_LEN + 4096 }),
411 };
412 w.u8(if slim {
413 PPU_SNAPSHOT_VERSION | PPU_SNAPSHOT_SLIM_FLAG
414 } else {
415 PPU_SNAPSHOT_VERSION
416 });
417 w.u8(region_to_u8(self.region));
418
419 w.u8(self.ctrl.bits());
420 w.u8(self.mask.bits());
421 w.u8(self.mask_for_skip_check.bits());
422 w.u8(self.mask_skip_pipe1.bits());
423 w.u8(self.status.bits());
424
425 w.u8(self.oam_addr);
426 w.u8(self.data_buffer);
427 w.u16(self.v);
428 w.u16(self.t);
429 w.u8(self.x);
430 w.u8(u8::from(self.w));
431
432 // Memory blocks at fixed sizes — no length prefix needed (versioned schema).
433 w.bytes(&self.ciram);
434 w.bytes(&self.oam);
435 w.bytes(&self.secondary_oam);
436 w.bytes(&self.palette_ram);
437
438 w.u8(self.open_bus);
439 for d in self.open_bus_decay {
440 w.u32(d);
441 }
442
443 w.u8(u8::from(self.nmi_line));
444 w.u8(u8::from(self.suppress_vbl_this_frame));
445 w.u8(u8::from(self.last_a12_level));
446
447 w.u16(self.dot);
448 w.i16(self.scanline);
449 w.u64(self.frame);
450 w.u8(u8::from(self.frame_complete));
451
452 w.u32(self.post_reset_mask_remaining);
453
454 w.u8(self.nt_latch);
455 w.u8(self.at_latch);
456 w.u8(self.bg_lo_latch);
457 w.u8(self.bg_hi_latch);
458 w.u16(self.bg_shift_lo);
459 w.u16(self.bg_shift_hi);
460 // v2: attribute shift registers widened to 16-bit (lockstep with
461 // the pattern shifters); the v1 1-bit `at_feed_*` fields are gone.
462 w.u16(self.at_shift_lo);
463 w.u16(self.at_shift_hi);
464
465 if let Some(ex) = self.ex_attr_latch {
466 w.u8(1);
467 w.u8(ex.palette);
468 w.u16(ex.chr_bank);
469 } else {
470 w.u8(0);
471 w.u8(0);
472 w.u16(0);
473 }
474 if let Some(s) = self.bg_split_latch {
475 w.u8(1);
476 w.u16(s.nt_addr);
477 w.u16(s.at_addr);
478 w.u8(s.fine_y);
479 w.u8(s.chr_bank);
480 } else {
481 w.u8(0);
482 w.u16(0);
483 w.u16(0);
484 w.u8(0);
485 w.u8(0);
486 }
487
488 w.bytes(&self.spr_shift_lo);
489 w.bytes(&self.spr_shift_hi);
490 w.bytes(&self.spr_attr);
491 w.bytes(&self.spr_x);
492 w.u8(self.spr_count);
493 w.u8(u8::from(self.spr_zero_in_line));
494
495 if !slim {
496 w.bytes(&self.framebuffer);
497 }
498
499 // v3 (W3-Stage-4): the `mc-ppu-2007-render-buffer` PPUDATA
500 // state-machine tail. Written unconditionally (zeros when the
501 // feature is off) so the blob layout is feature-independent.
502 {
503 w.u8(self.render_data_bus);
504 w.u8(self.ppudata_sm_countdown);
505 w.u8(u8::from(self.ppudata_v_inc_pending));
506 w.bytes(&self.spr_fetch_lo_raw);
507 w.bytes(&self.spr_fetch_hi_raw);
508 w.u16(self.ppudata_spr0_nt_addr);
509 }
510 // v3 (W3-Stage-4): the `mc-ppu-subpos` BG-reload freeze (the
511 // `$2001`-write-commit delay that injects the BG serial-in '1's).
512 // `bg_reload_render` re-syncs from the live mask when settled, but an
513 // in-flight `mask_write_delay` countdown can straddle an instruction
514 // boundary — serialize both so a restored state resumes the freeze
515 // exactly.
516 {
517 w.u8(u8::from(self.bg_reload_render));
518 w.u8(self.mask_write_delay);
519 }
520
521 // v4 (v1.7.0 F3): the in-flight extra-scanlines overclock countdown.
522 // Always `0` at the default `extra_scanlines == 0`, so this is a
523 // zero `u16` in the stock build (no behavioural change). The
524 // configured count itself (`extra_scanlines`) stays a frontend knob
525 // re-applied on restore, like `region` / `active_palette`.
526 w.u16(self.extra_lines_remaining);
527
528 // v5 (v2.0.3, ADR 0030): the 2-cycle-ALE fetch model's in-flight
529 // multiplexed-bus / octal-latch state. All at rest (`0`/`false`) at a
530 // clean fetch boundary, but a mid-render checkpoint (netplay rollback)
531 // can land with `copy_v_delay`/`pattern_latch_stale`/`ale_armed` live —
532 // serialize them (plus the latch + bus they splice through) so the
533 // re-simulated frame is byte-identical to the forward run.
534 {
535 w.u8(self.octal_latch);
536 w.u16(self.address_bus);
537 w.u8(u8::from(self.ale_armed));
538 w.u8(u8::from(self.pattern_latch_stale));
539 w.u8(self.copy_v_delay);
540 }
541
542 // v6 (W&W run-ahead fix): the per-sprite shifter HALT state. Set by the
543 // v2.0 sprite-shifter-counter model (a loaded-but-halted slot draws
544 // immediately on re-enable), it persists across the frame boundary and
545 // governs whether each of the 8 loaded sprites emits — so it is genuine
546 // rendering state. It was previously unserialized, so a per-frame
547 // save/restore (run-ahead, netplay rollback) drifted it: for a game that
548 // toggles rendering mid-frame (Wizards & Warriors' sprite-0 status-bar
549 // split), the drift accumulated into dropped/blinking sprites and a
550 // half-rendered playfield. `true` (halted) is the power-on default.
551 for h in &self.spr_halted {
552 w.u8(u8::from(*h));
553 }
554 // v6 (cont.) — remaining unserialized cross-frame render state.
555 w.u8(u8::from(self.prev_rendering_enabled));
556 w.u8(u8::from(self.rendering_enabled_delayed));
557 w.u8(u8::from(self.oam_corruption_pending));
558 w.u8(self.oam_corruption_index);
559 w.u8(u8::from(self.oam_corruption_disabled));
560 w.u8(u8::from(self.oam_corruption_disabled_instant));
561
562 // v7 (v2.1.4 F2.3): the optional OAM-decay model's per-row last-touch
563 // timestamps, stored as a RELATIVE AGE (`now - timestamp`) so the decay
564 // clock survives a rollback/restore that rebases the un-serialized
565 // free-running `dot_counter`. `now` is the live CPU cycle (`dot_counter / 3`,
566 // NTSC/Dendy divisor). `wrapping_sub` keeps the encode total even across the
567 // (astronomically unlikely) u64 wrap. When decay is off these ages are inert
568 // (never read on restore's decay path), but they still round-trip exactly.
569 let now = self.dot_counter / 3;
570 for ts in self.oam_decay_cycles {
571 w.u64(now.wrapping_sub(ts));
572 }
573
574 // v8: the per-dot sprite-evaluation FSM + the parallel OAM-data-bus
575 // model + the clear-window secondary-OAM write pointer. `secondary_oam`
576 // (the buffer) was always serialized; these are the POINTERS AND PHASE
577 // that fill it, and without them a mid-eval snapshot restored a full
578 // buffer next to a reset walker. See the `PPU_SNAPSHOT_VERSION` rustdoc
579 // for the run-ahead failure this closes and the Mesen2 cross-check.
580 {
581 w.u8(self.sprite_eval_read_latch);
582 w.u8(self.sprite_eval_n);
583 w.u8(self.sprite_eval_m);
584 w.u8(self.sprite_eval_found);
585 w.u8(self.sprite_eval_sec_idx);
586 w.u8(u8::from(self.sprite_eval_copying));
587 w.u8(u8::from(self.sprite_eval_done));
588 w.u8(u8::from(self.sprite_eval_overflow_search));
589 w.u8(u8::from(self.sprite_eval_zero_found));
590 w.u8(u8::from(self.sprite_eval_first_iter));
591
592 w.u8(self.oam_bus_copybuffer);
593 w.bytes(&self.oam_bus_secondary);
594 w.u8(self.oam_bus_addr_h);
595 w.u8(self.oam_bus_addr_l);
596 w.u8(self.oam_bus_secondary_addr);
597 w.u8(u8::from(self.oam_bus_copy_done));
598 w.u8(u8::from(self.oam_bus_sprite_in_range));
599 w.u8(self.oam_bus_overflow_counter);
600
601 w.u8(self.oam2_addr);
602 }
603
604 // v9 tail — the OAM2Address counter carried across sprite fetch plus
605 // its "OAM2 Overflowed" freeze flag and the dot-257 latch.
606 //
607 // These are serialized rather than allowlisted as derived. They LOOK
608 // derived (all three re-derive at dots 63/255/339 within a scanline),
609 // and that reasoning is exactly wrong here: the whole behaviour they
610 // model is what happens when rendering is DISABLED across those reset
611 // dots, so a snapshot taken inside such a window carries state that
612 // cannot be recomputed from anything else in the blob. Run-ahead
613 // snapshots and restores every frame, so dropping them would silently
614 // reintroduce the bug this release fixed.
615 w.u8(self.oam2_fetch_addr);
616 w.u8(u8::from(self.oam2_overflowed));
617 w.u8(u8::from(self.oam2_fetch_frozen));
618
619 // v10 tail — stage 2 of the rendering gate, which the dot-256 vertical
620 // increment reads. Serialized for the same reason as the v9 fields
621 // above: it LOOKS derivable from `rendering_enabled_delayed`, and is
622 // not. The two differ for exactly one dot after any `$2001` rendering
623 // edge, and that dot is the whole subject -- a run-ahead snapshot taken
624 // there and restored from stage 1 would fire, or skip, the increment
625 // the restored timeline must not.
626 w.u8(u8::from(self.rendering_enabled_delayed2));
627
628 // v11 tail — the odd-frame-deferred sprite re-arm (v2.9.5). It is set at
629 // the pre-render skip and cleared after scanline 0's first pixel, so it
630 // is live exactly across the frame boundary where run-ahead and save
631 // states snapshot. Dropping it would draw that frame's scanline-0
632 // sprites at their normal X instead of the composite PPU's X=0.
633 w.u8(u8::from(self.spr_rearm_deferred));
634
635 // v12 tail — the odd-frame skip replaced scanline 0's dot 0 (set at
636 // the skip, consumed at that dot), live across the frame boundary.
637 w.u8(u8::from(self.dot0_replaced));
638
639 // v13 tail — the sprite-limit option's extra sprites for the next line.
640 w.u8(self.spr_extra_count);
641 w.bytes(&self.spr_extra_lo);
642 w.bytes(&self.spr_extra_hi);
643 w.bytes(&self.spr_extra_attr);
644 w.bytes(&self.spr_extra_x);
645
646 w.buf
647 }
648
649 /// Decode a previously [`Ppu::snapshot`]ed blob.
650 ///
651 /// # Errors
652 ///
653 /// Returns [`PpuSnapshotError`] on a malformed blob.
654 // A flat, linear field-by-field decoder; splitting it would only scatter
655 // the schema that is clearest read top-to-bottom against the matching
656 // `snapshot` writer.
657 #[allow(clippy::too_many_lines)]
658 pub fn restore(&mut self, data: &[u8]) -> Result<(), PpuSnapshotError> {
659 // A valid snapshot (only the current version loads since v2.9.8) always contains these fixed-size blocks (the
660 // framebuffer, read unconditionally below at every version, dominates);
661 // the version-specific tails only add to this. This is a *conservative
662 // lower bound* — it deliberately omits the ~40 scalar register/latch
663 // bytes and the spr shift arrays, so it can never reject a valid blob,
664 // yet it rejects a clearly-truncated one BEFORE the version byte is read
665 // (so short/garbled input reports `Truncated`, not a misleading
666 // `UnsupportedVersion` on whatever byte sits at offset 0). `Truncated(0)`
667 // matches the offset semantics `R::need` uses elsewhere (the position at
668 // which a read ran out) — here, nothing valid was read.
669 // v2.3.3 — a slim blob (high bit of the version byte) carries no
670 // framebuffer, so the bound must drop that term for it. Read the flag
671 // from byte 0 directly rather than through `R`, because this check
672 // deliberately runs BEFORE the reader is constructed.
673 const MIN_SLIM_SIZE: usize = 1 + CIRAM_LEN + OAM_LEN + SEC_OAM_LEN + PAL_LEN;
674 const MIN_SNAPSHOT_SIZE: usize = MIN_SLIM_SIZE + FRAMEBUFFER_LEN;
675 let is_slim = data
676 .first()
677 .is_some_and(|v| v & PPU_SNAPSHOT_SLIM_FLAG != 0);
678 let min_size = if is_slim {
679 MIN_SLIM_SIZE
680 } else {
681 MIN_SNAPSHOT_SIZE
682 };
683 if data.len() < min_size {
684 return Err(PpuSnapshotError::Truncated(0));
685 }
686 let mut r = R { src: data, pos: 0 };
687 let raw_version = r.u8()?;
688 // v2.3.3 — the high bit marks a slim (framebuffer-less) blob; strip it
689 // before the range check so the version rules are unchanged.
690 let slim = raw_version & PPU_SNAPSHOT_SLIM_FLAG != 0;
691 let version = raw_version & !PPU_SNAPSHOT_SLIM_FLAG;
692 // Only the current version is read (v2.9.8, ADR 0042). Versions 1-10
693 // used to be upconverted here, each missing tail restored to its
694 // rest default; see `PPU_SNAPSHOT_VERSION` for what each added.
695 if version != PPU_SNAPSHOT_VERSION {
696 return Err(PpuSnapshotError::UnsupportedVersion(raw_version));
697 }
698 self.region = region_from_u8(r.u8()?)?;
699
700 self.ctrl = PpuCtrl::from_bits_truncate(r.u8()?);
701 self.mask = PpuMask::from_bits_truncate(r.u8()?);
702 self.mask_for_skip_check = PpuMask::from_bits_truncate(r.u8()?);
703 self.mask_skip_pipe1 = PpuMask::from_bits_truncate(r.u8()?);
704 self.status = PpuStatus::from_bits_truncate(r.u8()?);
705
706 self.oam_addr = r.u8()?;
707 self.data_buffer = r.u8()?;
708 self.v = r.u16()?;
709 self.t = r.u16()?;
710 self.x = bounded("x", r.u8()?, 7)?;
711 self.w = r.u8()? != 0;
712
713 r.bytes_into(&mut self.ciram)?;
714 r.bytes_into(&mut self.oam)?;
715 r.bytes_into(&mut self.secondary_oam)?;
716 r.bytes_into(&mut self.palette_ram)?;
717
718 self.open_bus = r.u8()?;
719 for d in &mut self.open_bus_decay {
720 *d = r.u32()?;
721 }
722
723 self.nmi_line = r.u8()? != 0;
724 self.suppress_vbl_this_frame = r.u8()? != 0;
725 self.last_a12_level = r.u8()? != 0;
726
727 let dot = r.u16()?;
728 let scanline = r.i16()?;
729 // -1 is legal: power-on parks the PPU at (-1, 340), and a snapshot taken
730 // before the first tick carries it; the advance wraps it to 0.
731 if dot > 340 || !(-1..=self.region.prerender_line()).contains(&scanline) {
732 return Err(PpuSnapshotError::InvalidRasterPosition { dot, scanline });
733 }
734 self.dot = dot;
735 self.scanline = scanline;
736 self.frame = r.u64()?;
737 self.frame_complete = r.u8()? != 0;
738
739 self.post_reset_mask_remaining = r.u32()?;
740
741 self.nt_latch = r.u8()?;
742 self.at_latch = r.u8()?;
743 self.bg_lo_latch = r.u8()?;
744 self.bg_hi_latch = r.u8()?;
745 self.bg_shift_lo = r.u16()?;
746 self.bg_shift_hi = r.u16()?;
747 self.at_shift_lo = r.u16()?;
748 self.at_shift_hi = r.u16()?;
749
750 let ex_present = r.u8()?;
751 let palette = r.u8()?;
752 let chr_bank = r.u16()?;
753 self.ex_attr_latch = match ex_present {
754 0 => None,
755 1 => Some(ExAttribute { palette, chr_bank }),
756 other => return Err(PpuSnapshotError::InvalidPresence(other)),
757 };
758 let split_present = r.u8()?;
759 let nt_addr = r.u16()?;
760 let at_addr = r.u16()?;
761 let fine_y = r.u8()?;
762 let chr_bank8 = r.u8()?;
763 self.bg_split_latch = match split_present {
764 0 => None,
765 1 => Some(BgSplitState {
766 nt_addr,
767 at_addr,
768 fine_y,
769 chr_bank: chr_bank8,
770 }),
771 other => return Err(PpuSnapshotError::InvalidPresence(other)),
772 };
773
774 r.bytes_into(&mut self.spr_shift_lo)?;
775 r.bytes_into(&mut self.spr_shift_hi)?;
776 r.bytes_into(&mut self.spr_attr)?;
777 r.bytes_into(&mut self.spr_x)?;
778 let spr_count = r.u8()?;
779 if spr_count > 8 {
780 return Err(PpuSnapshotError::InvalidSprCount(spr_count));
781 }
782 self.spr_count = spr_count;
783 self.spr_zero_in_line = r.u8()? != 0;
784
785 // Slim blobs carry no framebuffer; the existing one is left in place
786 // and the caller regenerates the image.
787 if !slim {
788 r.bytes_into(&mut self.framebuffer)?;
789 }
790
791 // v3 (W3-Stage-4): the gated master-clock PPU tail.
792 self.restore_stage4_tail(&mut r)?;
793
794 // v4 (v1.7.0 F3): the in-flight extra-scanlines overclock countdown.
795 let extra_lines_remaining = r.u16()?;
796 // Clamped, not rejected. The configured `extra_scanlines` is a frontend
797 // knob that is NOT serialized, so a real save made under a larger knob
798 // legitimately carries a larger countdown; refusing it would reject a
799 // good file. But a corrupt countdown under a live knob idles up to
800 // 65,535 scanlines (~4 s) before the frame resumes (review finding on
801 // #546), and the countdown never legitimately exceeds the knob it was
802 // loaded from, so it is clamped to the knob this PPU now runs with. At
803 // the default knob of 0 the insertion branch is unreachable and the
804 // value is inert, so it is kept as-is -- `set_extra_scanlines` zeroes
805 // it if the knob is later turned on.
806 self.extra_lines_remaining = if self.extra_scanlines == 0 {
807 extra_lines_remaining
808 } else {
809 extra_lines_remaining.min(self.extra_scanlines)
810 };
811
812 // v5 (v2.0.3, ADR 0030): the 2-cycle-ALE in-flight fetch state. A blob
813 // taken mid-render round-trips the live values so the re-simulated
814 // frame stays byte-identical (netplay rollback).
815 self.octal_latch = r.u8()?;
816 self.address_bus = r.u16()?;
817 self.ale_armed = r.u8()? != 0;
818 self.pattern_latch_stale = r.u8()? != 0;
819 self.copy_v_delay = r.u8()?;
820
821 // v6: per-sprite shifter halt state (see the write side).
822 for h in &mut self.spr_halted {
823 *h = r.u8()? != 0;
824 }
825 self.prev_rendering_enabled = r.u8()? != 0;
826 self.rendering_enabled_delayed = r.u8()? != 0;
827 self.oam_corruption_pending = r.u8()? != 0;
828 self.oam_corruption_index = bounded("oam_corruption_index", r.u8()?, 0x20)?;
829 self.oam_corruption_disabled = r.u8()? != 0;
830 self.oam_corruption_disabled_instant = r.u8()? != 0;
831
832 // v7 (v2.1.4 F2.3): the optional OAM-decay row timestamps, stored as a
833 // relative age. Reconstruct the absolute timestamp against the LIVE counter
834 // (`now = dot_counter / 3`, unchanged by restore) as `now - age`, so the
835 // decay clock is preserved regardless of how the counter was rebased between
836 // snapshot and restore (the rollback-determinism property).
837 let now = self.dot_counter / 3;
838 for ts in &mut self.oam_decay_cycles {
839 let age = r.u64()?;
840 *ts = now.wrapping_sub(age);
841 }
842
843 // v8: the per-dot sprite-evaluation FSM + parallel OAM-data-bus model +
844 // the clear-window secondary-OAM pointer.
845 self.sprite_eval_read_latch = r.u8()?;
846 self.sprite_eval_n = bounded("sprite_eval_n", r.u8()?, 63)?;
847 self.sprite_eval_m = bounded("sprite_eval_m", r.u8()?, 3)?;
848 self.sprite_eval_found = bounded("sprite_eval_found", r.u8()?, 8)?;
849 self.sprite_eval_sec_idx = bounded("sprite_eval_sec_idx", r.u8()?, 0x20)?;
850 self.sprite_eval_copying = r.u8()? != 0;
851 self.sprite_eval_done = r.u8()? != 0;
852 self.sprite_eval_overflow_search = r.u8()? != 0;
853 self.sprite_eval_zero_found = r.u8()? != 0;
854 self.sprite_eval_first_iter = r.u8()? != 0;
855
856 self.oam_bus_copybuffer = r.u8()?;
857 r.bytes_into(&mut self.oam_bus_secondary)?;
858 self.oam_bus_addr_h = bounded("oam_bus_addr_h", r.u8()?, 63)?;
859 self.oam_bus_addr_l = bounded("oam_bus_addr_l", r.u8()?, 3)?;
860 self.oam_bus_secondary_addr = bounded("oam_bus_secondary_addr", r.u8()?, 0x20)?;
861 self.oam_bus_copy_done = r.u8()? != 0;
862 self.oam_bus_sprite_in_range = r.u8()? != 0;
863 self.oam_bus_overflow_counter = bounded("oam_bus_overflow_counter", r.u8()?, 3)?;
864
865 self.oam2_addr = bounded("oam2_addr", r.u8()?, 0x1F)?;
866
867 // v9: the `OAM2Address` counter and its two latched flags.
868 // Range-check at the EDGE: this byte is untrusted input and is used
869 // directly as an index into `oam_bus_secondary: [u8; 32]`.
870 let fetch_addr = r.u8()?;
871 if fetch_addr >= 32 {
872 return Err(PpuSnapshotError::InvalidOam2FetchAddr(fetch_addr));
873 }
874 self.oam2_fetch_addr = fetch_addr;
875 self.oam2_overflowed = r.u8()? != 0;
876 self.oam2_fetch_frozen = r.u8()? != 0;
877
878 // v10: stage 2 of the rendering gate.
879 self.rendering_enabled_delayed2 = r.u8()? != 0;
880
881 // v11: the deferred sprite re-arm (`true` only in the two dots after an
882 // odd-frame skip).
883 self.spr_rearm_deferred = r.u8()? != 0;
884
885 // v12: the odd-frame skip replaced scanline 0's dot 0.
886 self.dot0_replaced = r.u8()? != 0;
887
888 // v13: the sprite-limit option's extra sprites for the next line.
889 self.spr_extra_count = bounded(
890 "spr_extra_count",
891 r.u8()?,
892 u8::try_from(crate::ppu::MAX_EXTRA_SPRITES).unwrap_or(u8::MAX),
893 )?;
894 r.bytes_into(&mut self.spr_extra_lo)?;
895 r.bytes_into(&mut self.spr_extra_hi)?;
896 r.bytes_into(&mut self.spr_extra_attr)?;
897 r.bytes_into(&mut self.spr_extra_x)?;
898
899 // Derived-cache fixup: the scanline-classification cache
900 // is a pure function of `scanline` + `region`, so it is recomputed rather
901 // than carried. Resetting the key to the `Ppu::new` sentinel forces the
902 // next `tick` to refill it from the restored scanline; leaving a warm key
903 // behind would let a cache filled under a different timeline satisfy the
904 // `scanline == flags_cached_scanline` guard on the fast dot path.
905 self.cached_visible = false;
906 self.cached_pre_render = false;
907 self.cached_render_line = false;
908 #[cfg(feature = "ppu-idle-line-fast")]
909 {
910 self.cached_idle_line = false;
911 }
912 self.flags_cached_scanline = i16::MIN;
913
914 // sanity: the schema-fixed sizes mean we should be at end of input now.
915 if r.pos != data.len() {
916 return Err(PpuSnapshotError::TrailingBytes {
917 consumed: r.pos,
918 extra: data.len() - r.pos,
919 });
920 }
921 Ok(())
922 }
923}
924
925impl Ppu {
926 /// v3 (W3-Stage-4) tail decode: the `mc-ppu-2007-render-buffer` PPUDATA
927 /// state machine + the `mc-ppu-subpos` BG-reload freeze. Bytes are
928 /// always present in a v3 blob; fields whose cargo feature is off are
929 /// consumed and discarded.
930 fn restore_stage4_tail(&mut self, r: &mut R<'_>) -> Result<(), PpuSnapshotError> {
931 let render_data_bus = r.u8()?;
932 let ppudata_sm_countdown = r.u8()?;
933 let ppudata_v_inc_pending = r.u8()? != 0;
934 let mut spr_fetch_lo_raw = [0u8; 8];
935 let mut spr_fetch_hi_raw = [0u8; 8];
936 r.bytes_into(&mut spr_fetch_lo_raw)?;
937 r.bytes_into(&mut spr_fetch_hi_raw)?;
938 let ppudata_spr0_nt_addr = r.u16()?;
939 {
940 self.render_data_bus = render_data_bus;
941 self.ppudata_sm_countdown = ppudata_sm_countdown;
942 self.ppudata_v_inc_pending = ppudata_v_inc_pending;
943 self.spr_fetch_lo_raw = spr_fetch_lo_raw;
944 self.spr_fetch_hi_raw = spr_fetch_hi_raw;
945 self.ppudata_spr0_nt_addr = ppudata_spr0_nt_addr;
946 }
947 let bg_reload_render = r.u8()? != 0;
948 let mask_write_delay = r.u8()?;
949 {
950 self.bg_reload_render = bg_reload_render;
951 self.mask_write_delay = mask_write_delay;
952 }
953 Ok(())
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 // Per-version tail sizes, in bytes. The synthesis tests below build an OLD
962 // blob by truncating a CURRENT one, so every schema bump has to be
963 // subtracted — and before these were named, that meant editing two
964 // hand-computed literals and hoping they agreed. Naming them makes a bump
965 // one edit here, and makes the composition checkable at a glance.
966 //
967 // Verified to sum: 23 + 2 + 6 + 14 + 256 + 50 + 3 + 1 + 1 = 356 = V3_TAIL..V11_TAIL.
968 /// v3: W3-Stage-4 — `u8*3` + `[u8;8]*2` + u16 PPUDATA FSM + `u8*2` BG freeze.
969 const V3_TAIL: usize = 23;
970 /// v4: `u16 extra_lines_remaining`.
971 const V4_TAIL: usize = 2;
972 /// v5: 2-cycle-ALE fetch state — octal latch, address bus, three flags.
973 const V5_TAIL: usize = 6;
974 /// v6: render state — `[u8;8] spr_halted` + 6 bytes of gating/corruption.
975 const V6_TAIL: usize = 14;
976 /// v7: OAM decay — `[u64;32]` relative-age counters.
977 const V7_TAIL: usize = 256;
978 /// v8: sprite-evaluation FSM + the OAM data-bus model + `oam2_addr`.
979 const V8_TAIL: usize = 50;
980 /// v9: the `OAM2Address` counter — `oam2_fetch_addr` + two latched flags.
981 const V9_TAIL: usize = 3;
982 /// v10: stage 2 of the rendering gate (`rendering_enabled_delayed2`).
983 const V10_TAIL: usize = 1;
984 /// v11: the odd-frame-deferred sprite re-arm (`spr_rearm_deferred`).
985 const V11_TAIL: usize = 1;
986 /// v12: the odd-frame skip replaced scanline 0's dot 0 (`dot0_replaced`).
987 const V12_TAIL: usize = 1;
988 /// Everything a v1 blob does not carry, from `ex_attr_latch` onward.
989 const V3_THROUGH_V12_TAILS: usize = V3_TAIL
990 + V4_TAIL
991 + V5_TAIL
992 + V6_TAIL
993 + V7_TAIL
994 + V8_TAIL
995 + V9_TAIL
996 + V10_TAIL
997 + V11_TAIL
998 + V12_TAIL;
999
1000 /// v12: `dot0_replaced` must survive the round trip TRUE (a dropped or
1001 /// inverted byte would read back as the default `false`), and a v11 blob
1002 /// is refused.
1003 #[test]
1004 fn snapshot_v12_carries_the_replaced_dot_0() {
1005 let mut p = Ppu::new(PpuRegion::Ntsc);
1006 p.dot0_replaced = true;
1007 let mut q = Ppu::new(PpuRegion::Ntsc);
1008 q.restore(&p.snapshot()).unwrap();
1009 assert!(q.dot0_replaced, "a true flag must survive the round trip");
1010 p.dot0_replaced = false;
1011 q.dot0_replaced = true;
1012 q.restore(&p.snapshot()).unwrap();
1013 assert!(!q.dot0_replaced, "a false flag must overwrite a stale true");
1014 let cur = p.snapshot();
1015 let mut v11 = cur[..cur.len() - V12_TAIL].to_vec();
1016 v11[0] = 11;
1017 assert!(matches!(
1018 q.restore(&v11),
1019 Err(PpuSnapshotError::UnsupportedVersion(11))
1020 ));
1021 }
1022
1023 /// v11: `spr_rearm_deferred` is the reason for the epoch, and it is only
1024 /// ever `true` across the frame boundary, where every run-ahead and save
1025 /// snapshot is taken. The round trip must carry a TRUE value (the default
1026 /// `false` would survive an omitted or inverted byte), and a v10 blob is
1027 /// refused. Added in v2.9.5 at
1028 /// Copilot's review of #575.
1029 #[test]
1030 fn snapshot_v11_carries_the_deferred_sprite_rearm() {
1031 let mut p = Ppu::new(PpuRegion::Ntsc);
1032 p.spr_rearm_deferred = true;
1033 let blob = p.snapshot();
1034 let mut q = Ppu::new(PpuRegion::Ntsc);
1035 q.restore(&blob).unwrap();
1036 assert!(
1037 q.spr_rearm_deferred,
1038 "a true deferral must survive the round trip"
1039 );
1040
1041 p.spr_rearm_deferred = false;
1042 let blob = p.snapshot();
1043 q.spr_rearm_deferred = true;
1044 q.restore(&blob).unwrap();
1045 assert!(
1046 !q.spr_rearm_deferred,
1047 "a false deferral must overwrite a stale true"
1048 );
1049
1050 // A v10 blob (the current blob minus the one-byte v11 tail) upconverted
1051 // to `false` until v2.9.8; it is refused now (ADR 0042).
1052 p.spr_rearm_deferred = true;
1053 let cur = p.snapshot();
1054 let mut v10 = cur[..cur.len() - V11_TAIL].to_vec();
1055 v10[0] = 10;
1056 assert!(matches!(
1057 q.restore(&v10),
1058 Err(PpuSnapshotError::UnsupportedVersion(10))
1059 ));
1060 }
1061
1062 #[test]
1063 fn snapshot_round_trip() {
1064 let mut p = Ppu::new(PpuRegion::Ntsc);
1065 p.ciram[10] = 0xAB;
1066 p.oam[20] = 0xCD;
1067 p.palette_ram[5] = 0x21;
1068 p.framebuffer[100] = 0xEF;
1069 p.dot = 123;
1070 p.scanline = -1;
1071 p.frame = 42;
1072 p.ex_attr_latch = Some(ExAttribute {
1073 palette: 2,
1074 chr_bank: 0x123,
1075 });
1076 p.bg_split_latch = Some(BgSplitState {
1077 nt_addr: 0x2400,
1078 at_addr: 0x23C0,
1079 fine_y: 5,
1080 chr_bank: 7,
1081 });
1082
1083 let blob = p.snapshot();
1084
1085 let mut q = Ppu::new(PpuRegion::Pal);
1086 q.restore(&blob).unwrap();
1087 assert_eq!(q.region, PpuRegion::Ntsc);
1088 assert_eq!(q.ciram[10], 0xAB);
1089 assert_eq!(q.oam[20], 0xCD);
1090 assert_eq!(q.palette_ram[5], 0x21);
1091 assert_eq!(q.framebuffer[100], 0xEF);
1092 assert_eq!(q.dot, 123);
1093 assert_eq!(q.scanline, -1);
1094 assert_eq!(q.frame, 42);
1095 assert_eq!(
1096 q.ex_attr_latch,
1097 Some(ExAttribute {
1098 palette: 2,
1099 chr_bank: 0x123
1100 })
1101 );
1102 assert_eq!(
1103 q.bg_split_latch,
1104 Some(BgSplitState {
1105 nt_addr: 0x2400,
1106 at_addr: 0x23C0,
1107 fine_y: 5,
1108 chr_bank: 7,
1109 })
1110 );
1111 }
1112
1113 #[test]
1114 fn snapshot_round_trips_16bit_attribute_shifters() {
1115 // v2 widened `at_shift_lo`/`at_shift_hi` from u8 to u16 (lockstep
1116 // with the BG pattern shifters; the 086ce4d left-edge palette
1117 // fix). Verify the full 16-bit value survives a round trip — a
1118 // regression that truncated to 8 bits would re-introduce the
1119 // attribute/pattern drift after a save-state load.
1120 let mut p = Ppu::new(PpuRegion::Ntsc);
1121 p.at_shift_lo = 0xAB12;
1122 p.at_shift_hi = 0xCD34;
1123 p.bg_shift_lo = 0x5678;
1124 p.bg_shift_hi = 0x9ABC;
1125 let blob = p.snapshot();
1126 assert_eq!(
1127 blob[0], PPU_SNAPSHOT_VERSION,
1128 "blob carries current version"
1129 );
1130
1131 let mut q = Ppu::new(PpuRegion::Ntsc);
1132 q.restore(&blob).unwrap();
1133 assert_eq!(q.at_shift_lo, 0xAB12);
1134 assert_eq!(q.at_shift_hi, 0xCD34);
1135 assert_eq!(q.bg_shift_lo, 0x5678);
1136 assert_eq!(q.bg_shift_hi, 0x9ABC);
1137 }
1138
1139 /// v2.9.8 (ADR 0042): every older version is refused, whatever its
1140 /// length. Each is built the way the upconversion tests used to build
1141 /// them: the current blob minus the tails that version did not carry,
1142 /// with its version byte. (v1 also had a different attribute-shifter
1143 /// layout; its version byte alone is what is refused.)
1144 #[test]
1145 fn every_older_snapshot_version_is_refused() {
1146 let cur = Ppu::new(PpuRegion::Ntsc).snapshot();
1147 let tails = [
1148 V3_TAIL, V4_TAIL, V5_TAIL, V6_TAIL, V7_TAIL, V8_TAIL, V9_TAIL, V10_TAIL, V11_TAIL,
1149 V12_TAIL,
1150 ];
1151 assert_eq!(tails.iter().sum::<usize>(), V3_THROUGH_V12_TAILS);
1152 for v in 1..PPU_SNAPSHOT_VERSION {
1153 // Versions 1 and 2 carry none of the v3+ tails; version N >= 3
1154 // carries the tails up to and including its own.
1155 let carried = usize::from(v.saturating_sub(2));
1156 let missing: usize = tails[carried..].iter().sum();
1157 let mut old = cur[..cur.len() - missing].to_vec();
1158 old[0] = v;
1159 assert!(
1160 matches!(
1161 Ppu::new(PpuRegion::Ntsc).restore(&old),
1162 Err(PpuSnapshotError::UnsupportedVersion(got)) if got == v
1163 ),
1164 "a v{v} blob must be refused"
1165 );
1166 }
1167 // A slim blob of an older version is refused the same way.
1168 let mut slim = Ppu::new(PpuRegion::Ntsc).snapshot_slim();
1169 slim[0] = PPU_SNAPSHOT_SLIM_FLAG | (PPU_SNAPSHOT_VERSION - 1);
1170 assert!(Ppu::new(PpuRegion::Ntsc).restore(&slim).is_err());
1171 }
1172
1173 #[test]
1174 fn snapshot_rejects_short_blob() {
1175 let mut p = Ppu::new(PpuRegion::Ntsc);
1176 assert!(matches!(
1177 p.restore(&[]).unwrap_err(),
1178 PpuSnapshotError::Truncated(_)
1179 ));
1180 // The regression this size guard prevents: a SHORT blob whose first byte
1181 // is an unknown version must be classified `Truncated` (the guard runs
1182 // before the version check), NOT `UnsupportedVersion(0xFF)`.
1183 assert!(matches!(
1184 p.restore(&[0xFF; 4]).unwrap_err(),
1185 PpuSnapshotError::Truncated(_)
1186 ));
1187 }
1188
1189 #[test]
1190 fn snapshot_rejects_bad_version() {
1191 let mut p = Ppu::new(PpuRegion::Ntsc);
1192 // A full-size blob (past the truncation guard) whose version byte is
1193 // unknown must be rejected at the version check — not mistaken for a
1194 // truncated blob. (A short bad-version blob is a Truncated case,
1195 // covered by `snapshot_rejects_short_blob`.)
1196 let mut blob = p.snapshot();
1197 blob[0] = 0xFF;
1198 let err = p.restore(&blob).unwrap_err();
1199 assert!(matches!(err, PpuSnapshotError::UnsupportedVersion(0xFF)));
1200 }
1201
1202 #[test]
1203 fn snapshot_is_deterministic() {
1204 let p = Ppu::new(PpuRegion::Ntsc);
1205 assert_eq!(p.snapshot(), p.snapshot());
1206 }
1207
1208 #[test]
1209 fn a_corrupt_v9_oam2_fetch_address_is_rejected_not_indexed() {
1210 // `oam2_fetch_addr` is read from an untrusted blob and used as an index
1211 // into `oam_bus_secondary: [u8; 32]`. A hostile or corrupt save state
1212 // carrying >= 32 must be refused at the boundary -- not panic the
1213 // emulator, and not be silently masked into a DIFFERENT valid state.
1214 //
1215 // Reported as a blocking finding on the release PR. The hardware
1216 // counter is 5 bits, so no reachable run can produce such a value; only
1217 // a malformed file can.
1218 let mut p = Ppu::new(PpuRegion::Ntsc);
1219 p.oam2_fetch_addr = 0x1F; // the largest LEGAL value
1220 let good = p.snapshot();
1221 assert!(
1222 Ppu::new(PpuRegion::Ntsc).restore(&good).is_ok(),
1223 "31 is in range and must still load"
1224 );
1225
1226 // Corrupt exactly that byte, leaving the rest of the blob intact.
1227 let idx = good
1228 .iter()
1229 .rposition(|&b| b == 0x1F)
1230 .expect("the fetch-address byte is in the blob");
1231 for bad_value in [32u8, 0x80, 0xFF] {
1232 let mut bad = good.clone();
1233 bad[idx] = bad_value;
1234 match Ppu::new(PpuRegion::Ntsc).restore(&bad) {
1235 Err(PpuSnapshotError::InvalidOam2FetchAddr(v)) => {
1236 assert_eq!(v, bad_value, "the error names the offending byte");
1237 }
1238 Err(e) => panic!("wrong error for {bad_value}: {e}"),
1239 Ok(()) => panic!("restore ACCEPTED an out-of-range fetch address {bad_value}"),
1240 }
1241 }
1242 }
1243
1244 #[test]
1245 fn a_corrupt_sprite_count_is_rejected_not_indexed() {
1246 // Core audit IMP-01. `spr_count` bounds loops over the eight-slot
1247 // sprite arrays, so a restored value above 8 panics on the next dot.
1248 // Locate the byte by DIFFERENCE rather than by searching for a value:
1249 // two snapshots that differ only in `spr_count` differ in exactly one
1250 // byte, and that is the field's offset.
1251 let mut a = Ppu::new(PpuRegion::Ntsc);
1252 a.spr_count = 3;
1253 let mut b = Ppu::new(PpuRegion::Ntsc);
1254 b.spr_count = 8; // the largest LEGAL value
1255 let (sa, sb) = (a.snapshot(), b.snapshot());
1256 let diffs: Vec<usize> = (0..sa.len()).filter(|&i| sa[i] != sb[i]).collect();
1257 assert_eq!(diffs.len(), 1, "spr_count is exactly one byte of the blob");
1258 let idx = diffs[0];
1259 assert!(
1260 Ppu::new(PpuRegion::Ntsc).restore(&sb).is_ok(),
1261 "8 is in range and must still load"
1262 );
1263
1264 for bad_value in [9u8, 0x80, 0xFF] {
1265 let mut bad = sb.clone();
1266 bad[idx] = bad_value;
1267 match Ppu::new(PpuRegion::Ntsc).restore(&bad) {
1268 Err(PpuSnapshotError::InvalidSprCount(v)) => {
1269 assert_eq!(v, bad_value, "the error names the offending byte");
1270 }
1271 Err(e) => panic!("wrong error for {bad_value}: {e}"),
1272 Ok(()) => panic!("restore ACCEPTED an out-of-range sprite count {bad_value}"),
1273 }
1274 }
1275 }
1276
1277 #[test]
1278 fn an_out_of_range_raster_position_is_rejected() {
1279 // Found by the v2.7.0 fuzz target. Each legal extreme loads; one past
1280 // it, on either axis and for both frame heights, is refused.
1281 let decode = |region: PpuRegion, dot: u16, scanline: i16| {
1282 let mut p = Ppu::new(region);
1283 p.dot = dot;
1284 p.scanline = scanline;
1285 let blob = p.snapshot();
1286 Ppu::new(region).restore(&blob)
1287 };
1288 for region in [PpuRegion::Ntsc, PpuRegion::Pal] {
1289 let last = region.prerender_line();
1290 assert!(
1291 decode(region, 340, last).is_ok(),
1292 "{region:?}: the last dot loads"
1293 );
1294 assert!(
1295 decode(region, 340, -1).is_ok(),
1296 "{region:?}: the power-on position loads"
1297 );
1298 for (dot, scanline) in [
1299 (341, 0),
1300 (u16::MAX, 0),
1301 (0, last + 1),
1302 (0, -2),
1303 (0, i16::MIN),
1304 ] {
1305 assert!(
1306 matches!(
1307 decode(region, dot, scanline),
1308 Err(PpuSnapshotError::InvalidRasterPosition { .. })
1309 ),
1310 "{region:?}: dot {dot}, scanline {scanline} must be rejected"
1311 );
1312 }
1313 }
1314 }
1315
1316 #[test]
1317 fn every_ppu_counter_and_index_is_bounded_on_restore() {
1318 // v2.7.0. Each field is located by DIFFERENCE (two snapshots that
1319 // differ only in it), its largest kept value must load, and one past
1320 // it, 0x80 and 0xFF must each be refused with an error naming it.
1321 fn check(name: &'static str, max: u8, set: impl Fn(&mut Ppu, u8)) {
1322 let mut a = Ppu::new(PpuRegion::Ntsc);
1323 let mut b = Ppu::new(PpuRegion::Ntsc);
1324 set(&mut a, max.saturating_sub(1));
1325 set(&mut b, max);
1326 let (sa, sb) = (a.snapshot(), b.snapshot());
1327 let diffs: Vec<usize> = (0..sa.len()).filter(|&i| sa[i] != sb[i]).collect();
1328 assert_eq!(diffs.len(), 1, "{name} is one byte");
1329 let at = diffs[0];
1330 Ppu::new(PpuRegion::Ntsc)
1331 .restore(&sb)
1332 .unwrap_or_else(|e| panic!("{name} = {max} must load: {e}"));
1333 for bad in [max + 1, 0x80u8.max(max + 1), 0xFF] {
1334 let mut blob = sb.clone();
1335 blob[at] = bad;
1336 match Ppu::new(PpuRegion::Ntsc).restore(&blob) {
1337 Err(PpuSnapshotError::FieldOutOfRange {
1338 field,
1339 value,
1340 max: m,
1341 }) => {
1342 assert_eq!((field, value, m), (name, bad, max));
1343 }
1344 Err(e) => panic!("{name} = {bad}: wrong error {e}"),
1345 Ok(()) => panic!("{name} = {bad}: restore ACCEPTED it"),
1346 }
1347 }
1348 }
1349 check("x", 7, |p, v| p.x = v);
1350 check("oam_corruption_index", 0x20, |p, v| {
1351 p.oam_corruption_index = v;
1352 });
1353 check("sprite_eval_n", 63, |p, v| p.sprite_eval_n = v);
1354 check("sprite_eval_m", 3, |p, v| p.sprite_eval_m = v);
1355 check("sprite_eval_found", 8, |p, v| p.sprite_eval_found = v);
1356 check("sprite_eval_sec_idx", 0x20, |p, v| {
1357 p.sprite_eval_sec_idx = v;
1358 });
1359 check("oam_bus_addr_h", 63, |p, v| p.oam_bus_addr_h = v);
1360 check("oam_bus_addr_l", 3, |p, v| p.oam_bus_addr_l = v);
1361 check("oam_bus_secondary_addr", 0x20, |p, v| {
1362 p.oam_bus_secondary_addr = v;
1363 });
1364 check("oam_bus_overflow_counter", 3, |p, v| {
1365 p.oam_bus_overflow_counter = v;
1366 });
1367 check("oam2_addr", 0x1F, |p, v| p.oam2_addr = v);
1368 }
1369
1370 #[test]
1371 fn snapshot_round_trips_the_v9_oam2_counter_at_non_default_values() {
1372 // v9 added three OAM2 fields. The pre-existing round-trip tests leave
1373 // them at their power-on defaults (0 / false / false), so a reader and
1374 // writer that disagree about ORDER still round-trip cleanly: every
1375 // field reads back the value it already had. That is a test which
1376 // passes because nothing was distinguishable, not because anything was
1377 // verified -- the shape this project keeps paying for.
1378 //
1379 // `snapshot_schema_audit` cannot close it either: it checks that every
1380 // field is WRITTEN, never that the reader agrees about where.
1381 //
1382 // So set all three to values distinguishable from the defaults AND from
1383 // each other's types, then assert each one individually.
1384 let mut p = Ppu::new(PpuRegion::Ntsc);
1385 p.oam2_fetch_addr = 0x1B;
1386 p.oam2_overflowed = true;
1387 p.oam2_fetch_frozen = true;
1388
1389 let blob = p.snapshot();
1390 assert_eq!(
1391 blob[0], PPU_SNAPSHOT_VERSION,
1392 "blob carries current version"
1393 );
1394
1395 let mut q = Ppu::new(PpuRegion::Ntsc);
1396 q.restore(&blob).unwrap();
1397 assert_eq!(q.oam2_fetch_addr, 0x1B, "OAM2 fetch address survives");
1398 assert!(q.oam2_overflowed, "the overflow flag survives");
1399 assert!(q.oam2_fetch_frozen, "the latched freeze flag survives");
1400
1401 // The two flags are adjacent bools, so a reader that swapped them would
1402 // pass every assertion above. Pin the asymmetric case too.
1403 let mut r = Ppu::new(PpuRegion::Ntsc);
1404 r.oam2_fetch_addr = 0x07;
1405 r.oam2_overflowed = false;
1406 r.oam2_fetch_frozen = true;
1407 let mut t = Ppu::new(PpuRegion::Ntsc);
1408 t.restore(&r.snapshot()).unwrap();
1409 assert_eq!(t.oam2_fetch_addr, 0x07);
1410 assert!(!t.oam2_overflowed, "overflow flag is NOT the freeze flag");
1411 assert!(t.oam2_fetch_frozen, "freeze flag is NOT the overflow flag");
1412 }
1413
1414 #[test]
1415 fn snapshot_round_trips_the_v6_tail_at_non_default_values() {
1416 // The v6 tail -- per-sprite `spr_halted` plus the six rendering /
1417 // OAM-corruption fields after it -- was round-tripped only at its
1418 // power-on defaults, so a reader that disagreed with the writer about
1419 // ORDER still passed: every field read back the value it already had.
1420 // `snapshot_schema_audit` proves only that each field is written.
1421 //
1422 // So flip ONE field at a time away from its default and compare the
1423 // whole tail after restore. A swapped pair of adjacent bools then
1424 // reads back as two wrong fields instead of two right ones.
1425 type Tail = ([bool; 8], bool, bool, bool, u8, bool, bool);
1426 fn tail(p: &Ppu) -> Tail {
1427 (
1428 p.spr_halted,
1429 p.prev_rendering_enabled,
1430 p.rendering_enabled_delayed,
1431 p.oam_corruption_pending,
1432 p.oam_corruption_index,
1433 p.oam_corruption_disabled,
1434 p.oam_corruption_disabled_instant,
1435 )
1436 }
1437 type Setter = fn(&mut Ppu);
1438 let cases: [(&str, Setter); 14] = [
1439 ("spr_halted[0]", |p| p.spr_halted[0] = false),
1440 ("spr_halted[1]", |p| p.spr_halted[1] = false),
1441 ("spr_halted[2]", |p| p.spr_halted[2] = false),
1442 ("spr_halted[3]", |p| p.spr_halted[3] = false),
1443 ("spr_halted[4]", |p| p.spr_halted[4] = false),
1444 ("spr_halted[5]", |p| p.spr_halted[5] = false),
1445 ("spr_halted[6]", |p| p.spr_halted[6] = false),
1446 ("spr_halted[7]", |p| p.spr_halted[7] = false),
1447 ("prev_rendering_enabled", |p| {
1448 p.prev_rendering_enabled = true;
1449 }),
1450 ("rendering_enabled_delayed", |p| {
1451 p.rendering_enabled_delayed = true;
1452 }),
1453 ("oam_corruption_pending", |p| {
1454 p.oam_corruption_pending = true;
1455 }),
1456 ("oam_corruption_index", |p| p.oam_corruption_index = 0x1F),
1457 ("oam_corruption_disabled", |p| {
1458 p.oam_corruption_disabled = true;
1459 }),
1460 ("oam_corruption_disabled_instant", |p| {
1461 p.oam_corruption_disabled_instant = true;
1462 }),
1463 ];
1464 let default_tail = tail(&Ppu::new(PpuRegion::Ntsc));
1465 for (name, set) in cases {
1466 let mut p = Ppu::new(PpuRegion::Ntsc);
1467 set(&mut p);
1468 let expected = tail(&p);
1469 assert_ne!(
1470 expected, default_tail,
1471 "{name}: the case must change something"
1472 );
1473 let mut q = Ppu::new(PpuRegion::Ntsc);
1474 q.restore(&p.snapshot()).unwrap();
1475 assert_eq!(
1476 tail(&q),
1477 expected,
1478 "{name} did not survive snapshot/restore"
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn snapshot_round_trips_sprite_evaluation_state() {
1485 // v8: a snapshot taken with a sprite-evaluation pass in flight (dots
1486 // 65..=256) must restore the FSM's pointers and phase, not just the
1487 // `secondary_oam` buffer they fill. Before this tail, run-ahead's
1488 // per-frame snapshot/restore silently reset the walker while keeping the
1489 // buffer, costing three AccuracyCoin tests on the desktop frontend.
1490 let mut p = Ppu::new(PpuRegion::Ntsc);
1491 p.sprite_eval_read_latch = 0x5A;
1492 p.sprite_eval_n = 37;
1493 p.sprite_eval_m = 2;
1494 p.sprite_eval_found = 6;
1495 p.sprite_eval_sec_idx = 25;
1496 p.sprite_eval_copying = true;
1497 p.sprite_eval_done = false;
1498 p.sprite_eval_overflow_search = true;
1499 p.sprite_eval_zero_found = true;
1500 p.sprite_eval_first_iter = false;
1501 p.oam_bus_copybuffer = 0xC3;
1502 p.oam_bus_secondary = [0x11; 32];
1503 p.oam_bus_secondary[7] = 0x99;
1504 p.oam_bus_addr_h = 41;
1505 p.oam_bus_addr_l = 3;
1506 p.oam_bus_secondary_addr = 18;
1507 p.oam_bus_copy_done = true;
1508 p.oam_bus_sprite_in_range = true;
1509 // 2, not the 5 this test used before v2.7.0: the running PPU loads the
1510 // counter with 3 and counts down, and restore now rejects anything
1511 // above 3. 2 is still distinct from every neighbouring field.
1512 p.oam_bus_overflow_counter = 2;
1513 p.oam2_addr = 12;
1514
1515 let blob = p.snapshot();
1516 assert_eq!(
1517 blob[0], PPU_SNAPSHOT_VERSION,
1518 "blob carries current version"
1519 );
1520
1521 let mut q = Ppu::new(PpuRegion::Ntsc);
1522 q.restore(&blob).unwrap();
1523 assert_eq!(q.sprite_eval_read_latch, 0x5A);
1524 assert_eq!(q.sprite_eval_n, 37);
1525 assert_eq!(q.sprite_eval_m, 2);
1526 assert_eq!(q.sprite_eval_found, 6);
1527 assert_eq!(q.sprite_eval_sec_idx, 25);
1528 assert!(q.sprite_eval_copying);
1529 assert!(!q.sprite_eval_done);
1530 assert!(q.sprite_eval_overflow_search);
1531 assert!(q.sprite_eval_zero_found);
1532 assert!(!q.sprite_eval_first_iter);
1533 assert_eq!(q.oam_bus_copybuffer, 0xC3);
1534 assert_eq!(q.oam_bus_secondary[7], 0x99);
1535 assert_eq!(q.oam_bus_secondary[0], 0x11);
1536 assert_eq!(q.oam_bus_addr_h, 41);
1537 assert_eq!(q.oam_bus_addr_l, 3);
1538 assert_eq!(q.oam_bus_secondary_addr, 18);
1539 assert!(q.oam_bus_copy_done);
1540 assert!(q.oam_bus_sprite_in_range);
1541 assert_eq!(q.oam_bus_overflow_counter, 2);
1542 assert_eq!(q.oam2_addr, 12);
1543 }
1544
1545 #[test]
1546 fn restore_invalidates_the_scanline_classification_cache() {
1547 // The cache is derived (a pure function of `scanline` + `region`) and so
1548 // deliberately NOT serialized. Restore must therefore reset its KEY to
1549 // the `Ppu::new` sentinel: a warm key inherited from another timeline
1550 // would otherwise satisfy the fast dot path's
1551 // `scanline == flags_cached_scanline` guard against a stale value.
1552 let p = Ppu::new(PpuRegion::Ntsc);
1553 let blob = p.snapshot();
1554
1555 let mut q = Ppu::new(PpuRegion::Ntsc);
1556 q.cached_visible = true;
1557 q.cached_pre_render = true;
1558 q.cached_render_line = true;
1559 q.flags_cached_scanline = 42;
1560 q.restore(&blob).unwrap();
1561 assert!(!q.cached_visible);
1562 assert!(!q.cached_pre_render);
1563 assert!(!q.cached_render_line);
1564 assert_eq!(q.flags_cached_scanline, i16::MIN);
1565 }
1566
1567 #[test]
1568 fn snapshot_round_trips_extra_lines_remaining() {
1569 // v1.7.0 F3: a save-state taken mid-insertion (extra_lines_remaining
1570 // > 0) must restore the in-flight countdown, not reset it to 0.
1571 let mut p = Ppu::new(PpuRegion::Ntsc);
1572 p.set_extra_scanlines(8);
1573 p.extra_lines_remaining = 5;
1574 let blob = p.snapshot();
1575 assert_eq!(
1576 blob[0], PPU_SNAPSHOT_VERSION,
1577 "blob carries current version"
1578 );
1579
1580 let mut q = Ppu::new(PpuRegion::Ntsc);
1581 q.restore(&blob).unwrap();
1582 assert_eq!(q.extra_lines_remaining, 5);
1583 }
1584
1585 #[test]
1586 fn a_restored_extra_lines_countdown_is_clamped_to_the_live_knob() {
1587 // Review finding on #546: a corrupt countdown under a live
1588 // extra-scanlines knob idled the PPU for up to 65,535 lines.
1589 let mut p = Ppu::new(PpuRegion::Ntsc);
1590 p.set_extra_scanlines(8);
1591 p.extra_lines_remaining = u16::MAX;
1592 let blob = p.snapshot();
1593
1594 let mut q = Ppu::new(PpuRegion::Ntsc);
1595 q.set_extra_scanlines(8);
1596 q.restore(&blob).unwrap();
1597 assert_eq!(q.extra_lines_remaining, 8, "clamped to the knob in force");
1598
1599 // A countdown already inside the knob is untouched.
1600 p.extra_lines_remaining = 5;
1601 let mut r = Ppu::new(PpuRegion::Ntsc);
1602 r.set_extra_scanlines(8);
1603 r.restore(&p.snapshot()).unwrap();
1604 assert_eq!(r.extra_lines_remaining, 5);
1605 }
1606
1607 #[test]
1608 fn snapshot_round_trips_2cycle_ale_fetch_state() {
1609 // v2.0.3 (ADR 0030): a checkpoint taken mid-render (netplay rollback)
1610 // can land with the 2-cycle-ALE fetch model's in-flight state live —
1611 // the octal latch / multiplexed bus, the ALE arm, and the two corruption
1612 // one-shots (`pattern_latch_stale`, the delayed-`CopyV` `copy_v_delay`).
1613 // They must round-trip so the re-simulated frame is byte-identical to the
1614 // forward run (else the peers desync). Regression pin for the promotion.
1615 let mut p = Ppu::new(PpuRegion::Ntsc);
1616 p.octal_latch = 0x19;
1617 p.address_bus = 0x2F19;
1618 p.ale_armed = true;
1619 p.pattern_latch_stale = true;
1620 p.copy_v_delay = 3;
1621 let blob = p.snapshot();
1622
1623 let mut q = Ppu::new(PpuRegion::Ntsc);
1624 q.restore(&blob).unwrap();
1625 assert_eq!(q.octal_latch, 0x19);
1626 assert_eq!(q.address_bus, 0x2F19);
1627 assert!(q.ale_armed);
1628 assert!(q.pattern_latch_stale);
1629 assert_eq!(q.copy_v_delay, 3);
1630 }
1631
1632 /// v2.3.3 — a slim blob must be dramatically smaller, and the size
1633 /// difference must be exactly the framebuffer.
1634 #[test]
1635 fn slim_snapshot_omits_exactly_the_framebuffer() {
1636 let p = Ppu::new(PpuRegion::Ntsc);
1637 let full = p.snapshot();
1638 let slim = p.snapshot_slim();
1639 assert_eq!(
1640 full.len() - slim.len(),
1641 FRAMEBUFFER_LEN,
1642 "slim must differ from full by exactly the framebuffer"
1643 );
1644 }
1645
1646 /// A slim blob restores every field EXCEPT the framebuffer, which it must
1647 /// leave untouched (the caller regenerates the image).
1648 #[test]
1649 fn slim_restore_preserves_the_existing_framebuffer() {
1650 let mut src = Ppu::new(PpuRegion::Ntsc);
1651 src.framebuffer[100] = 0xAB;
1652 src.spr_count = 5;
1653 let slim = src.snapshot_slim();
1654
1655 let mut dst = Ppu::new(PpuRegion::Ntsc);
1656 dst.framebuffer[100] = 0xEF; // must survive the restore
1657 dst.spr_count = 0;
1658 dst.restore(&slim).expect("slim blob restores");
1659 assert_eq!(dst.spr_count, 5, "non-framebuffer state must restore");
1660 assert_eq!(
1661 dst.framebuffer[100], 0xEF,
1662 "slim restore must not touch the framebuffer"
1663 );
1664 }
1665
1666 /// The flag must not disturb the existing format: a full blob still round
1667 /// trips its framebuffer, and its version byte has the flag clear.
1668 #[test]
1669 fn full_snapshot_is_unchanged_by_the_slim_flag() {
1670 let mut src = Ppu::new(PpuRegion::Ntsc);
1671 src.framebuffer[100] = 0xAB;
1672 let full = src.snapshot();
1673 assert_eq!(full[0] & PPU_SNAPSHOT_SLIM_FLAG, 0, "flag clear on full");
1674 assert_eq!(full[0], PPU_SNAPSHOT_VERSION);
1675 let mut dst = Ppu::new(PpuRegion::Ntsc);
1676 dst.restore(&full).expect("full blob restores");
1677 assert_eq!(dst.framebuffer[100], 0xAB);
1678 }
1679
1680 /// A truncated slim blob must still be rejected — the relaxed minimum
1681 /// must not become a hole that accepts garbage.
1682 #[test]
1683 fn truncated_slim_blob_is_still_rejected() {
1684 let p = Ppu::new(PpuRegion::Ntsc);
1685 let slim = p.snapshot_slim();
1686 let mut dst = Ppu::new(PpuRegion::Ntsc);
1687 assert!(dst.restore(&slim[..slim.len() / 2]).is_err());
1688 }
1689
1690 /// NC-14 (v2.9.9 re-audit): a blob with a byte appended is refused as
1691 /// trailing bytes, not as a truncation at its own end offset.
1692 #[test]
1693 fn a_long_blob_is_reported_as_trailing_bytes() {
1694 let p = Ppu::new(PpuRegion::Ntsc);
1695 let mut long = p.snapshot();
1696 let len = long.len();
1697 long.push(0);
1698 let mut dst = Ppu::new(PpuRegion::Ntsc);
1699 let err = dst.restore(&long).unwrap_err();
1700 assert!(
1701 matches!(err, PpuSnapshotError::TrailingBytes { consumed, extra: 1 } if consumed == len),
1702 "got {err:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn snapshot_round_trips_oam_decay_ages() {
1708 // v2.1.4 F2.3: the optional OAM-decay per-row timestamps must round-trip.
1709 // They are stored as a RELATIVE AGE against the un-serialized `dot_counter`,
1710 // so the exact absolute value is preserved only when the restoring instance
1711 // shares the same live counter (the normal same-instance snapshot/restore).
1712 let mut p = Ppu::new(PpuRegion::Ntsc);
1713 p.set_oam_decay(true);
1714 p.dot_counter = 90_000; // now = 30_000 CPU cycles
1715 // Distinct per-row timestamps (ages 0, 3, 6, ... spread across the array).
1716 for (i, ts) in p.oam_decay_cycles.iter_mut().enumerate() {
1717 *ts = 30_000 - (i as u64 * 3);
1718 }
1719 let blob = p.snapshot();
1720 assert_eq!(
1721 blob[0], PPU_SNAPSHOT_VERSION,
1722 "blob carries current version"
1723 );
1724
1725 // Restore into an instance with the SAME live counter → exact timestamps.
1726 let mut q = Ppu::new(PpuRegion::Pal);
1727 q.dot_counter = 90_000;
1728 q.restore(&blob).unwrap();
1729 assert_eq!(
1730 q.oam_decay_cycles, p.oam_decay_cycles,
1731 "absolute timestamps preserved when the live counter matches"
1732 );
1733
1734 // Restore into an instance whose counter has been REBASED (the rollback
1735 // case): the *relative age* — what the decay clock actually consumes — must
1736 // be identical, even though the absolute timestamps differ.
1737 let mut q2 = Ppu::new(PpuRegion::Ntsc);
1738 q2.dot_counter = 3 * 1_000_000; // a wildly different base (now = 1_000_000)
1739 q2.restore(&blob).unwrap();
1740 let now_p = p.dot_counter / 3;
1741 let now_q2 = q2.dot_counter / 3;
1742 for i in 0..32 {
1743 let age_p = now_p.wrapping_sub(p.oam_decay_cycles[i]);
1744 let age_q2 = now_q2.wrapping_sub(q2.oam_decay_cycles[i]);
1745 assert_eq!(age_p, age_q2, "row {i} age preserved across counter rebase");
1746 }
1747 }
1748
1749 #[test]
1750 fn snapshot_default_extra_lines_remaining_is_zero() {
1751 // At the default extra_scanlines == 0 the countdown is always 0, so
1752 // the v4 field is a zero u16 and restore is behaviourally identical.
1753 let p = Ppu::new(PpuRegion::Ntsc);
1754 assert_eq!(p.extra_lines_remaining, 0);
1755 let blob = p.snapshot();
1756 let mut q = Ppu::new(PpuRegion::Ntsc);
1757 q.restore(&blob).unwrap();
1758 assert_eq!(q.extra_lines_remaining, 0);
1759 }
1760
1761 #[test]
1762 fn set_extra_scanlines_resets_in_flight_countdown() {
1763 // Changing the configured count cancels any in-flight insertion so
1764 // the per-frame countdown cannot remain stale/out-of-bounds.
1765 let mut p = Ppu::new(PpuRegion::Ntsc);
1766 p.set_extra_scanlines(8);
1767 p.extra_lines_remaining = 6;
1768 p.set_extra_scanlines(2);
1769 assert_eq!(p.extra_lines_remaining, 0);
1770 p.extra_lines_remaining = 1;
1771 p.set_extra_scanlines(0); // disable
1772 assert_eq!(p.extra_lines_remaining, 0);
1773 }
1774}