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//!   v1 blobs are upconverted on read.)
26//! - `ex_attr_latch` (presence u8 + `palette` u8 + `chr_bank` u16)
27//! - `bg_split_latch` (presence u8 + `nt_addr` u16 + `at_addr` u16 + `fine_y` u8 + `chr_bank` u8)
28//! - sprite arrays: 8× `shift_lo` / `shift_hi` / `attr` / `x` / `spr_count` u8 / `spr_zero_in_line` bool
29//! - `256*240*4` framebuffer bytes
30
31use alloc::vec::Vec;
32use thiserror::Error;
33
34use crate::bus::{BgSplitState, ExAttribute};
35use crate::ppu::{FRAMEBUFFER_LEN, Ppu, PpuRegion};
36use crate::registers::{PpuCtrl, PpuMask, PpuStatus};
37
38/// Schema version for the PPU snapshot blob.
39///
40/// - v1: 8-bit `at_shift_lo`/`at_shift_hi` + 1-bit `at_feed_lo`/`at_feed_hi`.
41/// - v2: 16-bit `at_shift_lo`/`at_shift_hi` (lockstep with the pattern
42///   shifters); the feed fields are gone. v1 blobs are still read.
43/// - v3 (W3-Stage-4 promotion, 2026-06-10): appends the
44///   `mc-ppu-2007-render-buffer` rendering-time `$2007` PPUDATA
45///   state-machine tail — `render_data_bus`, `ppudata_sm_countdown`,
46///   `ppudata_v_inc_pending`, the raw (pre-h-flip) sprite pattern fetch
47///   bytes, and the slot-0 garbage-NT ALE latch. Written unconditionally
48///   (zeros when the feature is off) so the layout is identical across
49///   feature builds; v1/v2 blobs upconvert with the tail at the inactive
50///   defaults (the state the old clear-on-restore assumption imposed).
51/// - v4 (v1.7.0 F3, 2026-06-18): appends the `extra_lines_remaining`
52///   countdown for the in-flight PPU extra-scanlines overclock insertion.
53///   At the default `extra_scanlines == 0` this is always `0`, so the
54///   blob merely gains a zero `u16` and restore is behaviourally identical
55///   to v3; a non-default countdown taken mid-insertion now round-trips
56///   instead of restoring as `0` (which desynced). v1/v2/v3 blobs upconvert
57///   with `extra_lines_remaining = 0` (no insertion in flight).
58/// - v5 (v2.0.3, ADR 0030): appends the 2-cycle-ALE fetch model's in-flight
59///   multiplexed-bus / octal-latch state — `octal_latch`, `address_bus`,
60///   `ale_armed`, `pattern_latch_stale`, and the delayed-`CopyV` `copy_v_delay`
61///   countdown. These fields carry a background fetch's ALE→read state and the
62///   two modeled `$2006`/`$2007` corruption one-shots; a mid-render save/restore
63///   (netplay rollback checkpoints, TAS/save-states) that landed with any of
64///   them live restored them to the wrong value and desynced the re-simulated
65///   framebuffer — exactly the class of bug the v3/v4 tails already fixed for
66///   the `$2007` state machine and the overclock countdown. This is an ADDITIVE
67///   save-state format change (a `.rns` gains the tail): v1..=4 blobs upconvert
68///   with all five at their inactive defaults (`0`/`false`), i.e. the
69///   "no fetch in flight" rest state, which is correct for any pre-v5 save taken
70///   at rest. Not a *load-break* — a pre-v5 `.rns` still restores.
71/// - v6 (v2.1.1, "Fathom"): appends the per-sprite shifter-halt flags
72///   (`spr_halted[8]`), the 1-dot-delayed rendering gate
73///   (`prev_rendering_enabled` / `rendering_enabled_delayed`), and the
74///   OAM-corruption arming state (`oam_corruption_pending`,
75///   `oam_corruption_index`, `oam_corruption_disabled`,
76///   `oam_corruption_disabled_instant`). These fields were previously not
77///   serialized; a frontend run-ahead `snapshot`/`restore` round-trip
78///   landing with any of them live (mid-frame sprite-0 split, OAM
79///   corruption arming) would restore stale constructor values and corrupt
80///   rendering — the Wizards & Warriors half-blank playfield / stalled
81///   audio class. v1..=5 blobs upconvert with `spr_halted = [true; 8]`
82///   (halted = power-on default) and all others at `false`/`0`. Not a
83///   *load-break* — a pre-v6 `.rns` still restores.
84/// - v7 (v2.1.4 F2.3): appends the optional OAM-decay model's per-8-byte-row
85///   last-touch timestamps (`oam_decay_cycles[32]`), stored as a **relative age**
86///   (`now - timestamp` in CPU cycles) rather than the raw absolute value. The
87///   absolute timestamps reference the free-running `dot_counter`, which is
88///   deliberately NOT part of the save-state (it is cosmetic / re-derived); a raw
89///   absolute value would be meaningless after a rollback/restore rebased that
90///   counter, and would desync the decay clock. Encoding the age and reconstructing
91///   `now - age` against the *live* counter on load makes a run-ahead / netplay
92///   `snapshot`→`restore` byte-identical to the forward run (the property the
93///   v3/v4/v5/v6 tails secured for the other mid-frame state). The enable flag
94///   itself is a frontend/config knob (re-applied on load like `region` /
95///   `active_palette`), so — matching the `extra_scanlines` precedent — it is NOT
96///   serialized; only the in-flight ages are. At the default (decay off) the ages
97///   are inert dead state, so the blob merely grows by 256 bytes and restore is
98///   behaviourally identical. v1..=6 blobs upconvert by stamping every row as
99///   freshly-touched at the live cycle (age 0), i.e. the rest state — correct for
100///   any pre-v7 save. Not a *load-break* — a pre-v7 `.rns` still restores.
101/// - v8 (run-ahead sprite-evaluation fix): appends the per-dot **sprite-evaluation
102///   FSM** (`sprite_eval_read_latch`, `_n`, `_m`, `_found`, `_sec_idx`,
103///   `_copying`, `_done`, `_overflow_search`, `_zero_found`, `_first_iter`), the
104///   parallel **OAM-data-bus model** (`oam_bus_copybuffer`, `oam_bus_secondary[32]`,
105///   `oam_bus_addr_h`, `oam_bus_addr_l`, `oam_bus_secondary_addr`,
106///   `oam_bus_copy_done`, `oam_bus_sprite_in_range`, `oam_bus_overflow_counter`),
107///   and the secondary-OAM clear-window write pointer `oam2_addr`. 50 bytes.
108///
109///   These are the mid-scanline working registers of dots 65..=256: the eval pass
110///   walks primary OAM through `_n`/`_m`, stages each byte through
111///   `sprite_eval_read_latch`, and commits in-range sprites into `secondary_oam`
112///   at `_sec_idx`. `secondary_oam` itself was already serialized — but the
113///   *pointers and phase* driving it were not, so a snapshot taken with an eval
114///   pass in flight restored a full secondary-OAM buffer alongside a
115///   power-on-default FSM. The frontend's run-ahead (`snapshot_core_into` →
116///   N frames → `restore_quiet`, every visible frame, and ON BY DEFAULT at
117///   `run_ahead = 1`) hit this every frame: the hidden frames advanced the FSM
118///   and the rollback left that advance behind. Measured effect on the
119///   `AccuracyCoin` battery: 141/141 headless but 138/141 through the desktop
120///   frontend, failing exactly `Sprite Evaluation :: Arbitrary Sprite zero`
121///   (error 2), `Sprite Evaluation :: Misaligned OAM behavior` (error 1), and
122///   `PPU Behavior :: Rendering Flag Behavior` (error 2). Same bug class as the
123///   v6 tail (which closed the Wizards & Warriors half-blank playfield), a
124///   different uncovered field set.
125///
126///   Mesen2 serializes the same set — `_spriteIndex`, `_sprite0Added`,
127///   `_sprite0Visible`, `_oamCopybuffer`, `_secondaryOamAddr`, `_spriteInRange`,
128///   `_oamCopyDone`, `_overflowBugCounter` (`Core/NES/NesPpu.cpp`
129///   `NesPpu<T>::Serialize`) — independent confirmation that this is live state,
130///   not derived.
131///
132///   v1..=7 blobs upconvert to the constructor defaults (`0xFF` for the two read
133///   latches, `[0xFF; 32]` for the parallel secondary OAM, `0`/`false` elsewhere)
134///   — the at-rest state, and exactly what a pre-v8 restore left behind. NOTE
135///   that `rustynes_core`'s `.rns` container is version-EXACT per section, so an
136///   existing save state fails to load with a clear `VersionMismatch` rather than
137///   silently misreading (ADR 0028); the in-function upconvert path serves direct
138///   `Ppu::restore` callers.
139///
140///   Also on restore (ALL versions): the scanline-classification cache
141///   (`cached_visible` / `cached_pre_render` / `cached_render_line`, keyed by
142///   `flags_cached_scanline`) is INVALIDATED rather than serialized. It is a pure
143///   function of `scanline` + `region`, both of which are serialized, so
144///   recomputing it is equivalent and cheaper than carrying derived bytes — the
145///   same choice Mesen2 makes in its `if(!s.IsSaving())` post-load fixup block.
146pub const PPU_SNAPSHOT_VERSION: u8 = 8;
147
148/// v2.3.3 — high bit of the version byte, marking a **slim** snapshot: every
149/// field except the 245,760-byte framebuffer.
150///
151/// Exists for the rewind ring, which snapshots on *every* frame inside the
152/// frame budget and then XORs and LZ4-compresses the result. The framebuffer
153/// is 94% of those bytes and the worst possible payload for that scheme — it
154/// changes every frame, so the XOR never zeroes and the delta never
155/// compresses. Measured, rewind roughly doubled the produce-interval p95
156/// (31.17 ms against 17.12 ms with it off) and was the cause of a
157/// user-visible judder report; see `docs/performance.md` v2.3.3 F3/F4.
158///
159/// Encoded as a flag on the version byte rather than a new version number so
160/// the on-disk format is untouched: every `.rns` ever written has the high bit
161/// clear and still parses on exactly the path it always did. Slim snapshots
162/// are in-memory only and are never written to a file.
163///
164/// A slim blob restores every other field and **leaves the framebuffer
165/// untouched**, so the caller is responsible for producing an image — see
166/// `Nes::rewind_step_back`, which runs one frame to regenerate it.
167pub const PPU_SNAPSHOT_SLIM_FLAG: u8 = 0x80;
168
169const CIRAM_LEN: usize = 0x800;
170const OAM_LEN: usize = 0x100;
171const SEC_OAM_LEN: usize = 32;
172const PAL_LEN: usize = 32;
173
174/// Errors returned by [`Ppu::restore`].
175#[derive(Debug, Error)]
176#[non_exhaustive]
177pub enum PpuSnapshotError {
178    /// Blob is too short for the version-1 schema.
179    #[error("PPU snapshot truncated at offset {0}")]
180    Truncated(usize),
181    /// The blob's version byte is not understood by this build.
182    #[error("PPU snapshot unsupported version {0}")]
183    UnsupportedVersion(u8),
184    /// Region tag was not one of `0` (NTSC), `1` (PAL), `2` (Dendy).
185    #[error("PPU snapshot has invalid region tag {0}")]
186    InvalidRegion(u8),
187    /// Optional struct presence byte was something other than 0 or 1.
188    #[error("PPU snapshot has invalid optional presence byte {0}")]
189    InvalidPresence(u8),
190}
191
192const fn region_to_u8(r: PpuRegion) -> u8 {
193    match r {
194        PpuRegion::Ntsc => 0,
195        PpuRegion::Pal => 1,
196        PpuRegion::Dendy => 2,
197    }
198}
199
200const fn region_from_u8(v: u8) -> Result<PpuRegion, PpuSnapshotError> {
201    match v {
202        0 => Ok(PpuRegion::Ntsc),
203        1 => Ok(PpuRegion::Pal),
204        2 => Ok(PpuRegion::Dendy),
205        other => Err(PpuSnapshotError::InvalidRegion(other)),
206    }
207}
208
209struct W {
210    buf: Vec<u8>,
211}
212impl W {
213    fn u8(&mut self, v: u8) {
214        self.buf.push(v);
215    }
216    fn u16(&mut self, v: u16) {
217        self.buf.extend_from_slice(&v.to_le_bytes());
218    }
219    fn u32(&mut self, v: u32) {
220        self.buf.extend_from_slice(&v.to_le_bytes());
221    }
222    fn u64(&mut self, v: u64) {
223        self.buf.extend_from_slice(&v.to_le_bytes());
224    }
225    fn i16(&mut self, v: i16) {
226        self.buf.extend_from_slice(&v.to_le_bytes());
227    }
228    fn bytes(&mut self, v: &[u8]) {
229        self.buf.extend_from_slice(v);
230    }
231}
232
233struct R<'a> {
234    src: &'a [u8],
235    pos: usize,
236}
237impl R<'_> {
238    const fn need(&self, n: usize) -> Result<(), PpuSnapshotError> {
239        if self.src.len() - self.pos < n {
240            return Err(PpuSnapshotError::Truncated(self.pos));
241        }
242        Ok(())
243    }
244    fn u8(&mut self) -> Result<u8, PpuSnapshotError> {
245        self.need(1)?;
246        let v = self.src[self.pos];
247        self.pos += 1;
248        Ok(v)
249    }
250    fn u16(&mut self) -> Result<u16, PpuSnapshotError> {
251        self.need(2)?;
252        let v = u16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
253        self.pos += 2;
254        Ok(v)
255    }
256    fn u32(&mut self) -> Result<u32, PpuSnapshotError> {
257        self.need(4)?;
258        let mut a = [0u8; 4];
259        a.copy_from_slice(&self.src[self.pos..self.pos + 4]);
260        self.pos += 4;
261        Ok(u32::from_le_bytes(a))
262    }
263    fn u64(&mut self) -> Result<u64, PpuSnapshotError> {
264        self.need(8)?;
265        let mut a = [0u8; 8];
266        a.copy_from_slice(&self.src[self.pos..self.pos + 8]);
267        self.pos += 8;
268        Ok(u64::from_le_bytes(a))
269    }
270    fn i16(&mut self) -> Result<i16, PpuSnapshotError> {
271        self.need(2)?;
272        let v = i16::from_le_bytes([self.src[self.pos], self.src[self.pos + 1]]);
273        self.pos += 2;
274        Ok(v)
275    }
276    fn bytes_into(&mut self, dst: &mut [u8]) -> Result<(), PpuSnapshotError> {
277        self.need(dst.len())?;
278        dst.copy_from_slice(&self.src[self.pos..self.pos + dst.len()]);
279        self.pos += dst.len();
280        Ok(())
281    }
282}
283
284impl Ppu {
285    /// Encode the PPU's mutable state into a versioned binary blob.
286    // A flat, linear field-by-field encoder with per-version tail appends (v1
287    // through v8); splitting it would only scatter the schema that is clearest read
288    // top-to-bottom against the matching `restore` reader.
289    #[allow(clippy::too_many_lines)]
290    #[must_use]
291    pub fn snapshot(&self) -> Vec<u8> {
292        self.snapshot_with(false)
293    }
294
295    /// v2.3.3 — [`Ppu::snapshot`] without the framebuffer. See
296    /// [`PPU_SNAPSHOT_SLIM_FLAG`].
297    #[must_use]
298    pub fn snapshot_slim(&self) -> Vec<u8> {
299        self.snapshot_with(true)
300    }
301
302    /// Shared body writer for the full and slim encodings.
303    // One long straight-line writer: every field in schema order. Splitting it
304    // would make the field-order correspondence with the reader (and with the
305    // ADR 0034 schema audit, which parses this body) harder to verify, which is
306    // the opposite of what this code needs.
307    #[allow(clippy::too_many_lines)]
308    fn snapshot_with(&self, slim: bool) -> Vec<u8> {
309        // Capacity hint: ~256 KiB framebuffer dominates the full encoding;
310        // the slim one is ~4 KiB, so don't reserve for a buffer it omits.
311        let mut w = W {
312            buf: Vec::with_capacity(if slim { 4096 } else { FRAMEBUFFER_LEN + 4096 }),
313        };
314        w.u8(if slim {
315            PPU_SNAPSHOT_VERSION | PPU_SNAPSHOT_SLIM_FLAG
316        } else {
317            PPU_SNAPSHOT_VERSION
318        });
319        w.u8(region_to_u8(self.region));
320
321        w.u8(self.ctrl.bits());
322        w.u8(self.mask.bits());
323        w.u8(self.mask_for_skip_check.bits());
324        w.u8(self.mask_skip_pipe1.bits());
325        w.u8(self.status.bits());
326
327        w.u8(self.oam_addr);
328        w.u8(self.data_buffer);
329        w.u16(self.v);
330        w.u16(self.t);
331        w.u8(self.x);
332        w.u8(u8::from(self.w));
333
334        // Memory blocks at fixed sizes — no length prefix needed (versioned schema).
335        w.bytes(&self.ciram);
336        w.bytes(&self.oam);
337        w.bytes(&self.secondary_oam);
338        w.bytes(&self.palette_ram);
339
340        w.u8(self.open_bus);
341        for d in self.open_bus_decay {
342            w.u32(d);
343        }
344
345        w.u8(u8::from(self.nmi_line));
346        w.u8(u8::from(self.suppress_vbl_this_frame));
347        w.u8(u8::from(self.last_a12_level));
348
349        w.u16(self.dot);
350        w.i16(self.scanline);
351        w.u64(self.frame);
352        w.u8(u8::from(self.frame_complete));
353
354        w.u32(self.post_reset_mask_remaining);
355
356        w.u8(self.nt_latch);
357        w.u8(self.at_latch);
358        w.u8(self.bg_lo_latch);
359        w.u8(self.bg_hi_latch);
360        w.u16(self.bg_shift_lo);
361        w.u16(self.bg_shift_hi);
362        // v2: attribute shift registers widened to 16-bit (lockstep with
363        // the pattern shifters); the v1 1-bit `at_feed_*` fields are gone.
364        w.u16(self.at_shift_lo);
365        w.u16(self.at_shift_hi);
366
367        if let Some(ex) = self.ex_attr_latch {
368            w.u8(1);
369            w.u8(ex.palette);
370            w.u16(ex.chr_bank);
371        } else {
372            w.u8(0);
373            w.u8(0);
374            w.u16(0);
375        }
376        if let Some(s) = self.bg_split_latch {
377            w.u8(1);
378            w.u16(s.nt_addr);
379            w.u16(s.at_addr);
380            w.u8(s.fine_y);
381            w.u8(s.chr_bank);
382        } else {
383            w.u8(0);
384            w.u16(0);
385            w.u16(0);
386            w.u8(0);
387            w.u8(0);
388        }
389
390        w.bytes(&self.spr_shift_lo);
391        w.bytes(&self.spr_shift_hi);
392        w.bytes(&self.spr_attr);
393        w.bytes(&self.spr_x);
394        w.u8(self.spr_count);
395        w.u8(u8::from(self.spr_zero_in_line));
396
397        if !slim {
398            w.bytes(&self.framebuffer);
399        }
400
401        // v3 (W3-Stage-4): the `mc-ppu-2007-render-buffer` PPUDATA
402        // state-machine tail. Written unconditionally (zeros when the
403        // feature is off) so the blob layout is feature-independent.
404        {
405            w.u8(self.render_data_bus);
406            w.u8(self.ppudata_sm_countdown);
407            w.u8(u8::from(self.ppudata_v_inc_pending));
408            w.bytes(&self.spr_fetch_lo_raw);
409            w.bytes(&self.spr_fetch_hi_raw);
410            w.u16(self.ppudata_spr0_nt_addr);
411        }
412        // v3 (W3-Stage-4): the `mc-ppu-subpos` BG-reload freeze (the
413        // `$2001`-write-commit delay that injects the BG serial-in '1's).
414        // `bg_reload_render` re-syncs from the live mask when settled, but an
415        // in-flight `mask_write_delay` countdown can straddle an instruction
416        // boundary — serialize both so a restored state resumes the freeze
417        // exactly.
418        {
419            w.u8(u8::from(self.bg_reload_render));
420            w.u8(self.mask_write_delay);
421        }
422
423        // v4 (v1.7.0 F3): the in-flight extra-scanlines overclock countdown.
424        // Always `0` at the default `extra_scanlines == 0`, so this is a
425        // zero `u16` in the stock build (no behavioural change). The
426        // configured count itself (`extra_scanlines`) stays a frontend knob
427        // re-applied on restore, like `region` / `active_palette`.
428        w.u16(self.extra_lines_remaining);
429
430        // v5 (v2.0.3, ADR 0030): the 2-cycle-ALE fetch model's in-flight
431        // multiplexed-bus / octal-latch state. All at rest (`0`/`false`) at a
432        // clean fetch boundary, but a mid-render checkpoint (netplay rollback)
433        // can land with `copy_v_delay`/`pattern_latch_stale`/`ale_armed` live —
434        // serialize them (plus the latch + bus they splice through) so the
435        // re-simulated frame is byte-identical to the forward run.
436        {
437            w.u8(self.octal_latch);
438            w.u16(self.address_bus);
439            w.u8(u8::from(self.ale_armed));
440            w.u8(u8::from(self.pattern_latch_stale));
441            w.u8(self.copy_v_delay);
442        }
443
444        // v6 (W&W run-ahead fix): the per-sprite shifter HALT state. Set by the
445        // v2.0 sprite-shifter-counter model (a loaded-but-halted slot draws
446        // immediately on re-enable), it persists across the frame boundary and
447        // governs whether each of the 8 loaded sprites emits — so it is genuine
448        // rendering state. It was previously unserialized, so a per-frame
449        // save/restore (run-ahead, netplay rollback) drifted it: for a game that
450        // toggles rendering mid-frame (Wizards & Warriors' sprite-0 status-bar
451        // split), the drift accumulated into dropped/blinking sprites and a
452        // half-rendered playfield. `true` (halted) is the power-on default.
453        for h in &self.spr_halted {
454            w.u8(u8::from(*h));
455        }
456        // v6 (cont.) — remaining unserialized cross-frame render state.
457        w.u8(u8::from(self.prev_rendering_enabled));
458        w.u8(u8::from(self.rendering_enabled_delayed));
459        w.u8(u8::from(self.oam_corruption_pending));
460        w.u8(self.oam_corruption_index);
461        w.u8(u8::from(self.oam_corruption_disabled));
462        w.u8(u8::from(self.oam_corruption_disabled_instant));
463
464        // v7 (v2.1.4 F2.3): the optional OAM-decay model's per-row last-touch
465        // timestamps, stored as a RELATIVE AGE (`now - timestamp`) so the decay
466        // clock survives a rollback/restore that rebases the un-serialized
467        // free-running `dot_counter`. `now` is the live CPU cycle (`dot_counter / 3`,
468        // NTSC/Dendy divisor). `wrapping_sub` keeps the encode total even across the
469        // (astronomically unlikely) u64 wrap. When decay is off these ages are inert
470        // (never read on restore's decay path), but they still round-trip exactly.
471        let now = self.dot_counter / 3;
472        for ts in self.oam_decay_cycles {
473            w.u64(now.wrapping_sub(ts));
474        }
475
476        // v8: the per-dot sprite-evaluation FSM + the parallel OAM-data-bus
477        // model + the clear-window secondary-OAM write pointer. `secondary_oam`
478        // (the buffer) was always serialized; these are the POINTERS AND PHASE
479        // that fill it, and without them a mid-eval snapshot restored a full
480        // buffer next to a reset walker. See the `PPU_SNAPSHOT_VERSION` rustdoc
481        // for the run-ahead failure this closes and the Mesen2 cross-check.
482        {
483            w.u8(self.sprite_eval_read_latch);
484            w.u8(self.sprite_eval_n);
485            w.u8(self.sprite_eval_m);
486            w.u8(self.sprite_eval_found);
487            w.u8(self.sprite_eval_sec_idx);
488            w.u8(u8::from(self.sprite_eval_copying));
489            w.u8(u8::from(self.sprite_eval_done));
490            w.u8(u8::from(self.sprite_eval_overflow_search));
491            w.u8(u8::from(self.sprite_eval_zero_found));
492            w.u8(u8::from(self.sprite_eval_first_iter));
493
494            w.u8(self.oam_bus_copybuffer);
495            w.bytes(&self.oam_bus_secondary);
496            w.u8(self.oam_bus_addr_h);
497            w.u8(self.oam_bus_addr_l);
498            w.u8(self.oam_bus_secondary_addr);
499            w.u8(u8::from(self.oam_bus_copy_done));
500            w.u8(u8::from(self.oam_bus_sprite_in_range));
501            w.u8(self.oam_bus_overflow_counter);
502
503            w.u8(self.oam2_addr);
504        }
505
506        w.buf
507    }
508
509    /// Decode a previously [`Ppu::snapshot`]ed blob.
510    ///
511    /// # Errors
512    ///
513    /// Returns [`PpuSnapshotError`] on a malformed blob.
514    // A flat, linear field-by-field decoder with per-version tail branches (v1
515    // through v8); splitting it would only scatter the schema that is clearest
516    // read top-to-bottom against the matching `snapshot` writer.
517    #[allow(clippy::too_many_lines)]
518    pub fn restore(&mut self, data: &[u8]) -> Result<(), PpuSnapshotError> {
519        // A valid v1..=8 snapshot always contains these fixed-size blocks (the
520        // framebuffer, read unconditionally below at every version, dominates);
521        // the version-specific tails only add to this. This is a *conservative
522        // lower bound* — it deliberately omits the ~40 scalar register/latch
523        // bytes and the spr shift arrays, so it can never reject a valid blob,
524        // yet it rejects a clearly-truncated one BEFORE the version byte is read
525        // (so short/garbled input reports `Truncated`, not a misleading
526        // `UnsupportedVersion` on whatever byte sits at offset 0). `Truncated(0)`
527        // matches the offset semantics `R::need` uses elsewhere (the position at
528        // which a read ran out) — here, nothing valid was read.
529        // v2.3.3 — a slim blob (high bit of the version byte) carries no
530        // framebuffer, so the bound must drop that term for it. Read the flag
531        // from byte 0 directly rather than through `R`, because this check
532        // deliberately runs BEFORE the reader is constructed.
533        const MIN_SLIM_SIZE: usize = 1 + CIRAM_LEN + OAM_LEN + SEC_OAM_LEN + PAL_LEN;
534        const MIN_SNAPSHOT_SIZE: usize = MIN_SLIM_SIZE + FRAMEBUFFER_LEN;
535        let is_slim = data
536            .first()
537            .is_some_and(|v| v & PPU_SNAPSHOT_SLIM_FLAG != 0);
538        let min_size = if is_slim {
539            MIN_SLIM_SIZE
540        } else {
541            MIN_SNAPSHOT_SIZE
542        };
543        if data.len() < min_size {
544            return Err(PpuSnapshotError::Truncated(0));
545        }
546        let mut r = R { src: data, pos: 0 };
547        let raw_version = r.u8()?;
548        // v2.3.3 — the high bit marks a slim (framebuffer-less) blob; strip it
549        // before the range check so the version rules are unchanged.
550        let slim = raw_version & PPU_SNAPSHOT_SLIM_FLAG != 0;
551        let version = raw_version & !PPU_SNAPSHOT_SLIM_FLAG;
552        // Bound tied to the constant, not a literal: the acceptance range and
553        // the emitted version must move together on every schema bump.
554        if !matches!(version, 1..=PPU_SNAPSHOT_VERSION) {
555            return Err(PpuSnapshotError::UnsupportedVersion(raw_version));
556        }
557        self.region = region_from_u8(r.u8()?)?;
558
559        self.ctrl = PpuCtrl::from_bits_truncate(r.u8()?);
560        self.mask = PpuMask::from_bits_truncate(r.u8()?);
561        self.mask_for_skip_check = PpuMask::from_bits_truncate(r.u8()?);
562        self.mask_skip_pipe1 = PpuMask::from_bits_truncate(r.u8()?);
563        self.status = PpuStatus::from_bits_truncate(r.u8()?);
564
565        self.oam_addr = r.u8()?;
566        self.data_buffer = r.u8()?;
567        self.v = r.u16()?;
568        self.t = r.u16()?;
569        self.x = r.u8()?;
570        self.w = r.u8()? != 0;
571
572        r.bytes_into(&mut self.ciram)?;
573        r.bytes_into(&mut self.oam)?;
574        r.bytes_into(&mut self.secondary_oam)?;
575        r.bytes_into(&mut self.palette_ram)?;
576
577        self.open_bus = r.u8()?;
578        for d in &mut self.open_bus_decay {
579            *d = r.u32()?;
580        }
581
582        self.nmi_line = r.u8()? != 0;
583        self.suppress_vbl_this_frame = r.u8()? != 0;
584        self.last_a12_level = r.u8()? != 0;
585
586        self.dot = r.u16()?;
587        self.scanline = r.i16()?;
588        self.frame = r.u64()?;
589        self.frame_complete = r.u8()? != 0;
590
591        self.post_reset_mask_remaining = r.u32()?;
592
593        self.nt_latch = r.u8()?;
594        self.at_latch = r.u8()?;
595        self.bg_lo_latch = r.u8()?;
596        self.bg_hi_latch = r.u8()?;
597        self.bg_shift_lo = r.u16()?;
598        self.bg_shift_hi = r.u16()?;
599        if version >= 2 {
600            self.at_shift_lo = r.u16()?;
601            self.at_shift_hi = r.u16()?;
602        } else {
603            // v1: 8-bit attribute shift registers + a 1-bit feed each.
604            // Promote into the v2 16-bit registers (low byte = the v1
605            // 8-bit value; the high byte was always implicitly zero in
606            // the v1 model). The transient feed bits are dropped — they
607            // are regenerated from `at_latch` within one scanline of
608            // resumed rendering, so this is lossless in practice.
609            self.at_shift_lo = u16::from(r.u8()?);
610            self.at_shift_hi = u16::from(r.u8()?);
611            let _at_feed_lo = r.u8()?;
612            let _at_feed_hi = r.u8()?;
613        }
614
615        let ex_present = r.u8()?;
616        let palette = r.u8()?;
617        let chr_bank = r.u16()?;
618        self.ex_attr_latch = match ex_present {
619            0 => None,
620            1 => Some(ExAttribute { palette, chr_bank }),
621            other => return Err(PpuSnapshotError::InvalidPresence(other)),
622        };
623        let split_present = r.u8()?;
624        let nt_addr = r.u16()?;
625        let at_addr = r.u16()?;
626        let fine_y = r.u8()?;
627        let chr_bank8 = r.u8()?;
628        self.bg_split_latch = match split_present {
629            0 => None,
630            1 => Some(BgSplitState {
631                nt_addr,
632                at_addr,
633                fine_y,
634                chr_bank: chr_bank8,
635            }),
636            other => return Err(PpuSnapshotError::InvalidPresence(other)),
637        };
638
639        r.bytes_into(&mut self.spr_shift_lo)?;
640        r.bytes_into(&mut self.spr_shift_hi)?;
641        r.bytes_into(&mut self.spr_attr)?;
642        r.bytes_into(&mut self.spr_x)?;
643        self.spr_count = r.u8()?;
644        self.spr_zero_in_line = r.u8()? != 0;
645
646        // Slim blobs carry no framebuffer; the existing one is left in place
647        // and the caller regenerates the image.
648        if !slim {
649            r.bytes_into(&mut self.framebuffer)?;
650        }
651
652        // v3 (W3-Stage-4): the gated master-clock PPU tail. v1/v2 blobs
653        // lack it; upconvert at the inactive defaults (countdown 0 = no
654        // reload in flight), which is exactly what the pre-v3
655        // clear-on-restore assumption imposed.
656        if version >= 3 {
657            self.restore_stage4_tail(&mut r)?;
658        }
659
660        // v4 (v1.7.0 F3): the in-flight extra-scanlines overclock countdown.
661        // v1/v2/v3 blobs lack it; upconvert to `0` (no insertion in flight),
662        // which is exactly the state a pre-v4 restore left it in.
663        self.extra_lines_remaining = if version >= 4 { r.u16()? } else { 0 };
664
665        // v5 (v2.0.3, ADR 0030): the 2-cycle-ALE in-flight fetch state. v1..=4
666        // blobs lack it; upconvert to the inactive rest defaults (`0`/`false`) —
667        // the state a fetch boundary leaves it in, and exactly what a pre-v5
668        // restore imposed. A v5 blob taken mid-render round-trips the live values
669        // so the re-simulated frame stays byte-identical (netplay rollback).
670        if version >= 5 {
671            self.octal_latch = r.u8()?;
672            self.address_bus = r.u16()?;
673            self.ale_armed = r.u8()? != 0;
674            self.pattern_latch_stale = r.u8()? != 0;
675            self.copy_v_delay = r.u8()?;
676        } else {
677            self.octal_latch = 0;
678            self.address_bus = 0;
679            self.ale_armed = false;
680            self.pattern_latch_stale = false;
681            self.copy_v_delay = 0;
682        }
683
684        // v6: per-sprite shifter halt state (see the write side). Pre-v6 blobs
685        // lack it; upconvert to the power-on default (`true` = halted), which is
686        // what a pre-v6 restore left it at (the field kept its constructor value).
687        if version >= 6 {
688            for h in &mut self.spr_halted {
689                *h = r.u8()? != 0;
690            }
691            self.prev_rendering_enabled = r.u8()? != 0;
692            self.rendering_enabled_delayed = r.u8()? != 0;
693            self.oam_corruption_pending = r.u8()? != 0;
694            self.oam_corruption_index = r.u8()?;
695            self.oam_corruption_disabled = r.u8()? != 0;
696            self.oam_corruption_disabled_instant = r.u8()? != 0;
697        } else {
698            self.spr_halted = [true; 8];
699            self.prev_rendering_enabled = false;
700            self.rendering_enabled_delayed = false;
701            self.oam_corruption_pending = false;
702            self.oam_corruption_index = 0;
703            self.oam_corruption_disabled = false;
704            self.oam_corruption_disabled_instant = false;
705        }
706
707        // v7 (v2.1.4 F2.3): the optional OAM-decay row timestamps, stored as a
708        // relative age. Reconstruct the absolute timestamp against the LIVE counter
709        // (`now = dot_counter / 3`, unchanged by restore) as `now - age`, so the
710        // decay clock is preserved regardless of how the counter was rebased between
711        // snapshot and restore (the rollback-determinism property). Pre-v7 blobs
712        // lack it: stamp every row as freshly-touched at the live cycle (age 0),
713        // i.e. the rest state a pre-v7 restore effectively left (the constructor's
714        // all-zero array with decay off is never consulted anyway).
715        let now = self.dot_counter / 3;
716        if version >= 7 {
717            for ts in &mut self.oam_decay_cycles {
718                let age = r.u64()?;
719                *ts = now.wrapping_sub(age);
720            }
721        } else {
722            self.oam_decay_cycles = [now; 32];
723        }
724
725        // v8: the per-dot sprite-evaluation FSM + parallel OAM-data-bus model +
726        // the clear-window secondary-OAM pointer. Pre-v8 blobs lack it; upconvert
727        // to the constructor defaults, which is precisely the state a pre-v8
728        // restore left these fields in (they simply kept whatever the instance
729        // already held — for a fresh `Ppu`, these values).
730        if version >= 8 {
731            self.sprite_eval_read_latch = r.u8()?;
732            self.sprite_eval_n = r.u8()?;
733            self.sprite_eval_m = r.u8()?;
734            self.sprite_eval_found = r.u8()?;
735            self.sprite_eval_sec_idx = r.u8()?;
736            self.sprite_eval_copying = r.u8()? != 0;
737            self.sprite_eval_done = r.u8()? != 0;
738            self.sprite_eval_overflow_search = r.u8()? != 0;
739            self.sprite_eval_zero_found = r.u8()? != 0;
740            self.sprite_eval_first_iter = r.u8()? != 0;
741
742            self.oam_bus_copybuffer = r.u8()?;
743            r.bytes_into(&mut self.oam_bus_secondary)?;
744            self.oam_bus_addr_h = r.u8()?;
745            self.oam_bus_addr_l = r.u8()?;
746            self.oam_bus_secondary_addr = r.u8()?;
747            self.oam_bus_copy_done = r.u8()? != 0;
748            self.oam_bus_sprite_in_range = r.u8()? != 0;
749            self.oam_bus_overflow_counter = r.u8()?;
750
751            self.oam2_addr = r.u8()?;
752        } else {
753            self.sprite_eval_read_latch = 0xFF;
754            self.sprite_eval_n = 0;
755            self.sprite_eval_m = 0;
756            self.sprite_eval_found = 0;
757            self.sprite_eval_sec_idx = 0;
758            self.sprite_eval_copying = false;
759            self.sprite_eval_done = false;
760            self.sprite_eval_overflow_search = false;
761            self.sprite_eval_zero_found = false;
762            self.sprite_eval_first_iter = false;
763
764            self.oam_bus_copybuffer = 0xFF;
765            self.oam_bus_secondary = [0xFF; 32];
766            self.oam_bus_addr_h = 0;
767            self.oam_bus_addr_l = 0;
768            self.oam_bus_secondary_addr = 0;
769            self.oam_bus_copy_done = false;
770            self.oam_bus_sprite_in_range = false;
771            self.oam_bus_overflow_counter = 0;
772
773            self.oam2_addr = 0;
774        }
775
776        // Derived-cache fixup (every version): the scanline-classification cache
777        // is a pure function of `scanline` + `region`, so it is recomputed rather
778        // than carried. Resetting the key to the `Ppu::new` sentinel forces the
779        // next `tick` to refill it from the restored scanline; leaving a warm key
780        // behind would let a cache filled under a different timeline satisfy the
781        // `scanline == flags_cached_scanline` guard on the fast dot path.
782        self.cached_visible = false;
783        self.cached_pre_render = false;
784        self.cached_render_line = false;
785        #[cfg(feature = "ppu-idle-line-fast")]
786        {
787            self.cached_idle_line = false;
788        }
789        self.flags_cached_scanline = i16::MIN;
790
791        // sanity: the schema-fixed sizes mean we should be at end of input now.
792        if r.pos != data.len() {
793            return Err(PpuSnapshotError::Truncated(r.pos));
794        }
795        Ok(())
796    }
797}
798
799impl Ppu {
800    /// v3 (W3-Stage-4) tail decode: the `mc-ppu-2007-render-buffer` PPUDATA
801    /// state machine + the `mc-ppu-subpos` BG-reload freeze. Bytes are
802    /// always present in a v3 blob; fields whose cargo feature is off are
803    /// consumed and discarded.
804    fn restore_stage4_tail(&mut self, r: &mut R<'_>) -> Result<(), PpuSnapshotError> {
805        let render_data_bus = r.u8()?;
806        let ppudata_sm_countdown = r.u8()?;
807        let ppudata_v_inc_pending = r.u8()? != 0;
808        let mut spr_fetch_lo_raw = [0u8; 8];
809        let mut spr_fetch_hi_raw = [0u8; 8];
810        r.bytes_into(&mut spr_fetch_lo_raw)?;
811        r.bytes_into(&mut spr_fetch_hi_raw)?;
812        let ppudata_spr0_nt_addr = r.u16()?;
813        {
814            self.render_data_bus = render_data_bus;
815            self.ppudata_sm_countdown = ppudata_sm_countdown;
816            self.ppudata_v_inc_pending = ppudata_v_inc_pending;
817            self.spr_fetch_lo_raw = spr_fetch_lo_raw;
818            self.spr_fetch_hi_raw = spr_fetch_hi_raw;
819            self.ppudata_spr0_nt_addr = ppudata_spr0_nt_addr;
820        }
821        let bg_reload_render = r.u8()? != 0;
822        let mask_write_delay = r.u8()?;
823        {
824            self.bg_reload_render = bg_reload_render;
825            self.mask_write_delay = mask_write_delay;
826        }
827        Ok(())
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn snapshot_round_trip() {
837        let mut p = Ppu::new(PpuRegion::Ntsc);
838        p.ciram[10] = 0xAB;
839        p.oam[20] = 0xCD;
840        p.palette_ram[5] = 0x21;
841        p.framebuffer[100] = 0xEF;
842        p.dot = 123;
843        p.scanline = -1;
844        p.frame = 42;
845        p.ex_attr_latch = Some(ExAttribute {
846            palette: 2,
847            chr_bank: 0x123,
848        });
849        p.bg_split_latch = Some(BgSplitState {
850            nt_addr: 0x2400,
851            at_addr: 0x23C0,
852            fine_y: 5,
853            chr_bank: 7,
854        });
855
856        let blob = p.snapshot();
857
858        let mut q = Ppu::new(PpuRegion::Pal);
859        q.restore(&blob).unwrap();
860        assert_eq!(q.region, PpuRegion::Ntsc);
861        assert_eq!(q.ciram[10], 0xAB);
862        assert_eq!(q.oam[20], 0xCD);
863        assert_eq!(q.palette_ram[5], 0x21);
864        assert_eq!(q.framebuffer[100], 0xEF);
865        assert_eq!(q.dot, 123);
866        assert_eq!(q.scanline, -1);
867        assert_eq!(q.frame, 42);
868        assert_eq!(
869            q.ex_attr_latch,
870            Some(ExAttribute {
871                palette: 2,
872                chr_bank: 0x123
873            })
874        );
875        assert_eq!(
876            q.bg_split_latch,
877            Some(BgSplitState {
878                nt_addr: 0x2400,
879                at_addr: 0x23C0,
880                fine_y: 5,
881                chr_bank: 7,
882            })
883        );
884    }
885
886    #[test]
887    fn snapshot_round_trips_16bit_attribute_shifters() {
888        // v2 widened `at_shift_lo`/`at_shift_hi` from u8 to u16 (lockstep
889        // with the BG pattern shifters; the 086ce4d left-edge palette
890        // fix). Verify the full 16-bit value survives a round trip — a
891        // regression that truncated to 8 bits would re-introduce the
892        // attribute/pattern drift after a save-state load.
893        let mut p = Ppu::new(PpuRegion::Ntsc);
894        p.at_shift_lo = 0xAB12;
895        p.at_shift_hi = 0xCD34;
896        p.bg_shift_lo = 0x5678;
897        p.bg_shift_hi = 0x9ABC;
898        let blob = p.snapshot();
899        assert_eq!(
900            blob[0], PPU_SNAPSHOT_VERSION,
901            "blob carries current version"
902        );
903
904        let mut q = Ppu::new(PpuRegion::Ntsc);
905        q.restore(&blob).unwrap();
906        assert_eq!(q.at_shift_lo, 0xAB12);
907        assert_eq!(q.at_shift_hi, 0xCD34);
908        assert_eq!(q.bg_shift_lo, 0x5678);
909        assert_eq!(q.bg_shift_hi, 0x9ABC);
910    }
911
912    #[test]
913    fn snapshot_reads_v1_attribute_shifters_as_low_byte() {
914        // A v1 blob stored `at_shift_lo`/`at_shift_hi` as u8 plus two
915        // 1-bit `at_feed_*` bytes. The v2 reader must accept v1 blobs and
916        // promote the 8-bit attribute value into the low byte of the new
917        // 16-bit register (the high byte was always implicitly zero in
918        // the v1 model). Synthesize a v1 blob by snapshotting v2, then
919        // rewriting the version byte + the 4-byte attribute region in the
920        // v1 (u8 + u8 + u8 + u8) layout.
921        let mut p = Ppu::new(PpuRegion::Ntsc);
922        p.at_shift_lo = 0x00CD; // v1 could only hold the low byte
923        p.at_shift_hi = 0x00EF;
924        let v2 = p.snapshot();
925
926        // Locate the attribute field: it follows bg_shift_lo (u16) +
927        // bg_shift_hi (u16). We rebuild the blob as v1 by re-serialising
928        // up to that point and splicing a v1-shaped attribute block. The
929        // simplest robust construction: decode the v2 layout offset by
930        // searching for the known 16-bit AT-low bytes we set.
931        // at_shift_lo = 0x00CD -> LE bytes [0xCD, 0x00]; at_shift_hi =
932        // 0x00EF -> [0xEF, 0x00]. In v1 these become [0xCD][0xEF] plus two
933        // feed bytes. Build the v1 blob field-by-field by copying the
934        // prefix, then the v1 attribute block, then the v2 tail (which is
935        // identical from `ex_attr_latch` onward).
936        // Find the 4-byte AT region: bytes [.., 0xCD,0x00, 0xEF,0x00, ..].
937        let mut idx = None;
938        for w in 0..v2.len().saturating_sub(4) {
939            if v2[w] == 0xCD && v2[w + 1] == 0x00 && v2[w + 2] == 0xEF && v2[w + 3] == 0x00 {
940                idx = Some(w);
941                break;
942            }
943        }
944        let at = idx.expect("locate v2 16-bit AT region");
945        let mut v1 = Vec::new();
946        v1.extend_from_slice(&v2[..at]); // prefix (incl. bg shifters)
947        v1.push(0xCD); // v1 at_shift_lo (u8)
948        v1.push(0xEF); // v1 at_shift_hi (u8)
949        v1.push(0x01); // v1 at_feed_lo (u8)
950        v1.push(0x00); // v1 at_feed_hi (u8)
951        // Tail from ex_attr_latch onward, MINUS the v3 W3-Stage-4 tail
952        // (23 bytes: u8*3 + [u8;8]*2 + u16 PPUDATA state machine, then
953        // u8*2 BG-reload freeze), the v4 extra-scanlines countdown (2 bytes:
954        // u16 `extra_lines_remaining`), the v5 2-cycle-ALE fetch-state tail
955        // (6 bytes: u8 `octal_latch` + u16 `address_bus` + u8 `ale_armed` + u8
956        // `pattern_latch_stale` + u8 `copy_v_delay`), the v6 render-state
957        // tail (14 bytes: [u8;8] `spr_halted` + u8 `prev_rendering_enabled` + u8
958        // `rendering_enabled_delayed` + u8*4 `oam_corruption_*`), the v7
959        // OAM-decay tail (256 bytes: [u64;32] relative-age `oam_decay_cycles`),
960        // AND the v8 sprite-evaluation tail (50 bytes: u8*5 + bool*5 eval FSM,
961        // then u8 + [u8;32] + u8*3 + bool*2 + u8 OAM-data-bus model, then u8
962        // `oam2_addr`) — 351 bytes total, none of which a v1 blob carried.
963        v1.extend_from_slice(&v2[at + 4..v2.len() - 351]);
964        v1[0] = 1; // version byte -> v1
965
966        let mut q = Ppu::new(PpuRegion::Ntsc);
967        q.restore(&v1).expect("v1 blob must upconvert");
968        assert_eq!(q.at_shift_lo, 0x00CD, "v1 low byte promoted to 16-bit");
969        assert_eq!(q.at_shift_hi, 0x00EF);
970    }
971
972    #[test]
973    fn snapshot_rejects_short_blob() {
974        let mut p = Ppu::new(PpuRegion::Ntsc);
975        assert!(matches!(
976            p.restore(&[]).unwrap_err(),
977            PpuSnapshotError::Truncated(_)
978        ));
979        // The regression this size guard prevents: a SHORT blob whose first byte
980        // is an unknown version must be classified `Truncated` (the guard runs
981        // before the version check), NOT `UnsupportedVersion(0xFF)`.
982        assert!(matches!(
983            p.restore(&[0xFF; 4]).unwrap_err(),
984            PpuSnapshotError::Truncated(_)
985        ));
986    }
987
988    #[test]
989    fn snapshot_rejects_bad_version() {
990        let mut p = Ppu::new(PpuRegion::Ntsc);
991        // A full-size blob (past the truncation guard) whose version byte is
992        // unknown must be rejected at the version check — not mistaken for a
993        // truncated blob. (A short bad-version blob is a Truncated case,
994        // covered by `snapshot_rejects_short_blob`.)
995        let mut blob = p.snapshot();
996        blob[0] = 0xFF;
997        let err = p.restore(&blob).unwrap_err();
998        assert!(matches!(err, PpuSnapshotError::UnsupportedVersion(0xFF)));
999    }
1000
1001    #[test]
1002    fn snapshot_is_deterministic() {
1003        let p = Ppu::new(PpuRegion::Ntsc);
1004        assert_eq!(p.snapshot(), p.snapshot());
1005    }
1006
1007    #[test]
1008    fn snapshot_round_trips_sprite_evaluation_state() {
1009        // v8: a snapshot taken with a sprite-evaluation pass in flight (dots
1010        // 65..=256) must restore the FSM's pointers and phase, not just the
1011        // `secondary_oam` buffer they fill. Before this tail, run-ahead's
1012        // per-frame snapshot/restore silently reset the walker while keeping the
1013        // buffer, costing three AccuracyCoin tests on the desktop frontend.
1014        let mut p = Ppu::new(PpuRegion::Ntsc);
1015        p.sprite_eval_read_latch = 0x5A;
1016        p.sprite_eval_n = 37;
1017        p.sprite_eval_m = 2;
1018        p.sprite_eval_found = 6;
1019        p.sprite_eval_sec_idx = 25;
1020        p.sprite_eval_copying = true;
1021        p.sprite_eval_done = false;
1022        p.sprite_eval_overflow_search = true;
1023        p.sprite_eval_zero_found = true;
1024        p.sprite_eval_first_iter = false;
1025        p.oam_bus_copybuffer = 0xC3;
1026        p.oam_bus_secondary = [0x11; 32];
1027        p.oam_bus_secondary[7] = 0x99;
1028        p.oam_bus_addr_h = 41;
1029        p.oam_bus_addr_l = 3;
1030        p.oam_bus_secondary_addr = 18;
1031        p.oam_bus_copy_done = true;
1032        p.oam_bus_sprite_in_range = true;
1033        p.oam_bus_overflow_counter = 5;
1034        p.oam2_addr = 12;
1035
1036        let blob = p.snapshot();
1037        assert_eq!(
1038            blob[0], PPU_SNAPSHOT_VERSION,
1039            "blob carries current version"
1040        );
1041
1042        let mut q = Ppu::new(PpuRegion::Ntsc);
1043        q.restore(&blob).unwrap();
1044        assert_eq!(q.sprite_eval_read_latch, 0x5A);
1045        assert_eq!(q.sprite_eval_n, 37);
1046        assert_eq!(q.sprite_eval_m, 2);
1047        assert_eq!(q.sprite_eval_found, 6);
1048        assert_eq!(q.sprite_eval_sec_idx, 25);
1049        assert!(q.sprite_eval_copying);
1050        assert!(!q.sprite_eval_done);
1051        assert!(q.sprite_eval_overflow_search);
1052        assert!(q.sprite_eval_zero_found);
1053        assert!(!q.sprite_eval_first_iter);
1054        assert_eq!(q.oam_bus_copybuffer, 0xC3);
1055        assert_eq!(q.oam_bus_secondary[7], 0x99);
1056        assert_eq!(q.oam_bus_secondary[0], 0x11);
1057        assert_eq!(q.oam_bus_addr_h, 41);
1058        assert_eq!(q.oam_bus_addr_l, 3);
1059        assert_eq!(q.oam_bus_secondary_addr, 18);
1060        assert!(q.oam_bus_copy_done);
1061        assert!(q.oam_bus_sprite_in_range);
1062        assert_eq!(q.oam_bus_overflow_counter, 5);
1063        assert_eq!(q.oam2_addr, 12);
1064    }
1065
1066    #[test]
1067    fn restore_invalidates_the_scanline_classification_cache() {
1068        // The cache is derived (a pure function of `scanline` + `region`) and so
1069        // deliberately NOT serialized. Restore must therefore reset its KEY to
1070        // the `Ppu::new` sentinel: a warm key inherited from another timeline
1071        // would otherwise satisfy the fast dot path's
1072        // `scanline == flags_cached_scanline` guard against a stale value.
1073        let p = Ppu::new(PpuRegion::Ntsc);
1074        let blob = p.snapshot();
1075
1076        let mut q = Ppu::new(PpuRegion::Ntsc);
1077        q.cached_visible = true;
1078        q.cached_pre_render = true;
1079        q.cached_render_line = true;
1080        q.flags_cached_scanline = 42;
1081        q.restore(&blob).unwrap();
1082        assert!(!q.cached_visible);
1083        assert!(!q.cached_pre_render);
1084        assert!(!q.cached_render_line);
1085        assert_eq!(q.flags_cached_scanline, i16::MIN);
1086    }
1087
1088    #[test]
1089    fn snapshot_round_trips_extra_lines_remaining() {
1090        // v1.7.0 F3: a save-state taken mid-insertion (extra_lines_remaining
1091        // > 0) must restore the in-flight countdown, not reset it to 0.
1092        let mut p = Ppu::new(PpuRegion::Ntsc);
1093        p.set_extra_scanlines(8);
1094        p.extra_lines_remaining = 5;
1095        let blob = p.snapshot();
1096        assert_eq!(
1097            blob[0], PPU_SNAPSHOT_VERSION,
1098            "blob carries current version"
1099        );
1100
1101        let mut q = Ppu::new(PpuRegion::Ntsc);
1102        q.restore(&blob).unwrap();
1103        assert_eq!(q.extra_lines_remaining, 5);
1104    }
1105
1106    #[test]
1107    fn snapshot_round_trips_2cycle_ale_fetch_state() {
1108        // v2.0.3 (ADR 0030): a checkpoint taken mid-render (netplay rollback)
1109        // can land with the 2-cycle-ALE fetch model's in-flight state live —
1110        // the octal latch / multiplexed bus, the ALE arm, and the two corruption
1111        // one-shots (`pattern_latch_stale`, the delayed-`CopyV` `copy_v_delay`).
1112        // They must round-trip so the re-simulated frame is byte-identical to the
1113        // forward run (else the peers desync). Regression pin for the promotion.
1114        let mut p = Ppu::new(PpuRegion::Ntsc);
1115        p.octal_latch = 0x19;
1116        p.address_bus = 0x2F19;
1117        p.ale_armed = true;
1118        p.pattern_latch_stale = true;
1119        p.copy_v_delay = 3;
1120        let blob = p.snapshot();
1121
1122        let mut q = Ppu::new(PpuRegion::Ntsc);
1123        q.restore(&blob).unwrap();
1124        assert_eq!(q.octal_latch, 0x19);
1125        assert_eq!(q.address_bus, 0x2F19);
1126        assert!(q.ale_armed);
1127        assert!(q.pattern_latch_stale);
1128        assert_eq!(q.copy_v_delay, 3);
1129    }
1130
1131    /// v2.3.3 — a slim blob must be dramatically smaller, and the size
1132    /// difference must be exactly the framebuffer.
1133    #[test]
1134    fn slim_snapshot_omits_exactly_the_framebuffer() {
1135        let p = Ppu::new(PpuRegion::Ntsc);
1136        let full = p.snapshot();
1137        let slim = p.snapshot_slim();
1138        assert_eq!(
1139            full.len() - slim.len(),
1140            FRAMEBUFFER_LEN,
1141            "slim must differ from full by exactly the framebuffer"
1142        );
1143    }
1144
1145    /// A slim blob restores every field EXCEPT the framebuffer, which it must
1146    /// leave untouched (the caller regenerates the image).
1147    #[test]
1148    fn slim_restore_preserves_the_existing_framebuffer() {
1149        let mut src = Ppu::new(PpuRegion::Ntsc);
1150        src.framebuffer[100] = 0xAB;
1151        src.spr_count = 5;
1152        let slim = src.snapshot_slim();
1153
1154        let mut dst = Ppu::new(PpuRegion::Ntsc);
1155        dst.framebuffer[100] = 0xEF; // must survive the restore
1156        dst.spr_count = 0;
1157        dst.restore(&slim).expect("slim blob restores");
1158        assert_eq!(dst.spr_count, 5, "non-framebuffer state must restore");
1159        assert_eq!(
1160            dst.framebuffer[100], 0xEF,
1161            "slim restore must not touch the framebuffer"
1162        );
1163    }
1164
1165    /// The flag must not disturb the existing format: a full blob still round
1166    /// trips its framebuffer, and its version byte has the flag clear.
1167    #[test]
1168    fn full_snapshot_is_unchanged_by_the_slim_flag() {
1169        let mut src = Ppu::new(PpuRegion::Ntsc);
1170        src.framebuffer[100] = 0xAB;
1171        let full = src.snapshot();
1172        assert_eq!(full[0] & PPU_SNAPSHOT_SLIM_FLAG, 0, "flag clear on full");
1173        assert_eq!(full[0], PPU_SNAPSHOT_VERSION);
1174        let mut dst = Ppu::new(PpuRegion::Ntsc);
1175        dst.restore(&full).expect("full blob restores");
1176        assert_eq!(dst.framebuffer[100], 0xAB);
1177    }
1178
1179    /// A truncated slim blob must still be rejected — the relaxed minimum
1180    /// must not become a hole that accepts garbage.
1181    #[test]
1182    fn truncated_slim_blob_is_still_rejected() {
1183        let p = Ppu::new(PpuRegion::Ntsc);
1184        let slim = p.snapshot_slim();
1185        let mut dst = Ppu::new(PpuRegion::Ntsc);
1186        assert!(dst.restore(&slim[..slim.len() / 2]).is_err());
1187    }
1188
1189    #[test]
1190    fn snapshot_round_trips_oam_decay_ages() {
1191        // v2.1.4 F2.3: the optional OAM-decay per-row timestamps must round-trip.
1192        // They are stored as a RELATIVE AGE against the un-serialized `dot_counter`,
1193        // so the exact absolute value is preserved only when the restoring instance
1194        // shares the same live counter (the normal same-instance snapshot/restore).
1195        let mut p = Ppu::new(PpuRegion::Ntsc);
1196        p.set_oam_decay(true);
1197        p.dot_counter = 90_000; // now = 30_000 CPU cycles
1198        // Distinct per-row timestamps (ages 0, 3, 6, ... spread across the array).
1199        for (i, ts) in p.oam_decay_cycles.iter_mut().enumerate() {
1200            *ts = 30_000 - (i as u64 * 3);
1201        }
1202        let blob = p.snapshot();
1203        assert_eq!(
1204            blob[0], PPU_SNAPSHOT_VERSION,
1205            "blob carries current version"
1206        );
1207
1208        // Restore into an instance with the SAME live counter → exact timestamps.
1209        let mut q = Ppu::new(PpuRegion::Pal);
1210        q.dot_counter = 90_000;
1211        q.restore(&blob).unwrap();
1212        assert_eq!(
1213            q.oam_decay_cycles, p.oam_decay_cycles,
1214            "absolute timestamps preserved when the live counter matches"
1215        );
1216
1217        // Restore into an instance whose counter has been REBASED (the rollback
1218        // case): the *relative age* — what the decay clock actually consumes — must
1219        // be identical, even though the absolute timestamps differ.
1220        let mut q2 = Ppu::new(PpuRegion::Ntsc);
1221        q2.dot_counter = 3 * 1_000_000; // a wildly different base (now = 1_000_000)
1222        q2.restore(&blob).unwrap();
1223        let now_p = p.dot_counter / 3;
1224        let now_q2 = q2.dot_counter / 3;
1225        for i in 0..32 {
1226            let age_p = now_p.wrapping_sub(p.oam_decay_cycles[i]);
1227            let age_q2 = now_q2.wrapping_sub(q2.oam_decay_cycles[i]);
1228            assert_eq!(age_p, age_q2, "row {i} age preserved across counter rebase");
1229        }
1230    }
1231
1232    #[test]
1233    fn snapshot_pre_v7_blob_upconverts_oam_decay_to_rest() {
1234        // A v6 blob lacks the OAM-decay tail; the v7 reader must upconvert it by
1235        // stamping every row as freshly-touched at the live cycle (age 0), which is
1236        // the rest state (decay is off in any pre-v7 build, so the array is inert).
1237        // Synthesize a v6 blob by snapshotting the current version and truncating
1238        // BOTH the v8 sprite-evaluation tail (50 bytes) and the v7 OAM-decay tail
1239        // (256 bytes), then rewriting the version byte.
1240        let p = Ppu::new(PpuRegion::Ntsc);
1241        let cur = p.snapshot();
1242        let mut v6 = cur[..cur.len() - (50 + 256)].to_vec();
1243        v6[0] = 6;
1244
1245        let mut q = Ppu::new(PpuRegion::Ntsc);
1246        q.dot_counter = 3 * 12_345; // now = 12_345
1247        q.restore(&v6).expect("v6 blob must upconvert");
1248        assert_eq!(
1249            q.oam_decay_cycles, [12_345u64; 32],
1250            "pre-v7 rows stamped fresh at the live cycle"
1251        );
1252    }
1253
1254    #[test]
1255    fn snapshot_default_extra_lines_remaining_is_zero() {
1256        // At the default extra_scanlines == 0 the countdown is always 0, so
1257        // the v4 field is a zero u16 and restore is behaviourally identical.
1258        let p = Ppu::new(PpuRegion::Ntsc);
1259        assert_eq!(p.extra_lines_remaining, 0);
1260        let blob = p.snapshot();
1261        let mut q = Ppu::new(PpuRegion::Ntsc);
1262        q.restore(&blob).unwrap();
1263        assert_eq!(q.extra_lines_remaining, 0);
1264    }
1265
1266    #[test]
1267    fn set_extra_scanlines_resets_in_flight_countdown() {
1268        // Changing the configured count cancels any in-flight insertion so
1269        // the per-frame countdown cannot remain stale/out-of-bounds.
1270        let mut p = Ppu::new(PpuRegion::Ntsc);
1271        p.set_extra_scanlines(8);
1272        p.extra_lines_remaining = 6;
1273        p.set_extra_scanlines(2);
1274        assert_eq!(p.extra_lines_remaining, 0);
1275        p.extra_lines_remaining = 1;
1276        p.set_extra_scanlines(0); // disable
1277        assert_eq!(p.extra_lines_remaining, 0);
1278    }
1279}