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}