Skip to main content

rustysnes_ppu/
lib.rs

1//! `rustysnes-ppu` — PPU1 (5C77) + PPU2 (5C78) (video).
2//!
3//! Dual-chip PPU: BG modes 0-7 (incl. Mode 7 affine), OAM sprites, the dot-clock timeline.
4//! The PPU owns its own VRAM (64 KiB), CGRAM (palette), and OAM. Anything that has to reach
5//! the cartridge — Mode 7 / extended-bank reads on coprocessor boards, board IRQ/scanline
6//! notifies — goes through the narrow [`VideoBus`] trait, whose only concrete impl in
7//! production is the cart-mediated router in `rustysnes-core`. This is the RustyNES `PpuBus`
8//! shape, ported: the video chip depends ONLY on `rustysnes-cart` (its memory bus).
9//!
10//! Part of the one-directional chip-crate graph (see `docs/architecture.md`): this crate
11//! does NOT depend on the cpu/apu chip crates. `#![no_std]` + alloc so it cross-compiles to
12//! a bare-metal target; only the frontend carries `std` + `unsafe`.
13//!
14//! # Timing convention
15//!
16//! Per `docs/scheduler.md` (binding): RustySNES counts **341 dots of nominally 4 master
17//! clocks** per line; the scheduler advances the PPU one dot at a time via [`Ppu::tick_dot`].
18//! H runs 0..=339 (340 wraps to a new line), V runs 0..=261 (NTSC) / 0..=311 (PAL). Active
19//! output is dots 22..=277 on lines 1..=224 (1..=239 overscan); `VBlank` asserts at V=225
20//! (V=240 overscan). The renderer is per-scanline (it composites a whole visible line at
21//! [`RENDER_DOT`], one dot before that line's own per-line HDMA run can observe/mutate the
22//! registers the composite reads), which is far simpler than a per-dot renderer and
23//! bit-identical to one for every currently-modeled case, including a per-line HDMA-driven
24//! register write (e.g. a raster scroll split) — which only becomes visible starting the
25//! following line, matching real hardware (`docs/ppu.md` §Mid-scanline/HDMA-driven register
26//! timing, landed `v0.8.0`).
27//!
28//! # Rendering note (clean-room)
29//!
30//! The register semantics and rendering math here are re-implemented from `docs/ppu.md` plus
31//! the documented SNES hardware behavior (SNESdev/Fullsnes), structurally informed by the ares
32//! (ISC) PPU. No source was copied/ported verbatim.
33
34#![no_std]
35#![forbid(unsafe_code)]
36// reason: the pixel-compositing math is full of deliberate width-narrowing and sign-changing
37// casts (folding 16-bit fixed-point Mode 7 coordinates to indices, taking the low byte of a
38// palette word, mapping a signed scroll delta into a VRAM offset). They are intrinsic to a
39// bit-accurate PPU model and ubiquitous; flagging each one would bury the genuine signal.
40// This mirrors how `rustysnes-cpu/src/exec.rs` blankets the same lints for the ALU.
41#![allow(
42    clippy::cast_possible_truncation,
43    clippy::cast_sign_loss,
44    clippy::cast_possible_wrap,
45    clippy::cast_lossless
46)]
47// reason: the register/state structs (`Io`, `Ppu`, `WindowIo`/`WindowLayer`) mirror the SNES
48// hardware bit-fields, where a flock of independent enable/flip/invert bits is the genuine
49// shape — bundling them into bitflags would obscure the 1:1 register mapping. And `Box::new`ing
50// the 64 KiB VRAM / 122 KiB framebuffer arrays does build them on the stack transiently, but
51// that is the one-shot power-on path, not a hot loop; the heap home is the whole point.
52#![allow(clippy::struct_excessive_bools, clippy::large_stack_arrays)]
53extern crate alloc;
54
55use alloc::boxed::Box;
56
57use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
58
59pub mod bus;
60// HD texture pack tile-identity hashing + the `Ppu`-side `TileTag` recording hook (`v1.3.0`) --
61// off by default (`Ppu::set_hd_pack_tagging`), and compiled out entirely (not just runtime-inert)
62// when the `hd-pack` feature is off.
63#[cfg(feature = "hd-pack")]
64pub mod hdtag;
65mod regs;
66mod render;
67
68pub use bus::VideoBus;
69
70/// Master clocks per PPU dot (nominal). The scheduler owns the long-dot remainder; this crate
71/// just counts dots. See `docs/scheduler.md` "Convention (binding)".
72pub const MASTER_CLOCKS_PER_DOT: u32 = 4;
73
74/// Dots per scanline (the RustySNES convention: 341 dots of nominally 4 master clocks).
75pub const DOTS_PER_LINE: u16 = 341;
76
77/// The dot at which a visible scanline's composited output becomes final for that line.
78///
79/// The SNES hardware fact behind this: a line's own per-line HDMA transfer (`rustysnes-core`'s
80/// `HDMA_RUN_DOT`) fires at hcounter 1104 = dot 276, strictly *after* real hardware's per-pixel
81/// active-region output for that same line has already completed (ares' `cycleRenderPixel()`
82/// only runs for hcounter `[56, 1078]` — dot ~269.5 — `ref-proj/ares/ares/sfc/ppu/main.cpp`).
83/// [`Ppu::tick_dot`] composites the finishing line here, one dot before this line's own HDMA run
84/// can observe/mutate the registers that composite reads, so an HDMA-driven per-line register
85/// write during line `V` is only ever visible starting line `V+1` — matching real hardware. This
86/// is the single source of truth `rustysnes-core::bus`'s `HDMA_RUN_DOT` is defined equal to
87/// (PPU-owned since it is fundamentally a video-timing fact, not a DMA-specific one); a `#[test]`
88/// in `rustysnes-core` (which depends on this crate, not the reverse) asserts the two never
89/// drift apart. See `docs/ppu.md` §Mid-scanline/HDMA-driven register timing for the full
90/// mechanism and regression history.
91pub const RENDER_DOT: u16 = 276;
92
93/// Visible width of one rendered scanline in normal (non-hires) resolution, in pixels.
94///
95/// This is also the per-pixel-clock compositing width: one PPU pixel clock produces one
96/// `above`/`below` layer-pixel pair regardless of resolution — only the DAC output stage doubles
97/// for hi-res (`docs/ppu.md` §Hi-res (Modes 5/6) color-math precision).
98pub const SCREEN_WIDTH: usize = 256;
99
100/// Output width of one rendered scanline in hi-res (Modes 5/6, or pseudo-hires `SETINI` bit 3),
101/// in pixels — the DAC emits two output columns per pixel clock in this mode.
102pub const MAX_SCREEN_WIDTH: usize = 512;
103
104/// Maximum visible height (overscan). Standard frames fill the first 224 rows.
105pub const SCREEN_HEIGHT: usize = 239;
106
107/// First active output dot on a visible line.
108const ACTIVE_DOT_START: u16 = 22;
109
110/// Dots by which the HV-IRQ horizontal comparator lags the programmed `HTIME`, modelling the
111/// SNES hardware communication delay between the counter unit and the CPU's interrupt logic
112/// (ares `hcounter(10) == (HTIME+1)<<2` ⇒ fire at dot `HTIME + 3.5`; see `check_hv_irq`).
113const HIRQ_TRIGGER_DELAY: u16 = 4;
114
115/// Framebuffer length at normal (non-hires) resolution, in 15-bit BGR pixels.
116///
117/// This is the length [`Ppu::framebuffer`] returns for every frame that never enters hi-res —
118/// i.e. every currently shipping ROM/golden-vector, which stays byte-identical to before this
119/// const's hi-res sibling was added.
120pub const FRAMEBUFFER_LEN: usize = SCREEN_WIDTH * SCREEN_HEIGHT;
121
122/// The framebuffer's fixed backing-array allocation size — always hi-res-capacity, so a
123/// mode change mid-run never needs a reallocation. [`Ppu::framebuffer`] returns a
124/// resolution-sized *slice* of this backing storage, not the whole array.
125const MAX_FRAMEBUFFER_LEN: usize = MAX_SCREEN_WIDTH * SCREEN_HEIGHT;
126
127/// Video region — fixes the line count and the NTSC/PAL status bit. Data, not behavior.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum Region {
130    /// 262 lines / frame, 60 Hz. The default for the North-American / Japanese SNES.
131    #[default]
132    Ntsc,
133    /// 312 lines / frame, 50 Hz.
134    Pal,
135}
136
137impl Region {
138    /// Total scanlines per (non-interlaced) frame for this region.
139    #[must_use]
140    pub const fn lines_per_frame(self) -> u16 {
141        match self {
142            Self::Ntsc => 262,
143            Self::Pal => 312,
144        }
145    }
146}
147
148/// One sprite as decoded from the OAM low + high tables. `width`/`height` depend on `OBSEL`.
149#[derive(Debug, Clone, Copy, Default)]
150struct Object {
151    x: u16,           // 9-bit X (sign via bit 8)
152    y: u8,            // Y top
153    character: u8,    // tile number low 8 bits
154    nameselect: bool, // name-table select bit
155    palette: u8,      // 3-bit palette group
156    priority: u8,     // 2-bit priority
157    hflip: bool,
158    vflip: bool,
159    size: bool, // large/small toggle (high table)
160}
161
162/// The shared B-bus / PPU register I/O state. Most fields map 1:1 onto a documented register
163/// bit-field in `docs/ppu.md`; grouped here so the renderer reads a single struct.
164#[derive(Debug, Clone)]
165struct Io {
166    // INIDISP $2100
167    display_brightness: u8, // 0..=15
168    display_disable: bool,
169
170    // BGMODE $2105
171    bg_mode: u8, // 0..=7
172    bg3_priority: bool,
173    tile_size: [bool; 4], // per-BG 16x16 select
174
175    // MOSAIC $2106
176    mosaic_size: u8, // 1..=16
177    mosaic_enable: [bool; 4],
178
179    // Per-BG: BGnSC ($2107..a) + BGnNBA ($210b..c)
180    bg_screen_addr: [u16; 4],   // word address of the tilemap base
181    bg_screen_size: [u8; 4],    // 0..=3 (H/V 32/64-tile expansion)
182    bg_tiledata_addr: [u16; 4], // word address of the char base
183    bg_hofs: [u16; 4],
184    bg_vofs: [u16; 4],
185
186    // VMAIN $2115 + VMADD $2116/7 + the $2139/A read prefetch latch
187    vram_increment_size: u16,  // 1 / 32 / 128 words
188    vram_mapping: u8,          // 0..=3 address remap
189    vram_increment_high: bool, // false = increment on $2118/$2139 (low), true = high
190    vram_address: u16,         // word address
191    vram_read_latch: u16,      // prefetch latch for $2139/A
192
193    // M7SEL $211A + M7A..D + M7X/Y
194    m7_hflip: bool,
195    m7_vflip: bool,
196    m7_repeat: u8, // 0..=3
197    m7a: u16,
198    m7b: u16,
199    m7c: u16,
200    m7d: u16,
201    m7x: u16,
202    m7y: u16,
203    m7_hofs: u16,
204    m7_vofs: u16,
205
206    // CGADD $2121 / CGDATA $2122 / $213B read
207    cgram_address: u8,
208    cgram_latch_high: bool, // false: next access is the low byte
209    cgram_byte_latch: u8,   // low byte held between the two CGDATA writes
210
211    // OAMADD $2102/3 + the running address
212    oam_base_address: u16, // word-pair base (<<1 of the register value)
213    oam_priority_rotation: bool,
214    oam_address: u16,   // running 10-bit address
215    oam_byte_latch: u8, // even-byte hold for OAMDATA writes
216
217    // Windows $2123..$2129 + $212A/B masks, per layer
218    win: WindowIo,
219
220    // TM/TS $212C/D — main / sub layer enable (bg1..4, obj)
221    main_enable: [bool; 5],
222    sub_enable: [bool; 5],
223    // TMW/TSW $212E/F — per-layer window masking enable
224    win_main_enable: [bool; 5],
225    win_sub_enable: [bool; 5],
226
227    // CGWSEL $2130 / CGADSUB $2131 / COLDATA $2132
228    direct_color: bool,
229    add_subscreen: bool,          // false: fixed color, true: subscreen as addend
230    color_window_above: u8,       // 0..=3 (force-main mask)
231    color_window_below: u8,       // 0..=3 (sub/clip mask)
232    color_math_enable: [bool; 6], // bg1..4, obj, backdrop
233    color_halve: bool,
234    color_subtract: bool, // false: add, true: subtract
235    fixed_color: u16,     // 15-bit BGR
236
237    // SETINI $2133
238    interlace: bool,
239    obj_interlace: bool,
240    overscan: bool,
241    pseudo_hires: bool,
242    extbg: bool,
243
244    // OBSEL $2101
245    obj_tiledata_addr: u16,
246    obj_nameselect: u16,
247    obj_base_size: u8, // 0..=7
248
249    // H/V counter latch ($2137 / $213C/D / $213F)
250    latch_h: u16,
251    latch_v: u16,
252    counter_latched: bool,
253    ophct_high_toggle: bool,
254    opvct_high_toggle: bool,
255
256    // Sprite over-flags (STAT77 bits 6/7) — set during OAM evaluation.
257    range_over: bool,
258    time_over: bool,
259
260    // PPU open-bus / MDR latches for read-back of write-only / unused registers.
261    ppu1_mdr: u8,
262    ppu2_mdr: u8,
263}
264
265/// Per-layer window configuration (`$2123`–`$212B`). Six "layers" share the same shape: the
266/// four BGs, the sprites, and the color-math region.
267#[derive(Debug, Clone, Default)]
268struct WindowIo {
269    one_left: u8,
270    one_right: u8,
271    two_left: u8,
272    two_right: u8,
273    /// Per layer 0..=5 (bg1..4, obj, col): (`w1_enable`, `w1_invert`, `w2_enable`, `w2_invert`, mask).
274    layer: [WindowLayer; 6],
275}
276
277#[derive(Debug, Clone, Copy, Default)]
278struct WindowLayer {
279    one_enable: bool,
280    one_invert: bool,
281    two_enable: bool,
282    two_invert: bool,
283    mask: u8, // 0=OR 1=AND 2=XOR 3=XNOR
284}
285
286impl Default for Io {
287    fn default() -> Self {
288        Self {
289            display_brightness: 0,
290            display_disable: true, // power-on is force-blank
291            bg_mode: 0,
292            bg3_priority: false,
293            tile_size: [false; 4],
294            mosaic_size: 1,
295            mosaic_enable: [false; 4],
296            bg_screen_addr: [0; 4],
297            bg_screen_size: [0; 4],
298            bg_tiledata_addr: [0; 4],
299            bg_hofs: [0; 4],
300            bg_vofs: [0; 4],
301            vram_increment_size: 1,
302            vram_mapping: 0,
303            vram_increment_high: false,
304            vram_address: 0,
305            vram_read_latch: 0,
306            m7_hflip: false,
307            m7_vflip: false,
308            m7_repeat: 0,
309            m7a: 0,
310            m7b: 0,
311            m7c: 0,
312            m7d: 0,
313            m7x: 0,
314            m7y: 0,
315            m7_hofs: 0,
316            m7_vofs: 0,
317            cgram_address: 0,
318            cgram_latch_high: false,
319            cgram_byte_latch: 0,
320            oam_base_address: 0,
321            oam_priority_rotation: false,
322            oam_address: 0,
323            oam_byte_latch: 0,
324            win: WindowIo::default(),
325            main_enable: [false; 5],
326            sub_enable: [false; 5],
327            win_main_enable: [false; 5],
328            win_sub_enable: [false; 5],
329            direct_color: false,
330            add_subscreen: false,
331            color_window_above: 0,
332            color_window_below: 0,
333            color_math_enable: [false; 6],
334            color_halve: false,
335            color_subtract: false,
336            fixed_color: 0,
337            interlace: false,
338            obj_interlace: false,
339            overscan: false,
340            pseudo_hires: false,
341            extbg: false,
342            obj_tiledata_addr: 0,
343            obj_nameselect: 0,
344            obj_base_size: 0,
345            latch_h: 0,
346            latch_v: 0,
347            counter_latched: false,
348            ophct_high_toggle: false,
349            opvct_high_toggle: false,
350            range_over: false,
351            time_over: false,
352            ppu1_mdr: 0,
353            ppu2_mdr: 0,
354        }
355    }
356}
357
358impl WindowLayer {
359    fn save_state(self, s: &mut SaveWriter) {
360        s.write_bool(self.one_enable);
361        s.write_bool(self.one_invert);
362        s.write_bool(self.two_enable);
363        s.write_bool(self.two_invert);
364        s.write_u8(self.mask);
365    }
366
367    fn load_state(&mut self, s: &mut SaveReader) -> Result<(), SaveStateError> {
368        self.one_enable = s.read_bool()?;
369        self.one_invert = s.read_bool()?;
370        self.two_enable = s.read_bool()?;
371        self.two_invert = s.read_bool()?;
372        self.mask = s.read_u8()? & 0x03;
373        Ok(())
374    }
375}
376
377impl WindowIo {
378    fn save_state(&self, s: &mut SaveWriter) {
379        s.write_u8(self.one_left);
380        s.write_u8(self.one_right);
381        s.write_u8(self.two_left);
382        s.write_u8(self.two_right);
383        for l in &self.layer {
384            l.save_state(s);
385        }
386    }
387
388    fn load_state(&mut self, s: &mut SaveReader) -> Result<(), SaveStateError> {
389        self.one_left = s.read_u8()?;
390        self.one_right = s.read_u8()?;
391        self.two_left = s.read_u8()?;
392        self.two_right = s.read_u8()?;
393        for l in &mut self.layer {
394            l.load_state(s)?;
395        }
396        Ok(())
397    }
398}
399
400impl Io {
401    /// Write every register field, in declaration order, into the caller's section.
402    fn save_state(&self, s: &mut SaveWriter) {
403        s.write_u8(self.display_brightness);
404        s.write_bool(self.display_disable);
405        s.write_u8(self.bg_mode);
406        s.write_bool(self.bg3_priority);
407        for &v in &self.tile_size {
408            s.write_bool(v);
409        }
410        s.write_u8(self.mosaic_size);
411        for &v in &self.mosaic_enable {
412            s.write_bool(v);
413        }
414        for &v in &self.bg_screen_addr {
415            s.write_u16(v);
416        }
417        for &v in &self.bg_screen_size {
418            s.write_u8(v);
419        }
420        for &v in &self.bg_tiledata_addr {
421            s.write_u16(v);
422        }
423        for &v in &self.bg_hofs {
424            s.write_u16(v);
425        }
426        for &v in &self.bg_vofs {
427            s.write_u16(v);
428        }
429        s.write_u16(self.vram_increment_size);
430        s.write_u8(self.vram_mapping);
431        s.write_bool(self.vram_increment_high);
432        s.write_u16(self.vram_address);
433        s.write_u16(self.vram_read_latch);
434        s.write_bool(self.m7_hflip);
435        s.write_bool(self.m7_vflip);
436        s.write_u8(self.m7_repeat);
437        s.write_u16(self.m7a);
438        s.write_u16(self.m7b);
439        s.write_u16(self.m7c);
440        s.write_u16(self.m7d);
441        s.write_u16(self.m7x);
442        s.write_u16(self.m7y);
443        s.write_u16(self.m7_hofs);
444        s.write_u16(self.m7_vofs);
445        s.write_u8(self.cgram_address);
446        s.write_bool(self.cgram_latch_high);
447        s.write_u8(self.cgram_byte_latch);
448        s.write_u16(self.oam_base_address);
449        s.write_bool(self.oam_priority_rotation);
450        s.write_u16(self.oam_address);
451        s.write_u8(self.oam_byte_latch);
452        self.win.save_state(s);
453        for &v in &self.main_enable {
454            s.write_bool(v);
455        }
456        for &v in &self.sub_enable {
457            s.write_bool(v);
458        }
459        for &v in &self.win_main_enable {
460            s.write_bool(v);
461        }
462        for &v in &self.win_sub_enable {
463            s.write_bool(v);
464        }
465        s.write_bool(self.direct_color);
466        s.write_bool(self.add_subscreen);
467        s.write_u8(self.color_window_above);
468        s.write_u8(self.color_window_below);
469        for &v in &self.color_math_enable {
470            s.write_bool(v);
471        }
472        s.write_bool(self.color_halve);
473        s.write_bool(self.color_subtract);
474        s.write_u16(self.fixed_color);
475        s.write_bool(self.interlace);
476        s.write_bool(self.obj_interlace);
477        s.write_bool(self.overscan);
478        s.write_bool(self.pseudo_hires);
479        s.write_bool(self.extbg);
480        s.write_u16(self.obj_tiledata_addr);
481        s.write_u16(self.obj_nameselect);
482        s.write_u8(self.obj_base_size);
483        s.write_u16(self.latch_h);
484        s.write_u16(self.latch_v);
485        s.write_bool(self.counter_latched);
486        s.write_bool(self.ophct_high_toggle);
487        s.write_bool(self.opvct_high_toggle);
488        s.write_bool(self.range_over);
489        s.write_bool(self.time_over);
490        s.write_u8(self.ppu1_mdr);
491        s.write_u8(self.ppu2_mdr);
492    }
493
494    /// The inverse of [`Self::save_state`].
495    ///
496    /// # Errors
497    /// [`SaveStateError`] on truncated/corrupt input. No field here is used as an unchecked
498    /// array index: `cgram_address` (`u8`) indexes the 256-entry `cgram` exactly; `oam_address`
499    /// is masked `& 0x03ff` at every read/write site in `regs.rs` before use (never trusted
500    /// verbatim there either); `vram_address`/`vram_read_latch`-derived offsets are masked
501    /// `& 0x7fff` at every VRAM access site — so none of those needed additional masking for
502    /// memory safety. Every register a normal write constrains to a narrower width than its
503    /// storage type IS masked here to that width, though (`display_brightness`/`mosaic_size`
504    /// 4-bit, `bg_mode`/`obj_base_size` 3-bit, `bg_screen_size`/`vram_mapping`/`m7_repeat`/
505    /// `color_window_above`/`color_window_below`/`WindowLayer::mask` 2-bit), matching the same
506    /// "apply the engine's own normal-operation invariant on load" reasoning already applied
507    /// elsewhere in this project.
508    fn load_state(&mut self, s: &mut SaveReader) -> Result<(), SaveStateError> {
509        self.display_brightness = s.read_u8()? & 0x0F;
510        self.display_disable = s.read_bool()?;
511        self.bg_mode = s.read_u8()? & 0x07;
512        self.bg3_priority = s.read_bool()?;
513        for v in &mut self.tile_size {
514            *v = s.read_bool()?;
515        }
516        self.mosaic_size = s.read_u8()? & 0x0F;
517        for v in &mut self.mosaic_enable {
518            *v = s.read_bool()?;
519        }
520        for v in &mut self.bg_screen_addr {
521            *v = s.read_u16()?;
522        }
523        for v in &mut self.bg_screen_size {
524            *v = s.read_u8()? & 0x03;
525        }
526        for v in &mut self.bg_tiledata_addr {
527            *v = s.read_u16()?;
528        }
529        for v in &mut self.bg_hofs {
530            *v = s.read_u16()?;
531        }
532        for v in &mut self.bg_vofs {
533            *v = s.read_u16()?;
534        }
535        self.vram_increment_size = s.read_u16()?;
536        self.vram_mapping = s.read_u8()? & 0x03;
537        self.vram_increment_high = s.read_bool()?;
538        self.vram_address = s.read_u16()?;
539        self.vram_read_latch = s.read_u16()?;
540        self.m7_hflip = s.read_bool()?;
541        self.m7_vflip = s.read_bool()?;
542        self.m7_repeat = s.read_u8()? & 0x03;
543        self.m7a = s.read_u16()?;
544        self.m7b = s.read_u16()?;
545        self.m7c = s.read_u16()?;
546        self.m7d = s.read_u16()?;
547        self.m7x = s.read_u16()?;
548        self.m7y = s.read_u16()?;
549        self.m7_hofs = s.read_u16()?;
550        self.m7_vofs = s.read_u16()?;
551        self.cgram_address = s.read_u8()?;
552        self.cgram_latch_high = s.read_bool()?;
553        self.cgram_byte_latch = s.read_u8()?;
554        self.oam_base_address = s.read_u16()?;
555        self.oam_priority_rotation = s.read_bool()?;
556        self.oam_address = s.read_u16()?;
557        self.oam_byte_latch = s.read_u8()?;
558        self.win.load_state(s)?;
559        for v in &mut self.main_enable {
560            *v = s.read_bool()?;
561        }
562        for v in &mut self.sub_enable {
563            *v = s.read_bool()?;
564        }
565        for v in &mut self.win_main_enable {
566            *v = s.read_bool()?;
567        }
568        for v in &mut self.win_sub_enable {
569            *v = s.read_bool()?;
570        }
571        self.direct_color = s.read_bool()?;
572        self.add_subscreen = s.read_bool()?;
573        self.color_window_above = s.read_u8()? & 0x03;
574        self.color_window_below = s.read_u8()? & 0x03;
575        for v in &mut self.color_math_enable {
576            *v = s.read_bool()?;
577        }
578        self.color_halve = s.read_bool()?;
579        self.color_subtract = s.read_bool()?;
580        self.fixed_color = s.read_u16()?;
581        self.interlace = s.read_bool()?;
582        self.obj_interlace = s.read_bool()?;
583        self.overscan = s.read_bool()?;
584        self.pseudo_hires = s.read_bool()?;
585        self.extbg = s.read_bool()?;
586        self.obj_tiledata_addr = s.read_u16()?;
587        self.obj_nameselect = s.read_u16()?;
588        self.obj_base_size = s.read_u8()? & 0x07;
589        self.latch_h = s.read_u16()?;
590        self.latch_v = s.read_u16()?;
591        self.counter_latched = s.read_bool()?;
592        self.ophct_high_toggle = s.read_bool()?;
593        self.opvct_high_toggle = s.read_bool()?;
594        self.range_over = s.read_bool()?;
595        self.time_over = s.read_bool()?;
596        self.ppu1_mdr = s.read_u8()?;
597        self.ppu2_mdr = s.read_u8()?;
598        Ok(())
599    }
600}
601
602/// PPU1 (5C77) + PPU2 (5C78) state.
603///
604/// Owns VRAM (32 K words), CGRAM (256 × 15-bit BGR), OAM (544 bytes), the full register file,
605/// the dot/scanline timeline, and a 256×239 15-bit framebuffer. Advanced one dot at a time by
606/// the master-clock scheduler via [`Ppu::tick_dot`]; the scheduler polls [`Ppu::nmi_pending`],
607/// [`Ppu::irq_pending`], and [`Ppu::frame_ready`] to drive interrupts and presentation.
608#[derive(Clone)]
609pub struct Ppu {
610    /// 64 KiB of video RAM as 32 K 16-bit words (word-addressed).
611    vram: Box<[u16; 0x8000]>,
612    /// 256 palette entries, 15-bit BGR.
613    cgram: [u16; 256],
614    /// Object attribute memory: 512-byte low table + 32-byte high table.
615    oam: [u8; 544],
616
617    io: Io,
618    region: Region,
619
620    // --- Shared write latches (cross-register, so they live on the Ppu, not Io) ---
621    /// Mode-7 / scroll write-twice byte latch (`docs/ppu.md`: the M7A–M7Y + BG-offset latch).
622    mode7_byte_latch: u16,
623    /// BG-offset "previous PPU1 byte" half of the shared scroll write latch.
624    bgofs_prev1: u16,
625    /// BG-offset "previous PPU2 byte" half of the shared scroll write latch.
626    bgofs_prev2: u16,
627
628    // --- Timeline ---
629    /// Horizontal dot counter, 0..=340.
630    h: u16,
631    /// Vertical scanline counter, 0..=(lines_per_frame-1).
632    v: u16,
633    /// Interlace field (toggles each frame when interlace is on).
634    field: bool,
635    /// Currently inside vertical blank.
636    vblank: bool,
637    /// Currently inside horizontal blank.
638    hblank: bool,
639
640    // --- Interrupt / frame polls ---
641    /// NMI request, latched at `VBlank` start; cleared by [`Ppu::ack_nmi`].
642    nmi_pending: bool,
643    /// IRQ request from the HV comparator; cleared by [`Ppu::ack_irq`].
644    irq_pending: bool,
645    /// Whether the HV-IRQ comparator is armed (the scheduler/CPU programs H/V + enable).
646    irq_enable_h: bool,
647    irq_enable_v: bool,
648    irq_h: u16,
649    irq_v: u16,
650    /// Set when a full frame has been composited; cleared by [`Ppu::take_frame`].
651    frame_ready: bool,
652    /// Monotonic completed-frame counter (determinism diagnostics / save-states).
653    frame_count: u64,
654    /// Whether the *current* frame's output is hi-res (512-wide) — latched from [`Ppu::is_hires`]
655    /// at the first visible scanline of each frame (row 0) and held for the rest of that frame,
656    /// so the framebuffer's row stride stays consistent across every line of one frame even if
657    /// `BGMODE`/`SETINI` change mid-frame (`docs/ppu.md` §Hi-res (Modes 5/6) color-math
658    /// precision — a documented, deliberate per-frame-not-per-scanline simplification).
659    frame_hires: bool,
660
661    /// The composited 15-bit BGR framebuffer: [`FRAMEBUFFER_LEN`] (256×239) words for a normal
662    /// frame, [`MAX_FRAMEBUFFER_LEN`] (512×239) for a hi-res one. Backing storage is always
663    /// allocated at hi-res capacity; [`Ppu::framebuffer`] returns the resolution-sized slice.
664    framebuffer: Box<[u16; MAX_FRAMEBUFFER_LEN]>,
665
666    /// Whether [`render::render_scanline`](crate) records a [`hdtag::TileTag`] per composited
667    /// pixel into `tile_tags` this frame (`v1.3.0`, `hd-pack` feature). Off by default — a
668    /// host/frontend convenience switch, never part of `save_state`/`load_state` (the same carve-
669    /// out already established for cheats/watchpoints/voice-mutes/port2-peripheral).
670    #[cfg(feature = "hd-pack")]
671    hd_pack_tagging: bool,
672    /// Write-only tile-identity side-buffer paralleling [`Ppu::framebuffer`] pixel-for-pixel
673    /// (same indexing, same hi-res-capacity backing length — a boxed slice rather than a boxed
674    /// fixed-size array: building a ~2 MiB `[TileTag; MAX_FRAMEBUFFER_LEN]` array value before
675    /// moving it into a `Box` materializes it on the stack first in an unoptimized build, which
676    /// overflows the default thread stack; `alloc::vec!` fills the heap allocation in place,
677    /// one element at a time, with no such stack temporary). Only written when `hd_pack_tagging`
678    /// is set; when it is `false` (the default), every entry stays [`hdtag::TileTag::default`]
679    /// and `render_scanline`'s hot paths never touch it or the (feature-gated-out-entirely-when-
680    /// off) hashing helper — see `hd_pack_tagging_toggle_does_not_alter_framebuffer_output` for
681    /// the regression proof that toggling this never changes `framebuffer()`'s bytes.
682    #[cfg(feature = "hd-pack")]
683    tile_tags: Box<[hdtag::TileTag]>,
684}
685
686impl core::fmt::Debug for Ppu {
687    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
688        f.debug_struct("Ppu")
689            .field("h", &self.h)
690            .field("v", &self.v)
691            .field("vblank", &self.vblank)
692            .field("region", &self.region)
693            .field("frame_count", &self.frame_count)
694            .finish_non_exhaustive()
695    }
696}
697
698impl Default for Ppu {
699    fn default() -> Self {
700        Self::new()
701    }
702}
703
704impl Ppu {
705    /// Construct at power-on (NTSC). Storage starts zeroed; this is deterministic — the
706    /// determinism contract forbids the OS RNG (`docs/adr/0004`). Phase alignment, if any, is
707    /// driven by the scheduler's seeded PRNG, not here.
708    #[must_use]
709    pub fn new() -> Self {
710        Self::with_region(Region::Ntsc)
711    }
712
713    /// Construct at power-on for a specific [`Region`].
714    #[must_use]
715    pub fn with_region(region: Region) -> Self {
716        Self {
717            vram: Box::new([0u16; 0x8000]),
718            cgram: [0u16; 256],
719            oam: [0u8; 544],
720            io: Io::default(),
721            region,
722            mode7_byte_latch: 0,
723            bgofs_prev1: 0,
724            bgofs_prev2: 0,
725            h: 0,
726            v: 0,
727            field: false,
728            vblank: false,
729            hblank: false,
730            nmi_pending: false,
731            irq_pending: false,
732            irq_enable_h: false,
733            irq_enable_v: false,
734            irq_h: 0,
735            irq_v: 0,
736            frame_ready: false,
737            frame_count: 0,
738            frame_hires: false,
739            framebuffer: Box::new([0u16; MAX_FRAMEBUFFER_LEN]),
740            #[cfg(feature = "hd-pack")]
741            hd_pack_tagging: false,
742            #[cfg(feature = "hd-pack")]
743            tile_tags: alloc::vec![hdtag::TileTag::default(); MAX_FRAMEBUFFER_LEN]
744                .into_boxed_slice(),
745        }
746    }
747
748    /// Set the video region (NTSC/PAL). Affects line count + the STAT78 region bit.
749    pub const fn set_region(&mut self, region: Region) {
750        self.region = region;
751    }
752
753    /// The active video region (NTSC/PAL).
754    #[must_use]
755    pub const fn region(&self) -> Region {
756        self.region
757    }
758
759    /// The total visible height for the current overscan setting (224 or 239).
760    #[must_use]
761    pub const fn visible_height(&self) -> u16 {
762        if self.io.overscan { 239 } else { 224 }
763    }
764
765    /// Whether the PPU is *currently configured* for hi-res output: `BGMODE` 5/6, or pseudo-hires
766    /// (`SETINI` $2133 bit 3) in any mode — matching ares' `hires = pseudoHires || bgMode==5 ||
767    /// bgMode==6` (`ref-proj/ares/ares/sfc/ppu/dac.cpp`). This is the live per-scanline register
768    /// state; [`Ppu::frame_hires`] is the value latched from this at the start of the frame that's
769    /// actually being composited into the framebuffer right now.
770    #[must_use]
771    pub const fn is_hires(&self) -> bool {
772        self.io.pseudo_hires || self.io.bg_mode == 5 || self.io.bg_mode == 6
773    }
774
775    /// Whether the framebuffer *currently being composited* is hi-res (512-wide) — the value
776    /// [`Ppu::framebuffer`]'s length/stride is based on for this frame. See the `frame_hires`
777    /// field doc for why this is latched once per frame rather than read live per scanline.
778    #[must_use]
779    pub const fn frame_hires(&self) -> bool {
780        self.frame_hires
781    }
782
783    /// The output framebuffer's width for the frame currently being composited: [`SCREEN_WIDTH`]
784    /// normally, [`MAX_SCREEN_WIDTH`] when [`Ppu::frame_hires`] is set.
785    #[must_use]
786    pub const fn visible_width(&self) -> usize {
787        if self.frame_hires {
788            MAX_SCREEN_WIDTH
789        } else {
790            SCREEN_WIDTH
791        }
792    }
793
794    /// First `VBlank` scanline for the current overscan setting (225 or 240).
795    #[must_use]
796    const fn vblank_line(&self) -> u16 {
797        if self.io.overscan { 240 } else { 225 }
798    }
799
800    /// Advance the PPU by exactly one dot, the scheduler's video quantum. Composites a full
801    /// visible scanline at [`RENDER_DOT`] (one dot before that line's own per-line HDMA run can
802    /// observe/mutate the registers the composite reads — see `docs/ppu.md` §Mid-scanline/
803    /// HDMA-driven register timing); raises NMI at `VBlank` start and the HV-IRQ when the
804    /// programmed H/V is hit; calls [`VideoBus::notify_scanline`] at each line start and
805    /// [`VideoBus::notify_vblank`] when `VBlank` begins. Hot path: allocation-free.
806    pub fn tick_dot(&mut self, bus: &mut impl VideoBus) {
807        // HBlank region: dots 274..=340 (active output ends near dot 274).
808        self.hblank = self.h >= 274 || self.h < ACTIVE_DOT_START;
809
810        // HV-IRQ comparator (level): fire when the enabled H and/or V positions match.
811        self.check_hv_irq();
812
813        // Composite the line that's finishing (V is the line number 1..=visible) using register
814        // state as it stood just BEFORE this line's own HDMA run, not after (`docs/ppu.md`'s
815        // confirmed off-by-one-line fix). `advance_master` services this line's HDMA at this
816        // same dot, strictly AFTER this render call within the same master-clock tick (the HDMA
817        // check runs after `tick_ppu_dot` returns) -- see `docs/scheduler.md` §DMA/HDMA bus-steal.
818        if self.h == RENDER_DOT && self.v >= 1 && self.v <= self.visible_height() {
819            self.render_scanline(bus);
820        }
821
822        self.h += 1;
823        if self.h >= DOTS_PER_LINE {
824            // End of a scanline: advance V and fire frame/VBlank events. Rendering already
825            // happened above at RENDER_DOT, not here.
826            self.h = 0;
827            self.end_of_scanline(bus);
828        }
829    }
830
831    /// At the end of a scanline: advance V, and fire frame/VBlank events. Rendering happens
832    /// earlier, at [`RENDER_DOT`] within [`Ppu::tick_dot`] -- see that method's doc.
833    fn end_of_scanline(&mut self, bus: &mut impl VideoBus) {
834        self.v += 1;
835        let lines = self.region.lines_per_frame();
836        if self.v >= lines {
837            self.v = 0;
838            self.field = !self.field;
839            // A new frame begins; the composited buffer for the prior frame is ready.
840            self.frame_ready = true;
841            self.frame_count = self.frame_count.wrapping_add(1);
842            // Sprite over-flags reset at end of VBlank (start of new frame).
843            self.io.range_over = false;
844            self.io.time_over = false;
845        }
846
847        // Notify the board of the new scanline.
848        bus.notify_scanline();
849
850        // VBlank starts at V=225 (or 240 in overscan).
851        let vbl = self.vblank_line();
852        if self.v == vbl {
853            self.vblank = true;
854            self.nmi_pending = true;
855            bus.notify_vblank();
856        } else if self.v == 0 {
857            self.vblank = false;
858        }
859    }
860
861    /// Level-evaluate the HV-IRQ comparator at the current (h, v).
862    ///
863    /// The horizontal match is asserted [`HIRQ_TRIGGER_DELAY`] dots *after* the programmed
864    /// `HTIME`, modelling the SNES's hardware communication delay between the H/V counter unit
865    /// and the CPU's interrupt logic. ares encodes this as `hcounter(10) == io.htime` with
866    /// `io.htime` stored as `(HTIME + 1) << 2` clocks (`sfc/cpu/irq.cpp`, `sfc/cpu/io.cpp`), i.e.
867    /// the IRQ fires at hcounter `HTIME*4 + 14` = dot `HTIME + 3.5`. Without this delay an
868    /// IRQ-gated register write (e.g. the `hdmaen_latch_test` `STA $420C`) lands ~3–4 dots early,
869    /// which — combined with the dot-1104 HDMA latch — collapses the test's banded HDMAEN-vs-latch
870    /// crossing into a uniform per-line alternation.
871    const fn check_hv_irq(&mut self) {
872        // Adding the delay can push the target past the end of the line. On hardware the H counter
873        // never reaches those values (ares' stored `(HTIME+1)<<2 + 10` clocks then exceeds the max
874        // hcounter), so the IRQ simply never fires for such HTIME — suppress rather than wrap into
875        // the next line, which would be a spurious match hardware/ares never produce.
876        let h_target = self.irq_h + HIRQ_TRIGGER_DELAY;
877        let h_hit = h_target < DOTS_PER_LINE && self.h == h_target;
878        let h_match = !self.irq_enable_h || h_hit;
879        let v_match = !self.irq_enable_v || self.v == self.irq_v;
880        if (self.irq_enable_h || self.irq_enable_v) && h_match && v_match {
881            self.irq_pending = true;
882        }
883    }
884
885    // --- Interrupt / frame polls (the scheduler reads these) ---
886
887    /// Whether an NMI is pending (asserted at `VBlank` start). Poll; clear with [`Ppu::ack_nmi`].
888    #[must_use]
889    pub const fn nmi_pending(&self) -> bool {
890        self.nmi_pending
891    }
892
893    /// Acknowledge / clear the pending NMI (the CPU does this when it takes the vector).
894    pub const fn ack_nmi(&mut self) {
895        self.nmi_pending = false;
896    }
897
898    /// Whether an HV-timer IRQ is pending. Poll; clear with [`Ppu::ack_irq`].
899    #[must_use]
900    pub const fn irq_pending(&self) -> bool {
901        self.irq_pending
902    }
903
904    /// Acknowledge / clear the pending IRQ.
905    pub const fn ack_irq(&mut self) {
906        self.irq_pending = false;
907    }
908
909    /// Program the HV-IRQ comparator (the CPU writes `$4200`/`$4207`–`$420A`; the scheduler
910    /// forwards them here so the PPU owns the comparison against its own H/V phase).
911    pub const fn set_hv_irq(&mut self, enable_h: bool, enable_v: bool, h: u16, v: u16) {
912        self.irq_enable_h = enable_h;
913        self.irq_enable_v = enable_v;
914        self.irq_h = h;
915        self.irq_v = v;
916    }
917
918    /// Whether the PPU is currently in vertical blank.
919    #[must_use]
920    pub const fn in_vblank(&self) -> bool {
921        self.vblank
922    }
923
924    /// Whether the PPU is currently in horizontal blank.
925    #[must_use]
926    pub const fn in_hblank(&self) -> bool {
927        self.hblank
928    }
929
930    /// The current horizontal dot counter (0..=340).
931    #[must_use]
932    pub const fn dot(&self) -> u16 {
933        self.h
934    }
935
936    /// The current vertical scanline counter.
937    #[must_use]
938    pub const fn scanline(&self) -> u16 {
939        self.v
940    }
941
942    /// Latch the current H/V dot counters into `OPHCT`/`OPVCT` ($213C/$213D) right now — the same
943    /// effect a CPU read of `SLHV` ($2137) has (`regs.rs`'s `0x2137` arm calls this too, so there
944    /// is one implementation of the latch itself). Real hardware also drives this from the WRIO
945    /// ($4201) I/O port's bit7 falling edge (the controller-port-2 IOBIT pin, wired straight to
946    /// this latch — a Super Scope's light sensor toggles it to record the CRT beam position when
947    /// it "sees" it, `rustysnes_core::controller`); exposed as `pub` so the Bus (which owns WRIO,
948    /// not the PPU) can trigger the identical latch from that path without duplicating it.
949    pub const fn latch_hv_counters(&mut self) {
950        self.io.latch_h = self.h;
951        self.io.latch_v = self.v;
952        self.io.counter_latched = true;
953    }
954
955    /// Whether a finished frame is available. Cleared by [`Ppu::take_frame`].
956    #[must_use]
957    pub const fn frame_ready(&self) -> bool {
958        self.frame_ready
959    }
960
961    /// The current `BGMODE` ($2105) value (0..=7) — which of the 8 tile/priority layouts is
962    /// active. For the debugger overlay's PPU panel (`docs/frontend.md` §Debugger overlay).
963    #[must_use]
964    pub const fn bg_mode(&self) -> u8 {
965        self.io.bg_mode
966    }
967
968    /// The current `INIDISP` ($2100) master brightness (0..=15). For the debugger overlay.
969    #[must_use]
970    pub const fn display_brightness(&self) -> u8 {
971        self.io.display_brightness
972    }
973
974    /// Completed-frame count since power-on (monotonic, wrapping).
975    #[must_use]
976    pub const fn frame_count(&self) -> u64 {
977        self.frame_count
978    }
979
980    /// The composited framebuffer: `visible_width() * SCREEN_HEIGHT` 15-bit BGR pixels, row-major
981    /// at the current frame's stride ([`SCREEN_WIDTH`] normally, [`MAX_SCREEN_WIDTH`] for a
982    /// hi-res frame — see [`Ppu::frame_hires`]). Only the first [`Ppu::visible_height`] rows are
983    /// written each frame. A non-hires frame's length here is exactly [`FRAMEBUFFER_LEN`],
984    /// unchanged from before hi-res output existed.
985    #[must_use]
986    pub fn framebuffer(&self) -> &[u16] {
987        &self.framebuffer[..self.visible_width() * SCREEN_HEIGHT]
988    }
989
990    /// Borrow the framebuffer and clear the `frame_ready` flag (one-shot presentation).
991    pub fn take_frame(&mut self) -> &[u16] {
992        self.frame_ready = false;
993        &self.framebuffer[..self.visible_width() * SCREEN_HEIGHT]
994    }
995
996    /// Enable/disable per-pixel [`hdtag::TileTag`] recording (`v1.3.0`, `hd-pack` feature).
997    ///
998    /// Off by default. Toggling this never changes [`Ppu::framebuffer`]'s bytes for the same
999    /// input — only whether [`Ppu::tile_tags`] gets populated alongside it (see
1000    /// `hd_pack_tagging_toggle_does_not_alter_framebuffer_output` in this module's tests for the
1001    /// regression proof). Turning tagging OFF also clears every entry back to
1002    /// [`hdtag::TileTag::default`] — without this, a caller could otherwise observe stale tags
1003    /// from the last frame tagging was on, contradicting [`Ppu::tile_tags`]'s own "every entry is
1004    /// default unless tagging was on while that pixel was rendered" guarantee. A host/frontend
1005    /// convenience switch, never part of `save_state`/`load_state`.
1006    #[cfg(feature = "hd-pack")]
1007    pub fn set_hd_pack_tagging(&mut self, enabled: bool) {
1008        self.hd_pack_tagging = enabled;
1009        if !enabled {
1010            self.tile_tags.fill(hdtag::TileTag::default());
1011        }
1012    }
1013
1014    /// Whether [`hdtag::TileTag`] recording is currently enabled.
1015    #[cfg(feature = "hd-pack")]
1016    #[must_use]
1017    pub const fn hd_pack_tagging(&self) -> bool {
1018        self.hd_pack_tagging
1019    }
1020
1021    /// The tile-identity side-buffer for the frame currently composited — same length/indexing
1022    /// as [`Ppu::framebuffer`]. Every entry is [`hdtag::TileTag::default`] (hash `0`) unless
1023    /// [`Ppu::set_hd_pack_tagging`] was on while that pixel was rendered.
1024    #[cfg(feature = "hd-pack")]
1025    #[must_use]
1026    pub fn tile_tags(&self) -> &[hdtag::TileTag] {
1027        &self.tile_tags[..self.visible_width() * SCREEN_HEIGHT]
1028    }
1029
1030    // --- Storage accessors (for tests / save-state plumbing) ---
1031
1032    /// Read a VRAM word directly (test/diagnostic; not the register path).
1033    #[must_use]
1034    pub fn vram_word(&self, addr: u16) -> u16 {
1035        self.vram[(addr & 0x7fff) as usize]
1036    }
1037
1038    /// The full 64 KiB VRAM as a flat word slice (32Ki x `u16`, native word addressing) — for a
1039    /// host embedder that needs a raw memory-map pointer (e.g. a libretro core's
1040    /// `RETRO_MEMORY_VIDEO_RAM`).
1041    #[must_use]
1042    pub fn vram(&self) -> &[u16] {
1043        &*self.vram
1044    }
1045
1046    /// The mutable counterpart to [`Self::vram`] — same host-embedder use case.
1047    pub fn vram_mut(&mut self) -> &mut [u16] {
1048        &mut *self.vram
1049    }
1050
1051    /// Read a CGRAM entry directly (test/diagnostic).
1052    #[must_use]
1053    pub const fn cgram_word(&self, index: u8) -> u16 {
1054        self.cgram[index as usize]
1055    }
1056
1057    /// Read an OAM byte directly (test/diagnostic).
1058    #[must_use]
1059    pub const fn oam_byte(&self, index: u16) -> u8 {
1060        self.oam[(index as usize) % 544]
1061    }
1062
1063    /// Write VRAM/CGRAM/OAM, the full register file, the write latches, the dot/scanline
1064    /// timeline, the interrupt/frame poll state, and the composited framebuffer into a `"PPU0"`
1065    /// section. There is no firmware/ROM byte here to exclude — the PPU carries no chip-ROM
1066    /// dump (`docs/adr/0003`); `region` is written too since it's set by cart detection, not
1067    /// re-derivable from anything else stored here.
1068    pub fn save_state(&self, w: &mut SaveWriter) {
1069        w.section(*b"PPU0", |s| {
1070            for &word in self.vram.iter() {
1071                s.write_u16(word);
1072            }
1073            for &word in &self.cgram {
1074                s.write_u16(word);
1075            }
1076            s.write_bytes(&self.oam);
1077            self.io.save_state(s);
1078            s.write_u8(match self.region {
1079                Region::Ntsc => 0,
1080                Region::Pal => 1,
1081            });
1082            s.write_u16(self.mode7_byte_latch);
1083            s.write_u16(self.bgofs_prev1);
1084            s.write_u16(self.bgofs_prev2);
1085            s.write_u16(self.h);
1086            s.write_u16(self.v);
1087            s.write_bool(self.field);
1088            s.write_bool(self.vblank);
1089            s.write_bool(self.hblank);
1090            s.write_bool(self.nmi_pending);
1091            s.write_bool(self.irq_pending);
1092            s.write_bool(self.irq_enable_h);
1093            s.write_bool(self.irq_enable_v);
1094            s.write_u16(self.irq_h);
1095            s.write_u16(self.irq_v);
1096            s.write_bool(self.frame_ready);
1097            s.write_u64(self.frame_count);
1098            s.write_bool(self.frame_hires);
1099            for &word in self.framebuffer.iter() {
1100                s.write_u16(word);
1101            }
1102        });
1103    }
1104
1105    /// The inverse of [`Self::save_state`].
1106    ///
1107    /// # Errors
1108    /// [`SaveStateError`] on truncated/corrupt input, a section with unconsumed trailing bytes,
1109    /// or [`SaveStateError::Invalid`] if the encoded `region` discriminant doesn't match one of
1110    /// [`Region`]'s two variants (a semantic enum constraint, not a hardware register width).
1111    pub fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
1112        let mut s = r.expect_section(*b"PPU0")?;
1113        for word in self.vram.iter_mut() {
1114            *word = s.read_u16()?;
1115        }
1116        for word in &mut self.cgram {
1117            *word = s.read_u16()? & 0x7FFF;
1118        }
1119        self.oam.copy_from_slice(s.read_bytes(544)?);
1120        self.io.load_state(&mut s)?;
1121        let region = s.read_u8()?;
1122        self.region = match region {
1123            0 => Region::Ntsc,
1124            1 => Region::Pal,
1125            _ => {
1126                return Err(SaveStateError::Invalid(alloc::format!(
1127                    "Ppu region discriminant {region} is not a valid Region variant (0-1)"
1128                )));
1129            }
1130        };
1131        self.mode7_byte_latch = s.read_u16()?;
1132        self.bgofs_prev1 = s.read_u16()?;
1133        self.bgofs_prev2 = s.read_u16()?;
1134        self.h = s.read_u16()?;
1135        self.v = s.read_u16()?;
1136        self.field = s.read_bool()?;
1137        self.vblank = s.read_bool()?;
1138        self.hblank = s.read_bool()?;
1139        self.nmi_pending = s.read_bool()?;
1140        self.irq_pending = s.read_bool()?;
1141        self.irq_enable_h = s.read_bool()?;
1142        self.irq_enable_v = s.read_bool()?;
1143        self.irq_h = s.read_u16()?;
1144        self.irq_v = s.read_u16()?;
1145        self.frame_ready = s.read_bool()?;
1146        self.frame_count = s.read_u64()?;
1147        self.frame_hires = s.read_bool()?;
1148        for word in self.framebuffer.iter_mut() {
1149            *word = s.read_u16()? & 0x7FFF;
1150        }
1151        if s.remaining() != 0 {
1152            return Err(SaveStateError::Invalid(alloc::format!(
1153                "PPU0 section has {} trailing byte(s)",
1154                s.remaining()
1155            )));
1156        }
1157        Ok(())
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use crate::bus::NullVideoBus;
1165
1166    #[test]
1167    fn constructs() {
1168        let p = Ppu::new();
1169        assert_eq!(p.framebuffer().len(), FRAMEBUFFER_LEN);
1170        assert_eq!(p.region, Region::Ntsc);
1171    }
1172
1173    #[test]
1174    fn full_state_round_trips_through_save_state() {
1175        let mut p = Ppu::with_region(Region::Pal);
1176        p.io.bg_mode = 7;
1177        p.io.vram_address = 0x1234;
1178        p.vram[0x100] = 0xBEEF;
1179        p.cgram[10] = 0x7FFF;
1180        p.oam[5] = 0x42;
1181        p.h = 200;
1182        p.v = 150;
1183        p.frame_count = 99;
1184
1185        let mut w = SaveWriter::new();
1186        p.save_state(&mut w);
1187        let bytes = w.into_bytes();
1188
1189        let mut fresh = Ppu::new();
1190        let mut r = SaveReader::new(&bytes);
1191        fresh.load_state(&mut r).unwrap();
1192
1193        assert_eq!(fresh.region, Region::Pal);
1194        assert_eq!(fresh.io.bg_mode, 7);
1195        assert_eq!(fresh.io.vram_address, 0x1234);
1196        assert_eq!(fresh.vram[0x100], 0xBEEF);
1197        assert_eq!(fresh.cgram[10], 0x7FFF);
1198        assert_eq!(fresh.oam[5], 0x42);
1199        assert_eq!(fresh.h, 200);
1200        assert_eq!(fresh.v, 150);
1201        assert_eq!(fresh.frame_count, 99);
1202        assert_eq!(r.remaining(), 0);
1203    }
1204
1205    #[test]
1206    fn out_of_range_region_discriminant_is_rejected_not_panicked_on() {
1207        let p = Ppu::new();
1208        let mut w = SaveWriter::new();
1209        p.save_state(&mut w);
1210        let mut bytes = w.into_bytes();
1211
1212        // The region byte follows VRAM + CGRAM + OAM + the whole Io section. Skip past those by
1213        // replaying load_state's own field order (rather than hardcoding a byte offset), so this
1214        // stays correct if a field is added/removed above it.
1215        let mut r = SaveReader::new(&bytes);
1216        let mut s = r.expect_section(*b"PPU0").unwrap();
1217        for _ in 0..0x8000 {
1218            s.read_u16().unwrap(); // vram
1219        }
1220        for _ in 0..256 {
1221            s.read_u16().unwrap(); // cgram
1222        }
1223        s.read_bytes(544).unwrap(); // oam
1224        let mut io_scratch = Io::default();
1225        io_scratch.load_state(&mut s).unwrap(); // consumes exactly one Io's worth of bytes
1226
1227        let offset = bytes.len() - s.remaining();
1228        bytes[offset] = 99;
1229
1230        let mut fresh = Ppu::new();
1231        let mut r2 = SaveReader::new(&bytes);
1232        assert!(matches!(
1233            fresh.load_state(&mut r2),
1234            Err(SaveStateError::Invalid(_))
1235        ));
1236    }
1237
1238    #[test]
1239    fn counter_wraps_at_341_and_262() {
1240        let mut p = Ppu::new();
1241        let mut bus = NullVideoBus;
1242        // One full NTSC frame = 341 * 262 dots.
1243        let total = u32::from(DOTS_PER_LINE) * u32::from(Region::Ntsc.lines_per_frame());
1244        for _ in 0..total {
1245            p.tick_dot(&mut bus);
1246        }
1247        assert_eq!(p.h, 0);
1248        assert_eq!(p.v, 0);
1249        assert_eq!(p.frame_count, 1);
1250    }
1251
1252    #[test]
1253    fn vblank_asserts_at_225() {
1254        let mut p = Ppu::new();
1255        let mut bus = NullVideoBus;
1256        // Tick until V reaches 225.
1257        while p.v != 225 {
1258            p.tick_dot(&mut bus);
1259        }
1260        assert!(p.vblank);
1261        assert!(p.nmi_pending);
1262    }
1263
1264    #[test]
1265    fn vblank_at_240_with_overscan() {
1266        let mut p = Ppu::new();
1267        let mut bus = NullVideoBus;
1268        // Enable overscan via SETINI.
1269        p.write_reg(0x2133, 0x04);
1270        while p.v != 240 {
1271            p.tick_dot(&mut bus);
1272        }
1273        assert!(p.vblank);
1274    }
1275
1276    #[test]
1277    fn hv_irq_fires_at_programmed_position() {
1278        let mut p = Ppu::new();
1279        let mut bus = NullVideoBus;
1280        p.set_hv_irq(true, true, 100, 50);
1281        let mut fired_at = None;
1282        for _ in 0..(u32::from(DOTS_PER_LINE) * 60) {
1283            p.tick_dot(&mut bus);
1284            if p.irq_pending() && fired_at.is_none() {
1285                fired_at = Some((p.dot(), p.scanline()));
1286                p.ack_irq();
1287            }
1288        }
1289        assert!(fired_at.is_some());
1290        let (h, v) = fired_at.unwrap();
1291        // The H comparator lags HTIME by `HIRQ_TRIGGER_DELAY` dots (hardware counter→IRQ
1292        // communication delay; see `check_hv_irq`), and it is evaluated at the start of tick
1293        // before H increments, so the IRQ is observed at dot `HTIME + HIRQ_TRIGGER_DELAY` (or the
1294        // dot after) on the programmed scanline.
1295        assert_eq!(v, 50);
1296        let target = 100 + HIRQ_TRIGGER_DELAY;
1297        assert!(h == target || h == target + 1);
1298    }
1299
1300    #[test]
1301    fn frame_ready_toggles_once_per_frame() {
1302        let mut p = Ppu::new();
1303        let mut bus = NullVideoBus;
1304        let total = u32::from(DOTS_PER_LINE) * u32::from(Region::Ntsc.lines_per_frame());
1305        for _ in 0..total {
1306            p.tick_dot(&mut bus);
1307        }
1308        assert!(p.frame_ready());
1309        let _ = p.take_frame();
1310        assert!(!p.frame_ready());
1311    }
1312
1313    #[test]
1314    fn pal_has_312_lines() {
1315        let p = Ppu::with_region(Region::Pal);
1316        assert_eq!(p.region.lines_per_frame(), 312);
1317    }
1318
1319    #[test]
1320    fn clone_is_independent() {
1321        let mut p = Ppu::new();
1322        // VMAIN increment-on-high so L then H both land at the same word address.
1323        p.write_reg(0x2115, 0x80);
1324        p.write_reg(0x2116, 0x00);
1325        p.write_reg(0x2117, 0x00);
1326        p.write_reg(0x2118, 0x34);
1327        p.write_reg(0x2119, 0x12);
1328        let q = p.clone();
1329        assert_eq!(q.vram_word(0), 0x1234);
1330    }
1331
1332    #[test]
1333    fn vram_and_vram_mut_expose_the_same_flat_64kib() {
1334        let mut p = Ppu::new();
1335        assert_eq!(p.vram().len(), 0x8000);
1336        p.write_reg(0x2115, 0x80);
1337        p.write_reg(0x2116, 0x00);
1338        p.write_reg(0x2117, 0x00);
1339        p.write_reg(0x2118, 0x34);
1340        p.write_reg(0x2119, 0x12);
1341        assert_eq!(p.vram()[0], 0x1234);
1342        p.vram_mut()[1] = 0xABCD;
1343        assert_eq!(p.vram_word(1), 0xABCD);
1344    }
1345}