Skip to main content

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}