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}