rustynes_ppu/ppu.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: this PPU contains code derived from Mesen2 (GPL-3.0-or-later): the sprite-evaluation FSM and OAM-data-bus model, `Core/NES/NesPpu.cpp` (`ProcessSpriteEvaluation` / `ReadSpriteRam`); it also incorporates models ported from TriCNES (MIT) — the ALE / octal-latch address-multiplex and the OAM-corruption behavior. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! 2C02 PPU core: state, register surface, scanline counter, NMI signaling.
6//!
7//! See `docs/ppu-2c02.md`. Background and sprite *rendering* (per-dot tile
8//! fetch, shift registers, sprite evaluation, sprite-zero hit) is plumbed
9//! through this struct but the visible-pixel output path is filled in by
10//! Sprints 2-2 and 2-3 — the surface and scanline FSM here is what
11//! Sprint 2-1 delivers.
12
13use crate::bus::{BgSplitState, ExAttribute, PpuBus};
14use crate::palette::{build_rgba_lut, build_rgba_lut_from_base};
15use crate::registers::{PpuCtrl, PpuMask, PpuStatus};
16use alloc::boxed::Box;
17use alloc::vec;
18
19/// Visible screen width in pixels, and the stride of every per-pixel buffer the
20/// PPU exposes.
21///
22/// v2.3.8 — the single definition. `provenance::SCREEN_W` was a second copy of
23/// this number in a `debug-hooks`-gated module, which made the width
24/// unreachable from ungated code and invited a third copy rather than a
25/// dependency. It now aliases this.
26pub const SCREEN_WIDTH: usize = 256;
27
28/// Visible screen height in pixels. Companion to [`SCREEN_WIDTH`].
29pub const SCREEN_HEIGHT: usize = 240;
30
31/// RGBA8 framebuffer length in bytes (256 × 240 × 4).
32pub const FRAMEBUFFER_LEN: usize = SCREEN_WIDTH * SCREEN_HEIGHT * 4;
33
34/// Visible pixel count (256 × 240) — length of the parallel
35/// [`Ppu::index_framebuffer`] (one `u16` per pixel).
36pub const FRAMEBUFFER_PIXELS: usize = SCREEN_WIDTH * SCREEN_HEIGHT;
37
38/// v1.2.0 beta.2 (Workstream C3) — per-pixel HD-pack tile-source record.
39///
40/// One entry per visible pixel (parallel to [`Ppu::index_framebuffer`]),
41/// populated in the pixel-emit path only when the `hd-pack` cargo feature is
42/// enabled. It records the **identity of the CHR tile** that produced the
43/// pixel — the 16-byte pattern-table tile base address (in PPU `$0000..=$1FFF`
44/// pattern space, fine-Y masked off), the 2-bit attribute/sprite palette, the
45/// sprite flip flags, and whether the source was a sprite or the background.
46///
47/// It is pure output telemetry: it mirrors data the renderer already computed,
48/// reads no new VRAM, and changes no emulation state — so it is byte-identical
49/// with the feature on or off and is never serialized into a save-state. The
50/// frontend's Mesen-style HD-pack loader groups these by 8×8 screen cell, hashes
51/// the referenced CHR bytes, and substitutes hi-res replacement tiles at blit
52/// time. See `docs/ppu-2c02.md` §HD-pack tile-source export.
53#[cfg(feature = "hd-pack")]
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub struct HdTileSource {
56 /// 16-byte CHR tile base address in pattern space (`$0000..=$1FF0`, low
57 /// nibble always 0). `tile = (addr >> 4) & 0xFF`, `table = addr & 0x1000`.
58 /// `0xFFFF` marks a transparent / universal-background pixel (no tile).
59 pub chr_addr: u16,
60 /// Final palette group: BG attribute (0..=3) or sprite palette (0..=3).
61 pub palette: u8,
62 /// `true` when the pixel came from a sprite (vs. the background).
63 pub is_sprite: bool,
64 /// Sprite horizontal flip (always `false` for BG pixels).
65 pub flip_h: bool,
66 /// Sprite vertical flip (always `false` for BG pixels).
67 pub flip_v: bool,
68 /// Mesen `PaletteColors`: the tile's active palette packed as the HD-pack
69 /// tile-identity key expects. BG = `pr[base+3] | pr[base+2]<<8 |
70 /// pr[base+1]<<16 | pr[0]<<24`; sprite = `0xFF000000 | pr[base+3] |
71 /// pr[base+2]<<8 | pr[base+1]<<16` (top byte `0xFF` = the sprite/BG
72 /// discriminator). Part of the CHR-RAM/CHR-ROM tile key (HdNesPpu.h:119/167).
73 pub palette_colors: u32,
74 /// Which texel COLUMN (0..=7) of the 8x8 tile this screen pixel samples —
75 /// Mesen `OffsetX` (HdNesPpu.h:172). For BG it folds in fine-X scroll
76 /// (`(fineX + (pixel_x & 7)) & 7`); for a sprite it is the column within the
77 /// sprite tile. The HD compositor samples the replacement at this column so
78 /// the high-def tile tracks the scrolled / sprite position pixel-for-pixel
79 /// (flips are applied at sample time). Output-only.
80 pub offset_x: u8,
81 /// Which texel ROW (0..=7) of the 8x8 tile this screen pixel samples — Mesen
82 /// `OffsetY`. BG = fine-Y; sprite = the row within the sprite tile.
83 pub offset_y: u8,
84 /// Mesen CHR-ROM `TileIndex`: the ABSOLUTE post-banking CHR-ROM tile number
85 /// (`chr_phys(addr) / 16`) for a CHR-ROM cart, or [`HD_CHR_RAM`] when CHR is
86 /// RAM (the tile is content-hashed instead). The HD-pack key uses
87 /// `TileIndex ^ PaletteColors` for CHR-ROM and `CalculateHash(palette ++
88 /// data)` for CHR-RAM (Mesen `HdTileKey`). Captured at fetch (the only point
89 /// with mapper access). Output-only.
90 pub chr_tile_index: u32,
91 /// v1.8.9 — the `$2001` grayscale + emphasis bits at this pixel
92 /// (`mask.bits() & 0xE1`: bit 0 = grayscale, bits 5-7 = R/G/B emphasis). The
93 /// HD compositor re-applies them to the replacement texel (Mesen
94 /// `ProcessGrayscaleAndEmphasis`) so HD tiles track grayscale / emphasis fades
95 /// like the base frame, which already has them baked in. Output-only.
96 pub color_mask: u8,
97 /// v1.8.9 — every opaque sprite covering this pixel (up to 4), front-to-back,
98 /// for Mesen `spriteAtPosition` / `spriteNearby` (which match ANY covering
99 /// sprite, including ones a higher-priority BG occludes). `sprites[0]` is the
100 /// front-most. The existing `is_sprite` + tile fields still describe the
101 /// VISIBLE pixel (the winning layer); these add the hidden layers. Output-only.
102 pub sprites: [HdSprite; 4],
103 /// Number of valid entries in [`Self::sprites`] (`0..=4`).
104 pub sprite_count: u8,
105}
106
107/// One sprite covering a pixel, for the HD-pack multi-sprite conditions
108/// (`spriteAtPosition` / `spriteNearby`). Carries just the identity those
109/// conditions match on. Output-only telemetry.
110#[cfg(feature = "hd-pack")]
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112pub struct HdSprite {
113 /// Absolute CHR-ROM tile index (`chr_phys/16`), or [`HD_CHR_RAM`] for CHR-RAM.
114 pub chr_tile_index: u32,
115 /// The sprite's packed `PaletteColors` (Mesen key form).
116 pub palette_colors: u32,
117}
118
119/// `chr_tile_index` sentinel meaning "CHR is RAM" — the tile is keyed by its 16
120/// CHR bytes (content) rather than by an absolute CHR-ROM tile index.
121#[cfg(feature = "hd-pack")]
122pub const HD_CHR_RAM: u32 = u32::MAX;
123
124#[cfg(feature = "hd-pack")]
125impl Default for HdTileSource {
126 /// A blank record: no tile, and the CHR-RAM sentinel (so an unwritten /
127 /// default record is content-keyed, never mistaken for CHR-ROM tile 0).
128 fn default() -> Self {
129 Self {
130 chr_addr: 0,
131 palette: 0,
132 is_sprite: false,
133 flip_h: false,
134 flip_v: false,
135 palette_colors: 0,
136 offset_x: 0,
137 offset_y: 0,
138 chr_tile_index: HD_CHR_RAM,
139 color_mask: 0,
140 sprites: [HdSprite::default(); 4],
141 sprite_count: 0,
142 }
143 }
144}
145
146/// Sentinel `chr_addr` for a transparent / universal-background HD-pack pixel.
147#[cfg(feature = "hd-pack")]
148pub const HD_TILE_NONE: u16 = 0xFFFF;
149
150/// Diagnostic: capture the (frame, scanline, dot, mask) of `$2007` reads to
151/// pin where the `$2007 Stress` test's per-dot reads land vs the visible
152/// scanline they target. Gated; default build unaffected.
153pub mod read2007_diag {
154 use core::sync::atomic::AtomicU32;
155 /// Next free slot (count of captured `$2007` reads).
156 pub static IDX: AtomicU32 = AtomicU32::new(0);
157 /// Packed: `((scanline+1)<<18) | (dot<<5) | (rendering_enabled<<1) | is_render`.
158 pub static LOG: [AtomicU32; 1024] = [const { AtomicU32::new(0) }; 1024];
159 /// Tunable PPU-dot countdown: `$2007` read-during-rendering to reload.
160 ///
161 /// The `PPUDATA` state machine reloads `data_buffer` from the fetch cadence
162 /// (`TriCNES` `PPU_DATA_StateMachine` latch cascade: read-end -> ALE +2 ->
163 /// reload +4 dots; with R1's fixed CPU<->PPU mod-4 phase the landing is a
164 /// constant offset from the register-read sample point). 0 = immediate.
165 /// Default 5 = the empirical winner (W2 sweep 0-12: 5 -> 170/170 stable
166 /// reads on the `$2007 Stress` answer key; 6/7 -> 169; everything else
167 /// far below). Env knob `RUSTYNES_2007_DELAY` (wired in the diag bins).
168 pub static RENDER_BUFFER_DOT_DELAY: AtomicU32 = AtomicU32::new(5);
169 /// Sub-knob: defer the `$2007` v-glitch increment to the `TStep` dot.
170 ///
171 /// The `TStep` is the SAME dot as the buffer reload — `TriCNES`
172 /// `PPU_DATA_StateMachine_Half`: `PPU_2007_TStep = TStep_Latch || PD_RB`
173 /// — instead of read time. With the immediate increment, every fetch in
174 /// the read-to-reload window uses the post-glitch `v` (coarse-x +1 -> NT
175 /// byte = tile+1; fine-y +1 -> PT row+1) — exactly the per-index mismatch
176 /// signature the W2 baseline measured. 1 = deferred (default), 0 =
177 /// immediate (legacy). Env knob `RUSTYNES_2007_VINC` (wired in the diag
178 /// bins).
179 pub static RENDER_BUFFER_DEFER_V_INC: AtomicU32 = AtomicU32::new(1);
180}
181
182/// v2.0.3 (ADR 0030) octal-latch calibration tracer.
183///
184/// Entirely a diagnostic: a lock-free ring of packed per-event records captured
185/// only when `ENABLE` is set (via the env knob wired in the test harness). It
186/// costs nothing in an untraced run — each `push` call site is a single relaxed
187/// atomic load + early-return branch, and every call site sits in a cold
188/// corruption branch (never the steady-state per-dot path). Records the
189/// corruption-relevant events (`$2006`/`$2007` during render, hybrid/stale
190/// splices) on scanlines 2-5 so a run can be cross-diffed against the `TriCNES`
191/// per-dot bus sequence.
192///
193/// Gated behind the default-off `ppu-octal-trace` dev feature. Behavior never
194/// depends on it — the 2-cycle-ALE fetch model (now the only PPU fetch path, ADR
195/// 0030) drives its `push` call sites unconditionally, but with the feature off
196/// `push` is a zero-cost no-op ([`stub`](self)) and the ring's 64-bit-atomic
197/// storage does not exist, so the shipped hot path is untouched and the
198/// `#![no_std]` chip stack (whose `thumbv7em` target lacks 64-bit atomics) still
199/// builds. Enable with `--features ppu-octal-trace` to capture a trace.
200#[cfg(feature = "ppu-octal-trace")]
201pub mod octal_trace {
202 use core::sync::atomic::{AtomicU32, AtomicU64};
203 /// 1 = capture enabled. Off by default; an untraced flag-on run pays only a
204 /// single relaxed atomic load + branch per `push` call site.
205 pub static ENABLE: AtomicU32 = AtomicU32::new(0);
206 /// Next free slot (saturating; stops at `LOG.len()`).
207 pub static IDX: AtomicU32 = AtomicU32::new(0);
208 /// Packed record: `(kind<<58) | (frame<<44) | (scanline<<32) | (dot<<20) | value`
209 /// where `value` is event-specific (a 20-bit address / latch payload).
210 pub static LOG: [AtomicU64; 4096] = [const { AtomicU64::new(0) }; 4096];
211
212 /// Event kind: `$2006` second write during rendering (value = new `v`).
213 pub const K_W2006: u64 = 1;
214 /// Event kind: `$2007` read during rendering (value = `v`).
215 pub const K_R2007: u64 = 2;
216 /// Event kind: hybrid nametable splice fired (value = effective address).
217 pub const K_HYBRID: u64 = 3;
218 /// Event kind: stale-latch pattern splice fired (value = effective address).
219 pub const K_STALE: u64 = 4;
220 /// Event kind: `$2007` state-machine countdown landed (value = data byte).
221 pub const K_SMLAND: u64 = 5;
222
223 /// Push one record (no-op unless `ENABLE` and slots remain).
224 #[allow(clippy::cast_sign_loss)] // scanline filtered to 2..=5 (always >= 0)
225 pub fn push(kind: u64, frame: u64, scanline: i16, dot: u16, value: u32) {
226 if ENABLE.load(core::sync::atomic::Ordering::Relaxed) == 0 {
227 return;
228 }
229 // Restrict to the corruption-relevant visible scanlines (ALE+Read is
230 // scanline 3, Hybrid scanline 4) to keep the ring from overflowing on
231 // the many unrelated `$2006`/`$2007`-during-render writes elsewhere.
232 if !(2..=5).contains(&scanline) {
233 return;
234 }
235 let i = IDX.fetch_add(1, core::sync::atomic::Ordering::Relaxed) as usize;
236 if i >= LOG.len() {
237 return;
238 }
239 let sl = (u64::from((scanline & 0x0FFF) as u16)) << 32;
240 let packed = (kind << 58)
241 | ((frame & 0x3FFF) << 44)
242 | sl
243 | ((u64::from(dot) & 0xFFF) << 20)
244 | u64::from(value & 0xF_FFFF);
245 LOG[i].store(packed, core::sync::atomic::Ordering::Relaxed);
246 }
247}
248
249/// Zero-cost no-op stand-in for the octal-latch tracer.
250///
251/// Compiled when the `ppu-octal-trace` dev feature is off (the default, and the
252/// only config the `#![no_std]` chip stack builds — the full tracer's ring needs
253/// 64-bit atomics the `thumbv7em` target lacks). Exposes the same `push`
254/// signature and `K_*` event-kind constants the 2-cycle-ALE fetch path
255/// references, so those call sites stay unconditional; `push` here is an empty
256/// body the optimizer removes entirely. Behavior is thus byte-identical to the
257/// full tracer with capture disabled — the tracer only ever observes, never
258/// influences, emulation.
259#[cfg(not(feature = "ppu-octal-trace"))]
260pub mod octal_trace {
261 /// Event kind: `$2006` second write during rendering.
262 pub const K_W2006: u64 = 1;
263 /// Event kind: `$2007` read during rendering.
264 pub const K_R2007: u64 = 2;
265 /// Event kind: hybrid nametable splice fired.
266 pub const K_HYBRID: u64 = 3;
267 /// Event kind: stale-latch pattern splice fired.
268 pub const K_STALE: u64 = 4;
269 /// Event kind: `$2007` state-machine countdown landed.
270 pub const K_SMLAND: u64 = 5;
271
272 /// No-op (the `ppu-octal-trace` feature is off). Compiles to nothing.
273 #[inline(always)]
274 pub const fn push(_kind: u64, _frame: u64, _scanline: i16, _dot: u16, _value: u32) {}
275}
276
277/// v2.0.3 (ADR 0030, Option 1) — the delayed-`CopyV` countdown length in PPU
278/// dots (`TriCNES` `PPU_Update2006Delay`, `Emulator.cs:9837-9843`, which is 4
279/// for three of the four CPU/PPU sub-cycle alignments and 5 for the fourth).
280/// `RustyNES`'s lockstep bus applies the `$2006` write at the start of a CPU
281/// cycle; the corrupted nametable read is the phase-1 dot of the fetch group one
282/// coarse-X past the write. Empirically calibrated against the `TriCNES` per-dot
283/// bus trace so the countdown lands on that read after exactly one `inc_hori_v`
284/// and one phase-0 NT ALE (which loads the one-tile-ahead `$19` low byte). See
285/// the v2.0.3 campaign plan.
286const COPY_V_DELAY: u8 = 4;
287
288/// v2.1.4 F2.3 — optional OAM decay threshold, in **CPU cycles**.
289///
290/// The 2C02's Object Attribute Memory is dynamic RAM: each row is implicitly
291/// refreshed every time sprite evaluation (or a `$2004` access) reads it during
292/// rendering, but with rendering disabled long enough the un-refreshed rows lose
293/// their charge and decay to a fixed garbage pattern. Mesen2 models this as a
294/// per-8-byte-row CPU-cycle timestamp with a 3000-cycle refresh window
295/// (`NesPpu::OamDecayCycleCount`, `Core/NES/NesPpu.cpp`); a read/write that lands
296/// within 3000 CPU cycles of the row's last touch refreshes it, otherwise the row
297/// has decayed. This value mirrors Mesen2's constant exactly so the two agree on
298/// when a row is considered stale.
299///
300/// This whole model is **off by default** (`Ppu::oam_decay_enabled == false`) and
301/// **NTSC/Dendy-only** — on PAL the far more frequent refresh cadence masks decay
302/// entirely, so the feature is never applied there. With it off, no OAM access
303/// consults the decay state and the PPU is byte-identical to a build that never
304/// had the field. See `docs/ppu-2c02.md` (§OAM decay).
305const OAM_DECAY_CPU_CYCLES: u64 = 3000;
306
307/// Region governs the size of the post-render-to-pre-render scanline span.
308#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
309pub enum PpuRegion {
310 /// NTSC (and Famicom). 262 scanlines per frame, pre-render = scanline 261.
311 Ntsc,
312 /// PAL. 312 scanlines per frame, pre-render = scanline 311.
313 Pal,
314 /// Dendy (Russian PAL famiclone). 312 scanlines, but VBL starts at 291.
315 Dendy,
316}
317
318impl PpuRegion {
319 /// Pre-render scanline number.
320 #[must_use]
321 pub const fn prerender_line(self) -> i16 {
322 match self {
323 Self::Ntsc => 261,
324 Self::Pal | Self::Dendy => 311,
325 }
326 }
327
328 /// Last visible scanline (always 239).
329 #[must_use]
330 pub const fn last_visible_line(self) -> i16 {
331 239
332 }
333
334 /// Scanline at which V-blank starts (and `PPUSTATUS.VBLANK` is set on dot 1).
335 #[must_use]
336 pub const fn vblank_start_line(self) -> i16 {
337 match self {
338 Self::Ntsc | Self::Pal => 241,
339 Self::Dendy => 291,
340 }
341 }
342
343 /// Number of CPU cycles `$2000`/`$2001`/`$2005`/`$2006` writes are
344 /// ignored after a power-on / reset. Per nesdev wiki:
345 /// NTSC ≈ 29,658; PAL ≈ 33,132.
346 #[must_use]
347 pub const fn post_reset_mask_cycles(self) -> u32 {
348 match self {
349 Self::Ntsc => 29_658,
350 Self::Pal | Self::Dendy => 33_132,
351 }
352 }
353}
354
355/// v2.1.7 P5 — selectable 2C02 die revision, gating revision-dependent quirks.
356///
357/// Additive and **default-off**: the [`Default`] ([`Self::Rp2c02H`]) preserves
358/// `RustyNES`'s established behavior byte-for-byte, so `AccuracyCoin`, the
359/// commercial oracle, and the visual / audio regression suites are unaffected at
360/// the default. Only the opt-in [`Self::Rp2c02G`] selection changes any emulated
361/// behavior (see below).
362///
363/// Real RP2C02 dies shipped across several letter revisions. The one behavioral
364/// difference `RustyNES` currently models per-revision is the **OAMADDR
365/// (`$2003`) write-during-rendering OAM corruption** glitch: writing `$2003`
366/// while rendering is enabled on a visible / pre-render scanline copies one
367/// 8-byte OAM "row" from row 0 over the row the write's high bits target, on the
368/// next rendered dot (the same `CorruptOAM` mechanism the rendering-disable
369/// model uses; see `Ppu::process_oam_corruption`). A handful of titles —
370/// notably *Huge Insect* — trip it. It is **not** enabled on the default
371/// revision.
372///
373/// Honesty note (see `docs/accuracy-ledger.md`): the exact mapping of the
374/// `$2003` corruption onto specific 2C02 letter revisions is not firmly
375/// established in the public literature, and the precise per-title byte output
376/// of the glitch is not independently oracle-verified in this cut. `RustyNES`
377/// therefore offers the model as an opt-in approximation keyed to a single
378/// "earlier revision" selection ([`Self::Rp2c02G`]) rather than claiming exact
379/// silicon-revision fidelity. This is config, **not** save-state: like
380/// [`PpuRegion`] it is re-applied on load and is not part of the snapshot.
381#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
382pub enum PpuRevision {
383 /// Default. Later RP2C02 die (the "H"-class revision `RustyNES` has always
384 /// modeled). The OAMADDR (`$2003`) write-during-rendering OAM corruption is
385 /// **not** modeled, so the deterministic output is byte-identical to a build
386 /// without this feature.
387 #[default]
388 Rp2c02H,
389 /// Earlier RP2C02 die ("rev E+" in the nesdev notes). Additionally models the
390 /// OAMADDR (`$2003`) write-during-rendering OAM row-corruption glitch that
391 /// *Huge Insect* and a few other titles trip. Opt-in; changes emulated
392 /// behavior only for software that writes `$2003` mid-render.
393 Rp2c02G,
394}
395
396impl PpuRevision {
397 /// Whether this revision models the OAMADDR (`$2003`) write-during-rendering
398 /// OAM corruption glitch. Only [`Self::Rp2c02G`] does; the default returns
399 /// `false`, keeping the default build byte-identical.
400 #[must_use]
401 pub const fn models_oamaddr_corruption(self) -> bool {
402 matches!(self, Self::Rp2c02G)
403 }
404}
405
406/// v2.1.7 P5 — selectable power-up palette-RAM contents.
407///
408/// The 2C02's palette RAM is not cleared at power-on; different consoles (and
409/// thus different emulator authors' reference dumps) come up with different
410/// garbage. This is a documented power-up option, **default-off**: [`Default`]
411/// ([`Self::Zeroed`]) keeps `RustyNES`'s established all-zero power-up palette,
412/// so default rendering is byte-identical. It writes only `Ppu::palette_ram`,
413/// which is already part of the save-state snapshot, so it needs no
414/// snapshot-format change.
415#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
416pub enum PaletteInit {
417 /// Default. All 32 palette-RAM bytes power up to `0x00` — `RustyNES`'s
418 /// established deterministic power-up state. Byte-identical.
419 #[default]
420 Zeroed,
421 /// The canonical "Blargg" power-up palette dump (the 32-byte pattern used by
422 /// blargg's NES and mirrored by `TriCNES`'s `BlarggPalette`). A documented,
423 /// deterministic known pattern for software that samples uninitialized
424 /// palette RAM before writing it. Opt-in.
425 Blargg,
426}
427
428/// The canonical "Blargg" power-up palette-RAM contents (32 bytes). Mirrors
429/// `TriCNES`'s `BlarggPalette` table (`Emulator.cs`) verbatim. Only applied when
430/// [`PaletteInit::Blargg`] is selected.
431const BLARGG_POWER_UP_PALETTE: [u8; 32] = [
432 0x09, 0x01, 0x00, 0x01, 0x00, 0x02, 0x02, 0x0D, 0x08, 0x10, 0x08, 0x24, 0x00, 0x00, 0x04, 0x2C,
433 0x09, 0x01, 0x34, 0x03, 0x00, 0x04, 0x00, 0x14, 0x08, 0x3A, 0x00, 0x02, 0x00, 0x20, 0x2C, 0x08,
434];
435
436/// 2C02 PPU.
437///
438/// `tick(bus)` advances one PPU dot. The PPU is the master clock;
439/// `rustynes-core` calls it three times per CPU cycle (NTSC).
440#[derive(Debug)]
441#[allow(clippy::struct_excessive_bools)] // PPU's many 1-bit latches are spec
442pub struct Ppu {
443 /// Region (governs frame structure).
444 pub(crate) region: PpuRegion,
445
446 // === CPU-facing register state ===
447 pub(crate) ctrl: PpuCtrl,
448 pub(crate) mask: PpuMask,
449 /// Two-stage delay pipeline of `mask` consumed exclusively by the
450 /// pre-render dot-339 odd-frame skip check. `mask_for_skip_check` is
451 /// the value seen *this* dot; `mask_skip_pipe1` is the staged value
452 /// that will become visible *next* dot. Both shift at the end of every
453 /// `advance_dot`. The total visible delay between a PPUMASK write and
454 /// the dot-skip detector is two PPU clocks — enough to compensate for
455 /// the lockstep bus model applying `cpu_write` at the *start* of a CPU
456 /// cycle (before the cycle's 3 PPU ticks) while real hardware latches
457 /// the write at φ2 (effectively the *end* of the cycle). Required by
458 /// blargg `ppu_vbl_nmi/10-even_odd_timing`; tests 1-9 of the same
459 /// corpus are unaffected because rendering is enabled long before
460 /// the boundary.
461 pub(crate) mask_for_skip_check: PpuMask,
462 pub(crate) mask_skip_pipe1: PpuMask,
463 pub(crate) status: PpuStatus,
464 /// `$2003` OAMADDR.
465 pub(crate) oam_addr: u8,
466 /// `$2007` PPUDATA read buffer.
467 pub(crate) data_buffer: u8,
468 /// `mc-ppu-2007-render-buffer`: the most recent VRAM data-bus value (every
469 /// rendering fetch updates it). During rendering a `$2007` read returns THIS
470 /// (the pipeline's current fetch byte), not a read at `v` — the model the
471 /// `AccuracyCoin` `$2007 Stress` test brackets (`$2007` read on every dot).
472 pub(crate) render_data_bus: u8,
473 /// `mc-ppu-2007-render-buffer`: PPU-dot countdown from a `$2007` read during
474 /// rendering to the `PPUDATA` state machine's `data_buffer` reload. `TriCNES`
475 /// reloads `PPU_ReadBuffer` via a latch cascade ~4 dots after read-END
476 /// (`Emulator.cs` `PPU_DATA_StateMachine`); with R1's fixed CPU<->PPU mod-4
477 /// phase the hardware landing dot is a constant +5 dots from the
478 /// register-read sample point (empirical W2 sweep winner: 170/170 stable
479 /// reads). Set at the read (`RENDER_BUFFER_DOT_DELAY`),
480 /// decremented once per dot in `Ppu::tick` (end of the fetch dispatch); at
481 /// 0 the buffer latches `render_data_bus` (the fetch cadence's latched bus
482 /// value — never a fresh VRAM read, so zero new A12/mapper events).
483 /// 0 = inactive. Serialized in the PPU snapshot v3 tail (W3-Stage-4,
484 /// 2026-06-10) so an in-flight reload survives a save-state restore.
485 pub(crate) ppudata_sm_countdown: u8,
486 /// `mc-ppu-2007-render-buffer`: the v-glitch increment of the in-flight
487 /// `$2007` read is still pending — performed at the `TStep` (= the countdown
488 /// landing dot, after the buffer reload), per `TriCNES`
489 /// `PPU_DATA_StateMachine_Half`. Serialized in the PPU snapshot v3 tail
490 /// (paired with `ppudata_sm_countdown`).
491 pub(crate) ppudata_v_inc_pending: bool,
492 /// `mc-ppu-2007-render-buffer`: raw (pre-h-flip) sprite pattern bytes
493 /// captured by `fetch_sprite_tile` per slot, so the per-dot sprite-fetch
494 /// read cadence (dots 257-320) feeds `render_data_bus` the PT-lo / PT-hi
495 /// values the PPU drove on the bus (the `$2007` buffer captures the raw
496 /// bus byte, not the flipped shifter contents). Serialized in the PPU
497 /// snapshot v3 tail.
498 pub(crate) spr_fetch_lo_raw: [u8; 8],
499 /// See [`Self::spr_fetch_lo_raw`].
500 pub(crate) spr_fetch_hi_raw: [u8; 8],
501 /// `mc-ppu-2007-render-buffer`: the nametable address latched by the ALE
502 /// of sprite slot 0's first garbage NT read (dot 257) — captured BEFORE
503 /// the dot-257 copy-hori, so the read at dot 258 uses the OLD `v` (the
504 /// only sprite-interval read that does). Serialized in the PPU snapshot
505 /// v3 tail (refreshed every rendered scanline).
506 pub(crate) ppudata_spr0_nt_addr: u16,
507
508 // === v2.0.3 (ADR 0030) octal-latch / 2-cycle-ALE multiplexed-bus model ===
509 // Now the ONLY PPU fetch path (promoted from the experimental flag in
510 // v2.0.3; the superseded v2.0.2 whole-dot stand-in was retired). In continuous
511 // play these fields self-heal within a scanline (they reload on the next fetch
512 // ALE), but a mid-render rollback/save-state checkpoint can capture them live —
513 // so they ARE serialized in the `PPU_SNAPSHOT_VERSION` v5 tail (added in v2.0.3)
514 // for netplay-rollback determinism. That bump is ADDITIVE (pre-v5 blobs upconvert
515 // to the inactive rest defaults), NOT an ADR-0028 save-state format-epoch break.
516 //
517 // Ported from TriCNES (`TriCNES/Emulator.cs`, MIT, commit 9199870),
518 // the AccuracyCoin author's own cycle-accurate C# emulator (a detailed
519 // sub-cycle CPU/PPU/APU/DMA state machine), which is the
520 // ground-truth oracle for the "ALE + Read" / "Hybrid Addresses" tests (the
521 // vendored Mesen2 build does NOT pass them — see the ADR 0030 campaign audit).
522 //
523 // The PPU multiplexes its low 8 VRAM address pins (PA0-7) with the 8 data
524 // pins (AD7-0). A 74LS373-class external octal latch (`octal_latch`) captures
525 // A7-A0 on the address-latch-enable (ALE) half of each 2-cycle VRAM access;
526 // on the read half the PPU drives only A13-A8 (the fetch address's high 6
527 // bits, `& 0x3F00`) and the latch supplies A7-A0. The *effective* read
528 // address is therefore the splice `(fetch_addr & 0x3F00) | octal_latch`
529 // (TriCNES `FetchPPU`:153). When those halves desync — a mid-fetch `$2006`
530 // high-byte update, or a `$2007`-read ALE that overlaps the fetch cadence and
531 // freezes the latch on a stale DATA byte — the PPU reads a "hybrid" address it
532 // never coherently drove.
533 /// 74LS373 low-address octal latch (A7-A0). Loaded on each fetch's ALE
534 /// with the driven address's low byte; frozen (goes stale) across a
535 /// `$2007`-read/background-fetch ALE overlap. Serialized in the PPU snapshot
536 /// v5 tail (v2.0.3): it self-heals within a scanline in continuous play, but a
537 /// mid-render rollback checkpoint needs it restored for determinism.
538 pub(crate) octal_latch: u8,
539 /// The multiplexed PPU address/data bus. On a fetch's ALE (even) dot the full
540 /// driven 14-bit address is written here; on the read (odd) dot the DATA byte
541 /// is written back into the low 8 bits (the AD7-0 pins), so the register always
542 /// reflects the true multiplexed bus value. The effective read address is the
543 /// splice `(address_bus & 0x3F00) | octal_latch`. Serialized in the PPU snapshot
544 /// v5 tail (v2.0.3) for mid-render rollback determinism (self-heals on the next
545 /// fetch ALE in continuous play).
546 pub(crate) address_bus: u16,
547 /// Set on a fetch's ALE (even) dot after the address is driven onto
548 /// [`Self::address_bus`]; consumed (cleared) by the matching read (odd) dot's
549 /// [`Self::ale_splice`]. Distinguishes a read that followed a real ALE (main
550 /// background dispatch, phases 0/2/4/6 → 1/3/5/7) from a read with NO preceding
551 /// ALE (the dot-337-340 garbage nametable fetches), so the latter stays
552 /// behavior-neutral (drives + latches coherently in-place rather than splicing
553 /// a stale bus).
554 pub(crate) ale_armed: bool,
555 /// One-shot: the `$2007`-read ALE overlap froze `octal_latch` on the read's
556 /// DATA byte, so the next background PATTERN fetch reads
557 /// `(PAR-high 6):(stale low 8)` (`$0F03` -> `$0FFF`) — the "ALE + Read"
558 /// corruption. Consumed by the next pattern (BG-lo/BG-hi) fetch: the latch is
559 /// frozen at the PPUDATA state-machine landing dot and carried to the next
560 /// pattern read via the natural ALE/read multiplex.
561 pub(crate) pattern_latch_stale: bool,
562 /// The delayed-`CopyV` countdown (`TriCNES`
563 /// `PPU_Update2006Delay`, `Emulator.cs:1684-1704`). A `$2006` second write
564 /// that lands DURING rendering does NOT copy `t -> v` immediately; it stages
565 /// this PPU-dot countdown instead. While it runs, the background fetch
566 /// cadence keeps advancing coarse-X (via `inc_hori_v` at phase 7) and the
567 /// per-group phase-0 nametable ALE keeps loading `octal_latch` with the
568 /// CURRENT (pre-copy) `v`'s NT-low byte — so by the time the countdown lands
569 /// the latch NATURALLY holds the one-tile-ahead low byte (`$19`), and the
570 /// landing's `address_bus = v` splice yields the hybrid address (`$2F19`)
571 /// with no `+1 coarse-X` reconstruction. 0 = inactive.
572 pub(crate) copy_v_delay: u8,
573
574 // === Internal scroll/address registers (loopy v/t/x/w) ===
575 /// 15-bit "current VRAM address".
576 pub(crate) v: u16,
577 /// 15-bit "temporary VRAM address" (latched scroll/PPUADDR target).
578 pub(crate) t: u16,
579 /// 3-bit fine X scroll.
580 pub(crate) x: u8,
581 /// 1-bit write toggle for `$2005` / `$2006`.
582 pub(crate) w: bool,
583
584 // === Memory ===
585 /// Console-side nametable VRAM (CIRAM, 2 KiB). Owned by the PPU; the
586 /// mapper exposes a per-cart `nametable_address` mirroring map via
587 /// [`PpuBus::nametable_address`] so the PPU can read/write CIRAM directly
588 /// without going through `bus.ppu_read/write` for `$2000-$3EFF`.
589 pub(crate) ciram: Box<[u8]>,
590 /// Object Attribute Memory: 64 sprites × 4 bytes.
591 pub(crate) oam: Box<[u8]>,
592 /// Secondary OAM: up to 8 sprites for the next scanline. Populated
593 /// during sprite evaluation in Sprint 2-3.
594 pub(crate) secondary_oam: [u8; 32],
595 /// Palette RAM: 32 entries, 6-bit each (high 2 bits open-bus on read).
596 pub(crate) palette_ram: [u8; 32],
597
598 // === v2.1.4 F2.3 optional OAM decay (opt-in, default-OFF) ===
599 /// Per-8-byte-row last-touch timestamp, in **CPU cycles** (`dot_counter / 3`).
600 /// OAM is 256 bytes = 32 rows of 8 bytes; `oam_decay_cycles[addr >> 3]` is the
601 /// CPU cycle at which row `addr >> 3` was last refreshed by an OAM read/write.
602 /// Only consulted when [`Self::oam_decay_enabled`] is set AND the region is
603 /// NTSC/Dendy; otherwise it is dead state that never influences a read. Mirrors
604 /// Mesen2's `_oamDecayCycles` (`Core/NES/NesPpu.cpp`).
605 ///
606 /// Serialized as a *relative age* in the PPU snapshot v7 tail (see
607 /// `snapshot.rs`): the absolute timestamps reference the free-running,
608 /// **un-serialized** `dot_counter`, so a raw absolute value would be meaningless
609 /// after a rollback/restore rebased that counter. Storing `now - timestamp`
610 /// (and reconstructing `now - age` on load, relative to the live counter) keeps
611 /// a run-ahead / netplay `snapshot`→`restore` byte-identical to the forward run.
612 ///
613 /// Field POSITION here is not performance-relevant, and this was measured
614 /// rather than assumed (v2.3.1 G2, `docs/performance.md`): neither adding
615 /// `#[repr(C)]` nor moving this 256-byte cold array to the end of the struct
616 /// produced a reproducible change on any workload. `Ppu` is ~2.8 KB and stays
617 /// L1-resident across a frame, so layout has little left to buy.
618 pub(crate) oam_decay_cycles: [u64; 32],
619 /// Master enable for the OAM-decay model. **`false` by default** — a frontend /
620 /// config knob (re-applied on load like `region` / `active_palette`), NOT part
621 /// of the save-state. While `false`, every decay hook early-returns, so OAM
622 /// reads/writes touch neither this flag's siblings nor `oam_decay_cycles`, and
623 /// the framebuffer/audio/replay output is byte-identical to a decay-free build.
624 pub(crate) oam_decay_enabled: bool,
625
626 // === Open-bus latch (for $2000-$3FFF) ===
627 /// Most recent value driven onto the PPU bus by any register access.
628 pub(crate) open_bus: u8,
629 /// Per-bit-group decay counters (in CPU cycles) until each bit group of
630 /// the open-bus latch reads as 0. Three groups, each with its own timer:
631 /// `[0]` bits 0-4, `[1]` bit 5, `[2]` bits 6-7.
632 /// Required by `ppu_open_bus.nes` tests 7 and 9, which assert that some
633 /// reads refresh only a subset of the bit groups (e.g., reading $2002
634 /// must not refresh the low 5 bits' decay timer; palette $2007 reads
635 /// must not refresh the high 2 bits' decay timer).
636 pub(crate) open_bus_decay: [u32; 3],
637
638 // === NMI line + frame counter ===
639 /// `true` while the PPU is asserting NMI.
640 pub(crate) nmi_line: bool,
641 /// True for one frame after a `cpu_read_register($2002)` race so we
642 /// suppress the VBL flag set + NMI for that frame (per
643 /// `ppu_vbl_nmi/06-suppression.nes`). Toggled on the cycle the read
644 /// hits at scanline 241 dot 0 / dot 1.
645 pub(crate) suppress_vbl_this_frame: bool,
646 /// Last-observed A12 level, for edge-triggered notifications.
647 pub(crate) last_a12_level: bool,
648
649 // === Scanline FSM ===
650 /// Current dot (0..=340).
651 pub(crate) dot: u16,
652 /// Current scanline (-1 in pre-render, 0..=239 visible, 240 post-render,
653 /// 241..=260/310 vblank). Stored as i16 to allow temporary -1.
654 pub(crate) scanline: i16,
655 /// Frame counter (for odd-frame skip).
656 pub(crate) frame: u64,
657 /// `frame_complete` latch — set to `true` on the dot the PPU finishes
658 /// a frame; consumed by the run loop and cleared on next read.
659 pub(crate) frame_complete: bool,
660
661 // === Power-on / reset masking window ===
662 /// CPU cycles remaining in the post-reset masking window. While > 0,
663 /// writes to PPUCTRL/PPUMASK/PPUSCROLL/PPUADDR are silently ignored
664 /// (reads still work).
665 pub(crate) post_reset_mask_remaining: u32,
666
667 // === Background fetch + shift register state ===
668 /// Latched nametable byte from the current 8-cycle fetch group.
669 pub(crate) nt_latch: u8,
670 /// Latched attribute byte (palette) from the current 8-cycle fetch group.
671 pub(crate) at_latch: u8,
672 /// Latched BG pattern low byte from the current 8-cycle fetch group.
673 pub(crate) bg_lo_latch: u8,
674 /// Latched BG pattern high byte from the current 8-cycle fetch group.
675 pub(crate) bg_hi_latch: u8,
676 /// 16-bit BG pattern low shift register.
677 pub(crate) bg_shift_lo: u16,
678 /// 16-bit BG pattern high shift register.
679 pub(crate) bg_shift_hi: u16,
680 /// 16-bit attribute low shift register.
681 ///
682 /// Mirrors the 16-bit BG pattern shifters exactly: at each 8-dot
683 /// reload the latched attribute bit is expanded to a full byte
684 /// (`0x00` or `0xFF`) into bits 0-7, shifted left by 1 after each
685 /// emit, and shifted left by 8 at the pre-fetch boundary (dots 328 /
686 /// 336). Keeping it 16-bit (not the prior 8-bit + 1-bit-feed model)
687 /// is what keeps the attribute in lockstep with the pattern bits
688 /// through the dots 321-336 pre-fetch region, where `shift_bg` does
689 /// not run and only the explicit `<<= 8` advances the registers.
690 pub(crate) at_shift_lo: u16,
691 /// 16-bit attribute high shift register. See [`Self::at_shift_lo`].
692 pub(crate) at_shift_hi: u16,
693 /// Optional per-tile extended attribute (MMC5 `ExGrafix`). Latched at
694 /// the NT-byte fetch boundary; consumed by AT / BG-low / BG-high
695 /// fetches in the same 8-dot group.
696 pub(crate) ex_attr_latch: Option<ExAttribute>,
697 /// Optional vertical split-screen state (MMC5 `$5200`-`$5202`). Latched
698 /// at the NT-byte fetch boundary; consumed by AT / BG-low / BG-high
699 /// fetches in the same 8-dot group. When `Some`, the BG fetches use the
700 /// alt region's nametable address, attribute address, fine-Y, and CHR
701 /// bank instead of the values derived from `v`.
702 pub(crate) bg_split_latch: Option<BgSplitState>,
703
704 // === Sprite rendering state ===
705 /// Per-sprite shift registers (low + high pattern).
706 pub(crate) spr_shift_lo: [u8; 8],
707 pub(crate) spr_shift_hi: [u8; 8],
708 /// Per-sprite latched attribute byte.
709 pub(crate) spr_attr: [u8; 8],
710 /// Per-sprite X-coordinate counter.
711 pub(crate) spr_x: [u8; 8],
712 /// v2.0 (ppu-sprite-shifter-counter): per-sprite persistent "halted" latch.
713 /// Set when the X-counter reaches 0 (the sprite is drawing) and PERSISTS
714 /// across a render-disable and the frame boundary; re-armed to "counting"
715 /// at dot 339 for the loaded slots. Carries the Stale Sprite Shift Regs
716 /// t5/6 behavior (a reloaded-but-halted sprite draws on the next rendering
717 /// re-enable). Default build: absent (legacy `spr_x == 0` predicate).
718 pub(crate) spr_halted: [bool; 8],
719 /// Number of sprites loaded for the current scanline.
720 pub(crate) spr_count: u8,
721 /// `true` if sprite 0 is in the current scanline's sprite line-up.
722 pub(crate) spr_zero_in_line: bool,
723
724 // === Per-dot sprite-evaluation FSM state ===
725 /// Sprite-eval read latch: byte read from primary OAM on odd cycles
726 /// (1, 3, 5, ...) of dots 65-256, consumed by the immediately-following
727 /// even-cycle write into secondary OAM.
728 pub(crate) sprite_eval_read_latch: u8,
729 /// Primary-OAM sprite index 0..=63 walked during dots 65-256.
730 pub(crate) sprite_eval_n: u8,
731 /// Per-sprite byte index 0..=3 walked during dots 65-256 (drives the
732 /// buggy `n+m` increment when overflow detection mode is active).
733 pub(crate) sprite_eval_m: u8,
734 /// Number of in-range sprites found so far in this scanline's eval pass.
735 pub(crate) sprite_eval_found: u8,
736 /// Write index into `secondary_oam` (0..=31). Tracks how many bytes the
737 /// per-dot FSM has committed so far.
738 pub(crate) sprite_eval_sec_idx: u8,
739 /// `true` when the current sprite (the one whose `y` byte just tested
740 /// in-range) is still being copied — bytes 1, 2, 3 land in subsequent
741 /// even-dot writes.
742 pub(crate) sprite_eval_copying: bool,
743 /// `true` when eval has exhausted primary OAM (n wrapped past 63) or
744 /// overflow has been detected — remaining dots 65-256 idle out.
745 pub(crate) sprite_eval_done: bool,
746 /// `true` when 8 in-range sprites have been latched and the FSM is
747 /// in overflow-detection mode (buggy `n+m` increment active).
748 pub(crate) sprite_eval_overflow_search: bool,
749 /// Eval-side latch for "sprite 0 is in the line being evaluated."
750 /// Set during the current scanline's eval pass (dots 65..=256) when
751 /// sprite 0 lands in-range; committed to [`Self::spr_zero_in_line`]
752 /// at dot 256 alongside [`Self::spr_count`]. Keeping the eval-side
753 /// latch separate from the rendering-side flag ensures the FSM
754 /// doesn't trample the CURRENT scanline's sprite-0-hit signal while
755 /// it's still being read by the dots 1..=256 sprite-pixel evaluator.
756 pub(crate) sprite_eval_zero_found: bool,
757 /// Phase 3a flag — tracks whether current scanline's eval is on
758 /// its FIRST iteration (PPU cycle 66, first y-test). Set at
759 /// dot 0 of each visible scanline; cleared after the first y-test
760 /// fires (in-range or not). Per Mesen2 `ProcessSpriteEvaluation`
761 /// line 1040-1044, sprite-zero fires IFF the FIRST y-test is in
762 /// range — not "first in-range sprite found". When OAMADDR is 0
763 /// at eval start and OAM[0].y is in range, this matches the legacy
764 /// `n == 0` check. When OAMADDR != 0, this fires on whichever
765 /// sprite the start position points to (sprite at OAMADDR / 4)
766 /// if its y is in range, else NO sprite-zero is detected.
767 pub(crate) sprite_eval_first_iter: bool,
768
769 /// v2.0 Tier 1.2 — isolated OAM-data-bus model of the `NESdev`-documented PPU
770 /// sprite-evaluation datapath (`NESdev` wiki "PPU sprite evaluation"). These
771 /// fields exist ONLY under `ppu-oam-data-bus` and are read solely by `$2004`
772 /// during rendering — the rendering / sprite-zero / overflow / MMC3
773 /// sprite-fetch FSM uses `secondary_oam` + `sprite_eval_*` + `spr_*`, all
774 /// untouched. `oam_bus_copybuffer` is the value `$2004` returns while the
775 /// screen is drawn (the byte currently on the OAM data bus).
776 ///
777 /// Provenance: the OAM-data-bus and sprite-evaluation model is **derived
778 /// from Mesen2's `NesPpu.cpp`** (`ProcessSpriteEvaluation` / `ReadSpriteRam`),
779 /// GPL-3.0-or-later. See NOTICE and docs/originality-and-provenance.md (Section 1).
780 pub(crate) oam_bus_copybuffer: u8,
781 /// Parallel secondary OAM (the 32-byte sprite line buffer) for the bus model only.
782 pub(crate) oam_bus_secondary: [u8; 32],
783 /// Eval-pointer sprite index (0..=63) — which of the 64 primary sprites is examined.
784 pub(crate) oam_bus_addr_h: u8,
785 /// Eval-pointer byte-in-sprite (0..=3) — Y / tile / attr / X.
786 pub(crate) oam_bus_addr_l: u8,
787 /// Write index into the parallel secondary OAM.
788 pub(crate) oam_bus_secondary_addr: u8,
789 /// Primary OAM fully scanned / wrapped for this scanline.
790 pub(crate) oam_bus_copy_done: bool,
791 /// Currently copying an in-range sprite.
792 pub(crate) oam_bus_sprite_in_range: bool,
793 /// The 8-sprite-overflow PPU-bug countdown.
794 pub(crate) oam_bus_overflow_counter: u8,
795
796 /// OAM-corruption model — faithful port of `TriCNES`'s eval-pointer
797 /// machinery (`Emulator.cs` `PPU_Render_SpriteEvaluation` lines
798 /// 2664-2770 + `CorruptOAM` lines 2635-2651). Replaces the earlier
799 /// Mesen2 `_corruptOamRow` row-flag model (`dot >> 1` index), which
800 /// Mesen ships OFF by default (`EnablePpuOamRowCorruption=false`) and
801 /// documents as unfinished. The bug it fixes: SMB3 (MMC3) toggles
802 /// PPUMASK mid-visible-scanline to split its HUD; NMI/DMA jitter
803 /// shifts the disable dot, and the raw-dot row index intermittently
804 /// landed on Mario's OAM row (offset 40), wiping his sprite.
805 ///
806 /// `TriCNES` model: when rendering is disabled (1 -> 0) DURING sprite
807 /// evaluation (dots 1-64, secondary-OAM clear, NOT the pre-render
808 /// line), the corruption is DEFERRED — `oam_corruption_pending` is
809 /// set and `oam_corruption_index` captures the live secondary-OAM
810 /// write pointer (`OAM2Address`) at that instant. When rendering
811 /// RE-ENABLES (or at the pre-render line), one OAM "row" of 8 bytes
812 /// is replaced from row 0: `oam[index*8 + i] = oam[i]` for i in
813 /// 0..8 (index 0x20 wraps to 0), and `secondary_oam[index] =
814 /// secondary_oam[0]`.
815 ///
816 /// `oam2_addr` is the `OAM2Address` analogue maintained across the
817 /// dots 1-64 clear window (our dots 65-256 active eval already walks
818 /// `sprite_eval_sec_idx`, but the SMB3 HUD-split disable lands in the
819 /// clear window where `sprite_eval_sec_idx` is held at 0, so the
820 /// dedicated pointer is required to capture the right index).
821 ///
822 /// `oam_corruption_disabled` / `_instant` mirror `TriCNES`'s
823 /// `PPU_OAMCorruptionRenderingDisabledOutOfVBlank` (1-dot-delayed,
824 /// armed by the `$2001` write-delay) and `..._Instant` (the
825 /// data-bus-immediate path: OAM eval observes the disable the same
826 /// cycle). The disable edge is captured into `pending`/`index`
827 /// during the dots 1-64 eval window; the actual corruption is
828 /// committed at re-enable / pre-render.
829 ///
830 /// None of these fields are persisted in the PPU snapshot — like the
831 /// rest of the per-dot sprite-eval FSM state (`sprite_eval_*`), they
832 /// re-derive within a scanline/frame, matching the prior row-flag
833 /// OAM-corruption model (also un-snapshotted).
834 pub(crate) oam_corruption_pending: bool,
835 pub(crate) oam_corruption_index: u8,
836 pub(crate) oam_corruption_disabled: bool,
837 pub(crate) oam_corruption_disabled_instant: bool,
838 /// `OAM2Address` analogue: the secondary-OAM write pointer as it
839 /// walks the dots 1-64 secondary-OAM clear window. Reset to 0 at the
840 /// dot-1 boundary and incremented once per even clear dot, masked to
841 /// 0x1F, exactly as `TriCNES` drives `OAM2Address` during dots 1-64.
842 pub(crate) oam2_addr: u8,
843 /// Previous-tick rendering-enabled state — tracks the rising /
844 /// falling edge of `mask.rendering_enabled()` so the 1->0 edge
845 /// BG-shifter fix-up fires on the correct transition.
846 pub(crate) prev_rendering_enabled: bool,
847 /// v2.0 (ported from branch `ae30785`) — 1-PPU-dot-delayed rendering-enabled
848 /// gate. Per Mesen2 `NesPpu::UpdateState`: a `$2001` write toggling
849 /// `SHOW_BG|SHOW_SPRITE` takes effect on the rendering pipeline one PPU dot
850 /// later (the `mask` bit-fields update immediately for pixel output; this
851 /// delayed copy gates the fetch/shift/sprite-eval pipeline). When rendering
852 /// is stable this equals `mask.rendering_enabled()`, so only mid-scanline
853 /// `$2001` toggles observe the delay. The `ppu-sprite-shifter-counter`
854 /// feature reads it (via `rendering_gate`); the default build uses the
855 /// immediate value, so flag-off is byte-identical. Updated at tick end.
856 pub(crate) rendering_enabled_delayed: bool,
857
858 /// v2.0 Phase 6 (`mc-ppu-subpos`): the analog `$2001` BG-shift-register
859 /// RELOAD delay. The shifter reload gates on `bg_reload_render`, which tracks
860 /// the live `self.mask` rendering-enable bit EXCEPT during the
861 /// `MASK_WRITE_DELAY`-dot window after a `$2001` write, where it stays frozen
862 /// at its prior value (`TriCNES` gates the fetch/reload on
863 /// `PPU_Mask_Show*_Delayed` while the per-half-dot SHIFT runs on the
864 /// IMMEDIATE mask). So on a render re-enable the shifter advances for
865 /// `MASK_WRITE_DELAY` dots (injecting the serial-in '1') BEFORE the reload
866 /// resumes -> one reload is SKIPPED and the accumulated '1's reach the output
867 /// (BG Serial In), without perturbing the sprite/pixel/shift path. Because it
868 /// re-syncs to the live mask whenever settled, a direct mask set (unit tests,
869 /// save-state restore) leaves it consistent. `mask_write_delay` is the
870 /// remaining freeze countdown (0 = settled). Serialized in the PPU
871 /// snapshot v3 tail (W3-Stage-4, 2026-06-10) so an in-flight freeze
872 /// survives a save-state restore.
873 pub(crate) bg_reload_render: bool,
874 pub(crate) mask_write_delay: u8,
875
876 /// v1.4.0 Workstream F (F1) — scanline-stable rendering-classification
877 /// cache. `visible` / `pre_render` / `render_line` are pure functions of
878 /// `self.scanline` + `self.region`, so they only change when the scanline
879 /// advances. The hot per-dot `tick` recomputes them ~7 branches deep
880 /// 89,342 times/frame; instead we recompute them once when the scanline
881 /// changes (detected via the `flags_cached_scanline` sentinel) and read the
882 /// cached copies on every other dot. Byte-identical by construction (same
883 /// values, computed less often) and self-healing across reset / save-state
884 /// restore (the sentinel starts mismatched, forcing a recompute on the
885 /// first tick). NOT part of the PPU snapshot — pure derived data.
886 pub(crate) cached_visible: bool,
887 pub(crate) cached_pre_render: bool,
888 pub(crate) cached_render_line: bool,
889 /// v2.2.3 P2 — `true` on an **idle** line: not visible, not pre-render, and
890 /// not the VBL-set line (`vblank_start_line`). On NTSC that is line 240
891 /// (post-render) plus lines 242..=260 — 20 of 262. Such a line issues no
892 /// fetch, emits no pixel, runs no sprite evaluation, and raises no event, so
893 /// its dots are eligible for [`Self::tick_idle_line_fast`]. Derived from
894 /// `scanline` + `region` exactly like the three flags above, and keyed by
895 /// the same [`Self::flags_cached_scanline`] sentinel.
896 #[cfg(feature = "ppu-idle-line-fast")]
897 pub(crate) cached_idle_line: bool,
898 /// Sentinel: the scanline `cached_*` were last computed for. `i16::MIN`
899 /// (an impossible scanline) forces a recompute on the first tick.
900 pub(crate) flags_cached_scanline: i16,
901
902 /// Active output palette. Defaults to the 2C02 composite palette so normal
903 /// NES/Famicom rendering is byte-for-byte unchanged; set to one of the RGB
904 /// variants for Vs. System / PlayChoice-10 carts (see [`Ppu::set_palette`]).
905 /// Construction-time configuration only — never mutated during emulation, so
906 /// it is intentionally NOT part of the PPU save-state snapshot (it is
907 /// re-derived from the cartridge header on load).
908 pub(crate) active_palette: crate::palette::PpuPalette,
909 /// v2.8.0 Phase 4 — precomputed `(emphasis bits << 6) | color` → RGBA8
910 /// lookup (8 emphasis combinations × 64 colors = 512 entries, 2 KiB).
911 /// Built from the same pure [`crate::palette::palette_color_to_rgba`]
912 /// the per-pixel path used to call, so it is byte-identical by
913 /// construction; rebuilt whenever [`Ppu::set_palette`] changes the
914 /// active palette. Saves a palette-variant match + emphasis branches
915 /// per emitted pixel (61,440/frame). NOT part of the save-state
916 /// (derived data).
917 pub(crate) rgba_lut: [[u8; 4]; 512],
918 /// v1.1.0 beta.1 (T-110-A3) — optional custom 64-entry base palette from a
919 /// loaded `.pal` file. `None` (default) = use the built-in palette for the
920 /// active [`crate::palette::PpuPalette`], so default rendering is
921 /// byte-identical. When `Some`, [`Self::rgba_lut`] is built from it via the
922 /// composite emphasis model. A frontend presentation override — NOT part of
923 /// the save-state (it persists across save/load like the active palette).
924 pub(crate) custom_palette: Option<[[u8; 3]; 64]>,
925 /// True when this is a 2C05 PPU: `$2000`/`$2001` are swapped and `$2002`
926 /// returns the 2C05 sub-variant identifier in its low bits. Default false
927 /// (a 2C02 / 2C03 / 2C04, none of which swap or report an id).
928 pub(crate) is_2c05: bool,
929 /// 2C05 sub-variant `$2002` identifier byte (e.g. `$3D` for 2C05-02). Only
930 /// consulted when [`Self::is_2c05`] is true. Combined into the low 5 bits
931 /// of a `$2002` read per nesdev "PPU registers" §2C05 identifier.
932 pub(crate) id_2c05: u8,
933
934 /// v2.1.7 P5 — selected 2C02 die revision. Gates the OAMADDR (`$2003`)
935 /// write-during-rendering OAM corruption glitch (see [`PpuRevision`]). The
936 /// [`PpuRevision::default`] ([`PpuRevision::Rp2c02H`]) models NO extra
937 /// corruption, so the default build is byte-identical. Construction / config
938 /// only — never mutated by emulation and, like [`Self::active_palette`] /
939 /// [`Self::region`], re-applied on load rather than serialized in the
940 /// snapshot (the corruption *state* it can arm — `oam_corruption_pending` /
941 /// `oam_corruption_index` — IS in the v6 snapshot tail, so an armed
942 /// corruption still round-trips).
943 pub(crate) die_revision: PpuRevision,
944 /// v2.1.7 P5 — the power-up palette-RAM contents selected for this PPU (see
945 /// [`PaletteInit`]). Stored so a power-cycle can re-apply it after the PPU is
946 /// reconstructed. The [`PaletteInit::default`] ([`PaletteInit::Zeroed`])
947 /// leaves palette RAM all-zero (the established default), keeping default
948 /// rendering byte-identical. Config, not serialized (it writes
949 /// [`Self::palette_ram`], which the snapshot already carries).
950 pub(crate) power_up_palette: PaletteInit,
951
952 /// Framebuffer (RGBA8). Filled by Sprint 2-2/2-3 rendering.
953 pub(crate) framebuffer: Box<[u8]>,
954
955 /// v1.1.0 beta.1 (T-110-A1) — parallel per-pixel **palette-index**
956 /// framebuffer for the true composite `NES_NTSC` filter. Each entry is the
957 /// 9-bit `(emphasis << 6) | colour_index` value (0..=511) written in the
958 /// same emit path as [`Self::framebuffer`], so it is a faithful index-space
959 /// mirror of the RGBA output. The frontend uploads it as an `R16Uint`
960 /// texture and reconstructs the composite signal in a shader. Purely an
961 /// output buffer: it changes no logical state, so the determinism /
962 /// `AccuracyCoin` contract is unaffected. Unlike `framebuffer` (which IS in
963 /// the save-state), this and `dot_counter` / `frame_ntsc_phase` are NOT
964 /// serialized — they are regenerated on the next emitted frame, so a state
965 /// loaded while paused shows correct NTSC from the first frame after resume.
966 pub(crate) index_framebuffer: Box<[u16]>,
967 /// How many dots took the specialized fast path (`ppu-fetch-trace` only).
968 ///
969 /// Not telemetry. It exists so a test can assert it EXERCISED the fast path
970 /// rather than passing because the path was never entered — the failure this
971 /// project keeps finding, where a check agrees about something it never
972 /// reached. A review of #450 claimed the fast path bypasses the fetch trace;
973 /// the test refuting that is worthless unless it can show the path ran, and
974 /// this is how it shows it.
975 #[cfg(feature = "ppu-fetch-trace")]
976 pub fast_path_hits: u64,
977
978 /// Free-running PPU master-cycle counter (one increment per [`Self::tick`]),
979 /// the basis for the per-frame NTSC colour phase. Output-only / cosmetic
980 /// (drives only the optional NTSC filter's dot-crawl); not part of the
981 /// save-state. Wraps harmlessly.
982 pub(crate) dot_counter: u64,
983
984 /// NTSC composite colour phase snapshotted at each frame boundary, the
985 /// per-frame `videoPhase` consumed by the `NES_NTSC` filter (the shader
986 /// derives the per-scanline / per-pixel phase from this base). `0..=2` on
987 /// NTSC; on PAL/Dendy (no 3-phase crawl) it is the frame parity (`0..=1`).
988 /// Cosmetic; not part of the save-state.
989 pub(crate) frame_ntsc_phase: u8,
990
991 /// v1.7.0 "Forge" Workstream F3 — PPU extra-scanlines overclock.
992 ///
993 /// Number of EXTRA blank scanlines to insert into the vblank period each
994 /// frame (immediately before the pre-render line), at the existing dot
995 /// resolution (Mesen2 `UpdateTimings`). These lines render nothing, emit no
996 /// pixels, set/clear no PPU flags, and fire no VBL/NMI/A12 events — they are
997 /// pure additional CPU run-time per frame, giving games more compute headroom
998 /// without altering the visible image. **Off by default (`0`)**; the
999 /// `advance_dot` insertion path is entirely guarded by `extra_scanlines != 0`,
1000 /// so at the default this field changes nothing and the frame is
1001 /// byte-identical to stock. Distinct from the CPU-multiplier overclock (a
1002 /// v2.0 timebase item). A frontend config knob, NOT part of the save-state
1003 /// (re-applied by the frontend on restore, like `region` / `active_palette`).
1004 pub(crate) extra_scanlines: u16,
1005 /// v1.7.0 F3 — countdown of extra blank scanlines remaining for the CURRENT
1006 /// frame's vblank insertion. Loaded from [`Self::extra_scanlines`] when the
1007 /// PPU reaches the insertion point and decremented one extra line at a time.
1008 /// `0` when no insertion is in flight. Snapshotted (snapshot v4) so a
1009 /// save-state taken mid-insertion restores the in-flight countdown rather
1010 /// than resuming as `0` and desyncing. The configured count itself
1011 /// (`extra_scanlines`) stays a non-persisted frontend knob, re-applied on
1012 /// restore. At the default `extra_scanlines == 0` this is always `0`.
1013 pub(crate) extra_lines_remaining: u16,
1014
1015 /// v2.1.8 A1 — enable the specialized straight-line per-dot fast path for
1016 /// the common visible-scanline BG-render window (see [`Self::tick`] and
1017 /// `docs/performance.md`). When `true`, visible scanline dots `1..=256`
1018 /// whose per-dot state is provably "undisturbed" (no pending `$2006`
1019 /// copy-V, no PPUMASK write-delay, no PPUDATA state machine in flight, no
1020 /// armed/pending OAM-corruption, warm scanline classification cache,
1021 /// stable rendering-enable) are dispatched to
1022 /// [`Self::tick_visible_render_fast`], which executes the identical
1023 /// helper sequence with the statically-dead event/bookkeeping branches
1024 /// pruned. Any disturbance drops instantly back to the exact path.
1025 /// **Not serialized** — a frontend/config knob re-applied on restore.
1026 ///
1027 /// **Default `true` since the v2.2.3 performance pass (was default-OFF for
1028 /// v2.1.8 .. v2.2.2).** A1 shipped this off deliberately: it was that
1029 /// roadmap's highest-risk item, and keeping it off left the shipped build
1030 /// byte-identical while the differential test and the oracle suites proved
1031 /// correctness. Both conditions A1 named for promotion are now met —
1032 ///
1033 /// * **byte-identity**, held continuously since v2.1.8 by
1034 /// `crates/rustynes-test-harness/tests/fast_dotloop_diff.rs`, which runs
1035 /// a ROM corpus through BOTH paths and asserts identical framebuffer,
1036 /// palette-index framebuffer, audio, CPU-cycle count and full core
1037 /// snapshot **every frame** (so the fast path has never been unproven —
1038 /// only unshipped); and
1039 /// * **a clean-host Criterion confirmation** of the win: `full_frame`
1040 /// `nes_run_frame_nestest` 4.4343 ms -> 3.9331 ms, **-11.3%**, on a quiet
1041 /// host, reproducing A1's interleaved +12.3% measurement. The
1042 /// rendering-disabled `flowing_palette` workload is unchanged (-0.07%,
1043 /// noise) because its guard bails at `rendering_enabled()`.
1044 ///
1045 /// Promotion changes the *default*, not the behaviour: the frame the fast
1046 /// path produces is the frame the exact path produces, by construction and
1047 /// by test. `false` still selects the fully-general per-dot path and
1048 /// remains the fallback for any future doubt.
1049 pub(crate) fast_dotloop: bool,
1050
1051 /// Optional per-PPU-dot state trace (Session-10 observability
1052 /// tooling). Gated on the `ppu-state-trace` cargo feature so
1053 /// the default build pays no memory or codegen cost. See
1054 /// `docs/adr/0005-ppu-state-trace.md`.
1055 #[cfg(feature = "ppu-state-trace")]
1056 pub(crate) state_trace: Option<crate::state_trace::PpuStateTrace>,
1057 /// Per-dot PPU bus address capture. See [`crate::fetch_trace`].
1058 #[cfg(feature = "ppu-fetch-trace")]
1059 pub(crate) fetch_trace: Option<crate::fetch_trace::FetchTrace>,
1060
1061 /// v1.2.0 beta.2 (Workstream C3) — per-pixel HD-pack tile-source buffer
1062 /// (256 × 240 [`HdTileSource`] records), written in [`Self::emit_pixel`]
1063 /// in lockstep with [`Self::index_framebuffer`]. Output-only telemetry,
1064 /// gated on the `hd-pack` cargo feature so the default build pays no
1065 /// memory or codegen cost. Not part of the save-state.
1066 #[cfg(feature = "hd-pack")]
1067 pub(crate) hd_tile_source: Box<[HdTileSource]>,
1068
1069 /// v1.2.0 beta.2 (Workstream C3) — BG tile CHR base address latched at
1070 /// `fetch_bg_lo` time, then reloaded into the 2-stage `hd_bg_addr_*`
1071 /// queue in `reload_bg_shift_regs` so it tracks the BG pattern shift
1072 /// registers tile-for-tile. Pure telemetry; only touched when `hd-pack`
1073 /// is enabled.
1074 #[cfg(feature = "hd-pack")]
1075 pub(crate) hd_bg_addr_latch: u16,
1076 /// CHR base address of the BG tile currently feeding the shifters' high
1077 /// byte (the tile being displayed). See [`Self::hd_bg_addr_latch`].
1078 #[cfg(feature = "hd-pack")]
1079 pub(crate) hd_bg_addr_cur: u16,
1080 /// CHR base address of the next BG tile (shifters' low byte). Promoted to
1081 /// `hd_bg_addr_cur` on the prefetch byte-shift / per-tile boundary.
1082 #[cfg(feature = "hd-pack")]
1083 pub(crate) hd_bg_addr_next: u16,
1084 /// CHR base address fetched per sprite slot at `fetch_sprite_tile` time,
1085 /// consumed by `emit_pixel` for HD-pack sprite substitution.
1086 #[cfg(feature = "hd-pack")]
1087 pub(crate) hd_spr_addr: [u16; 8],
1088 /// The sprite's ORIGIN screen X per slot (the un-decremented `spr_x`), so
1089 /// `emit_pixel` can derive the column within the sprite for HD positioning.
1090 #[cfg(feature = "hd-pack")]
1091 pub(crate) hd_spr_x: [u8; 8],
1092 /// The sprite's flip-baked texel ROW (0..=7) per slot, captured at fetch (the
1093 /// `emit_pixel`-time scanline isn't enough to recover it post-shift).
1094 #[cfg(feature = "hd-pack")]
1095 pub(crate) hd_spr_off_y: [u8; 8],
1096 /// Absolute CHR-ROM tile index (`chr_phys/16`, or [`HD_CHR_RAM`]) tracked in
1097 /// lock-step with the `hd_bg_addr_*` cascade — the CHR-ROM HD-pack key.
1098 #[cfg(feature = "hd-pack")]
1099 pub(crate) hd_bg_idx_latch: u32,
1100 /// CHR-ROM tile index of the BG tile feeding the shifters' high byte.
1101 #[cfg(feature = "hd-pack")]
1102 pub(crate) hd_bg_idx_cur: u32,
1103 /// CHR-ROM tile index of the next BG tile (shifters' low byte).
1104 #[cfg(feature = "hd-pack")]
1105 pub(crate) hd_bg_idx_next: u32,
1106 /// Absolute CHR-ROM tile index per sprite slot (or [`HD_CHR_RAM`]).
1107 #[cfg(feature = "hd-pack")]
1108 pub(crate) hd_spr_idx: [u32; 8],
1109
1110 /// v2.3.2 "Lucid" — per-byte write attribution for CIRAM / OAM / palette RAM.
1111 ///
1112 /// `None` (the default) until the frontend arms it via
1113 /// [`Self::set_write_attribution`], so an unarmed `debug-hooks` build pays one
1114 /// `Option` test per PPU-memory write and no heap at all. Output-only
1115 /// telemetry: nothing in the emulation path reads it, so an armed store is
1116 /// bit-identical to an unarmed one. Deliberately NOT part of the save-state —
1117 /// attribution describes writes *this session* performed, and a restored
1118 /// state's bytes have no such history (see [`crate::provenance`]).
1119 #[cfg(feature = "debug-hooks")]
1120 pub(crate) write_attrib: Option<Box<crate::provenance::WriteAttribution>>,
1121 /// The `(pc, cycle)` of the CPU instruction currently executing, pushed down
1122 /// by the bus before each `$2000-$3FFF` register write and before each OAM
1123 /// DMA burst. Stamped into every attribution record made while it is set.
1124 ///
1125 /// The PPU cannot derive this itself: it never sees the CPU's program
1126 /// counter, and the effective VRAM destination the bus would need to record
1127 /// the attribution itself lives in the PPU's internal `v` register. Splitting
1128 /// the two halves this way is what lets each side contribute only what it
1129 /// actually knows.
1130 #[cfg(feature = "debug-hooks")]
1131 pub(crate) attrib_pc: u16,
1132 /// CPU cycle counterpart of [`Self::attrib_pc`].
1133 #[cfg(feature = "debug-hooks")]
1134 pub(crate) attrib_cycle: u64,
1135 /// The `(pc, cycle)` of the `STA $4014` that armed the in-flight OAM DMA.
1136 ///
1137 /// Separate from [`Self::attrib_pc`] because the burst does not run during
1138 /// the triggering instruction: `$4014` only sets `dma_pending`, and the 513
1139 /// or 514 DMA cycles are then stolen from the instructions that follow. By
1140 /// the time the first byte lands, [`Self::attrib_pc`] has already advanced to
1141 /// whichever instruction is being halted — an answer that is true about the
1142 /// *timing* and wrong about the *cause*. The bus latches this pair at the
1143 /// `$4014` write via [`Self::latch_dma_attrib_context`] so every byte of the
1144 /// burst names the store that actually caused it.
1145 #[cfg(feature = "debug-hooks")]
1146 pub(crate) dma_attrib_pc: u16,
1147 /// CPU cycle counterpart of [`Self::dma_attrib_pc`].
1148 #[cfg(feature = "debug-hooks")]
1149 pub(crate) dma_attrib_cycle: u64,
1150
1151 /// v2.3.2 "Lucid" phase 2 — per-pixel provenance for the current frame.
1152 ///
1153 /// `None` until armed, like [`Self::write_attrib`]. Overwritten in place
1154 /// every frame, exactly like the framebuffer it shadows.
1155 #[cfg(feature = "debug-hooks")]
1156 pub(crate) prov_frame: Option<Box<crate::provenance::PixelProvenanceFrame>>,
1157 /// Fast "is provenance armed?" flag, mirroring `prov_frame.is_some()`.
1158 ///
1159 /// `emit_pixel` runs 61,440 times a frame and is one of the two hottest
1160 /// functions in the emulator, so the per-pixel guard is a plain `bool` load
1161 /// rather than an `Option<Box<..>>` discriminant behind a pointer. Same
1162 /// shape as the bus's `event_logging` / `access_logging` flags.
1163 #[cfg(feature = "debug-hooks")]
1164 pub(crate) prov_armed: bool,
1165 /// Nametable address of the most recent NT fetch, awaiting commit.
1166 ///
1167 /// Held separately from [`Self::prov_bg_latch`] because the PPU performs two
1168 /// **dummy nametable fetches** at dots 337-340, after the pre-render line's
1169 /// last real tile has been fetched but before the visible line's first
1170 /// reload consumes it. Writing the NT address straight into the latch let
1171 /// those dummies overwrite the pending tile, so the first visible tile group
1172 /// reported the address of the tile after it. A tile is defined when its
1173 /// PATTERN is fetched — which the dummy fetches never do — so the pending
1174 /// addresses are committed in `fetch_bg_lo`.
1175 #[cfg(feature = "debug-hooks")]
1176 pub(crate) prov_nt_pending: u16,
1177 /// Attribute address awaiting commit. See [`Self::prov_nt_pending`].
1178 #[cfg(feature = "debug-hooks")]
1179 pub(crate) prov_at_pending: u16,
1180 /// Addresses of the background tile most recently FETCHED (the tile two
1181 /// slots ahead of the one on screen). Committed at pattern-fetch time;
1182 /// promoted through `next` into `cur` by the same shift-register reloads
1183 /// that move the pattern bytes, so the cascade stays tile-for-tile aligned
1184 /// with what the shifters are emitting.
1185 #[cfg(feature = "debug-hooks")]
1186 pub(crate) prov_bg_latch: ProvBgAddrs,
1187 /// Addresses of the background tile currently BEING DISPLAYED — the one
1188 /// `emit_pixel` must report.
1189 ///
1190 /// This cascade exists because `v` cannot answer the question: by the time a
1191 /// tile's pixels reach the screen, `v` has already advanced two tiles past
1192 /// it. Deriving the nametable address from `v` at emit time would be wrong
1193 /// for every pixel, and wrong in a way that looks plausible.
1194 #[cfg(feature = "debug-hooks")]
1195 pub(crate) prov_bg_cur: ProvBgAddrs,
1196 /// Addresses of the next background tile (the shifters' low byte), promoted
1197 /// into [`Self::prov_bg_cur`] on the per-tile boundary.
1198 #[cfg(feature = "debug-hooks")]
1199 pub(crate) prov_bg_next: ProvBgAddrs,
1200 /// Pattern address fetched per sprite slot, so a sprite pixel can report the
1201 /// CHR row behind it. Mirrors the `hd-pack` `hd_spr_addr` capture, kept
1202 /// separate so neither feature's telemetry depends on the other being on.
1203 #[cfg(feature = "debug-hooks")]
1204 pub(crate) prov_spr_addr: [u16; 8],
1205}
1206
1207/// The three VRAM addresses that produced one background tile.
1208///
1209/// Carried as a unit through the PPU's internal fetch → display cascade
1210/// (`latch` → `next` → `cur`), so a promotion is one struct copy instead of
1211/// three separate field moves that could drift out of step with each other.
1212#[cfg(feature = "debug-hooks")]
1213#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1214pub struct ProvBgAddrs {
1215 /// Nametable address the tile number came from.
1216 pub nt: u16,
1217 /// Attribute address the palette group came from. Carried rather than
1218 /// derived, because an MMC5 vertical split supplies its own attribute
1219 /// address that the standard `$23C0 | ...` arithmetic cannot produce.
1220 pub at: u16,
1221 /// CHR address of the pattern row (the low-plane address; the high plane is
1222 /// `+8`).
1223 pub pattern: u16,
1224}
1225
1226/// v2.0 Phase 6 (`mc-ppu-subpos`): the analog `$2001` PPUMASK write delay.
1227///
1228/// In PPU dots. `TriCNES` applies a `$2001` write 2-3 dots after the CPU write
1229/// (`PPU_Update2001Delay`, sub-dot alignment dependent); this emulator's reload
1230/// sits one dot later than that plus the existing 1-dot render gate, so the
1231/// effective default is 4. Runtime-tunable (an atomic) so the exact phase can be
1232/// swept against the `BG Serial In` / `Stale BG Shift` keys without rebuilding.
1233/// Only consulted under `mc-ppu-subpos`.
1234pub static MASK_WRITE_DELAY: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(4);
1235impl Ppu {
1236 /// New PPU in power-on state.
1237 #[must_use]
1238 // The power-on field initialization is naturally long (the PPU has many
1239 // state fields); the feature-gated `hd-pack` initializers nudge it over the
1240 // 100-line lint. Splitting the struct literal would hurt readability.
1241 #[allow(clippy::too_many_lines)]
1242 pub fn new(region: PpuRegion) -> Self {
1243 let mut p = Self {
1244 region,
1245 ctrl: PpuCtrl::empty(),
1246 mask: PpuMask::empty(),
1247 mask_for_skip_check: PpuMask::empty(),
1248 mask_skip_pipe1: PpuMask::empty(),
1249 status: PpuStatus::empty(),
1250 oam_addr: 0,
1251 data_buffer: 0,
1252 render_data_bus: 0,
1253 ppudata_sm_countdown: 0,
1254 ppudata_v_inc_pending: false,
1255 spr_fetch_lo_raw: [0; 8],
1256 spr_fetch_hi_raw: [0; 8],
1257 ppudata_spr0_nt_addr: 0x2000,
1258 octal_latch: 0,
1259 address_bus: 0,
1260 ale_armed: false,
1261 pattern_latch_stale: false,
1262 copy_v_delay: 0,
1263 v: 0,
1264 t: 0,
1265 x: 0,
1266 w: false,
1267 ciram: vec![0u8; 0x0800].into_boxed_slice(),
1268 oam: vec![0u8; 0x0100].into_boxed_slice(),
1269 secondary_oam: [0xFF; 32],
1270 oam_bus_copybuffer: 0xFF,
1271 oam_bus_secondary: [0xFF; 32],
1272 oam_bus_addr_h: 0,
1273 oam_bus_addr_l: 0,
1274 oam_bus_secondary_addr: 0,
1275 oam_bus_copy_done: false,
1276 oam_bus_sprite_in_range: false,
1277 oam_bus_overflow_counter: 0,
1278 palette_ram: [0u8; 32],
1279 // v2.1.4 F2.3 — OAM decay is off by default; the timestamps start at 0
1280 // (row "last touched at cycle 0"). While disabled they are never read,
1281 // and `set_oam_decay(true)` re-bases them to the current cycle so
1282 // enabling mid-run does not instantly decay every row.
1283 oam_decay_cycles: [0; 32],
1284 oam_decay_enabled: false,
1285 open_bus: 0,
1286 open_bus_decay: [0; 3],
1287 nmi_line: false,
1288 suppress_vbl_this_frame: false,
1289 last_a12_level: false,
1290 // Power-up position matches Mesen2's NesPpu::Reset(false) endpoint
1291 // (_scanline=-1, _cycle=340). After the first PPU tick, wraps to
1292 // (scanline=0, dot=0, frame+=1), putting the post-power-on PPU
1293 // position within ~2 dots of Mesen2's. Combined with the 8-cycle
1294 // CPU reset (see Cpu::reset), this closes the +344-dot PPU offset
1295 // identified empirically in Session-13 (docs/audit/
1296 // session-13-cpu-boot-fix-2026-05-21.md).
1297 //
1298 // SESSION-29 CRITICAL FINDING: Option (a) "PPU re-baseline"
1299 // empirically attempted and DOES NOT CLOSE THE C1 AXIS.
1300 // Shifting PPU init by +2 dots to (scanline=0, dot=1):
1301 // - Generates 24 snapshot regressions (audio_db, visual,
1302 // m22, Cascade A) — all are "expected" cosmetic shifts.
1303 // - BUT the cpu_interrupts_v2/{2,3,5}_strict probes STILL
1304 // FAIL — confirmed via `cargo test ... --include-ignored`.
1305 //
1306 // The +2 dot shift moves everything uniformly: VBL set position
1307 // AND BIT $2002 read position both shift by +2 dots, preserving
1308 // the relative race-window relationship. The BIT $2002 polling
1309 // loop inside blargg `sync_vbl` still hits the pre-VBL-set side
1310 // of the race window.
1311 //
1312 // CONCLUSION: closing C1 requires changing the PHASE
1313 // RELATIONSHIP between CPU and PPU (Option b — master-clock-
1314 // precise scheduling refactor), NOT a global PPU init shift.
1315 // The 4 C1 IRQ-timing residuals are deferred to v2.0 with the
1316 // master-clock refactor; v1.0.0 ships at 90.65% AccuracyCoin
1317 // with the 4 residuals documented as v2.0-deferred. See
1318 // `docs/audit/session-29-c1-axis-final-conclusion-2026-05-23.md`
1319 // + `docs/audit/session-29-option-a-empirical-falsification.md`.
1320 dot: 340,
1321 scanline: region.prerender_line(),
1322 frame: 0,
1323 frame_complete: false,
1324 post_reset_mask_remaining: region.post_reset_mask_cycles(),
1325 nt_latch: 0,
1326 at_latch: 0,
1327 bg_lo_latch: 0,
1328 bg_hi_latch: 0,
1329 bg_shift_lo: 0,
1330 bg_shift_hi: 0,
1331 at_shift_lo: 0,
1332 at_shift_hi: 0,
1333 ex_attr_latch: None,
1334 bg_split_latch: None,
1335 spr_shift_lo: [0; 8],
1336 spr_shift_hi: [0; 8],
1337 spr_attr: [0; 8],
1338 spr_x: [0; 8],
1339 spr_halted: [true; 8],
1340 spr_count: 0,
1341 spr_zero_in_line: false,
1342 sprite_eval_read_latch: 0xFF,
1343 sprite_eval_n: 0,
1344 sprite_eval_m: 0,
1345 sprite_eval_found: 0,
1346 sprite_eval_sec_idx: 0,
1347 sprite_eval_copying: false,
1348 sprite_eval_done: false,
1349 sprite_eval_overflow_search: false,
1350 sprite_eval_zero_found: false,
1351 sprite_eval_first_iter: false,
1352 oam_corruption_pending: false,
1353 oam_corruption_index: 0,
1354 oam_corruption_disabled: false,
1355 oam_corruption_disabled_instant: false,
1356 oam2_addr: 0,
1357 prev_rendering_enabled: false,
1358 rendering_enabled_delayed: false,
1359 bg_reload_render: false,
1360 mask_write_delay: 0,
1361 cached_visible: false,
1362 cached_pre_render: false,
1363 cached_render_line: false,
1364 #[cfg(feature = "ppu-idle-line-fast")]
1365 cached_idle_line: false,
1366 flags_cached_scanline: i16::MIN,
1367 active_palette: crate::palette::PpuPalette::Composite2C02,
1368 rgba_lut: build_rgba_lut(crate::palette::PpuPalette::Composite2C02),
1369 custom_palette: None,
1370 is_2c05: false,
1371 id_2c05: 0,
1372 // v2.1.7 P5 — default revision models no extra corruption; default
1373 // power-up palette is all-zero. Both keep the default build
1374 // byte-identical (the `palette_ram: [0u8; 32]` above already reflects
1375 // the `PaletteInit::Zeroed` default).
1376 die_revision: PpuRevision::Rp2c02H,
1377 power_up_palette: PaletteInit::Zeroed,
1378 framebuffer: vec![0u8; FRAMEBUFFER_LEN].into_boxed_slice(),
1379 index_framebuffer: vec![0u16; FRAMEBUFFER_PIXELS].into_boxed_slice(),
1380 #[cfg(feature = "ppu-fetch-trace")]
1381 fast_path_hits: 0,
1382 dot_counter: 0,
1383 frame_ntsc_phase: 0,
1384 extra_scanlines: 0,
1385 extra_lines_remaining: 0,
1386 // v2.2.3 performance pass: promoted to the default (was `false`
1387 // through v2.2.2). Byte-identical to the exact path by
1388 // construction and by `fast_dotloop_diff.rs`; -11.3% on the
1389 // rendering-heavy `full_frame` bench. See the field's rustdoc.
1390 fast_dotloop: true,
1391 #[cfg(feature = "ppu-state-trace")]
1392 state_trace: None,
1393 #[cfg(feature = "ppu-fetch-trace")]
1394 fetch_trace: None,
1395 #[cfg(feature = "hd-pack")]
1396 hd_tile_source: vec![HdTileSource::default(); FRAMEBUFFER_PIXELS].into_boxed_slice(),
1397 #[cfg(feature = "hd-pack")]
1398 hd_bg_addr_latch: HD_TILE_NONE,
1399 #[cfg(feature = "hd-pack")]
1400 hd_bg_addr_cur: HD_TILE_NONE,
1401 #[cfg(feature = "hd-pack")]
1402 hd_bg_addr_next: HD_TILE_NONE,
1403 #[cfg(feature = "hd-pack")]
1404 hd_spr_addr: [HD_TILE_NONE; 8],
1405 #[cfg(feature = "hd-pack")]
1406 hd_spr_x: [0; 8],
1407 #[cfg(feature = "hd-pack")]
1408 hd_spr_off_y: [0; 8],
1409 #[cfg(feature = "hd-pack")]
1410 hd_bg_idx_latch: HD_CHR_RAM,
1411 #[cfg(feature = "hd-pack")]
1412 hd_bg_idx_cur: HD_CHR_RAM,
1413 #[cfg(feature = "hd-pack")]
1414 hd_bg_idx_next: HD_CHR_RAM,
1415 #[cfg(feature = "hd-pack")]
1416 hd_spr_idx: [HD_CHR_RAM; 8],
1417 #[cfg(feature = "debug-hooks")]
1418 write_attrib: None,
1419 #[cfg(feature = "debug-hooks")]
1420 attrib_pc: 0,
1421 #[cfg(feature = "debug-hooks")]
1422 attrib_cycle: 0,
1423 #[cfg(feature = "debug-hooks")]
1424 dma_attrib_pc: 0,
1425 #[cfg(feature = "debug-hooks")]
1426 dma_attrib_cycle: 0,
1427 #[cfg(feature = "debug-hooks")]
1428 prov_frame: None,
1429 #[cfg(feature = "debug-hooks")]
1430 prov_armed: false,
1431 #[cfg(feature = "debug-hooks")]
1432 prov_nt_pending: 0,
1433 #[cfg(feature = "debug-hooks")]
1434 prov_at_pending: 0,
1435 #[cfg(feature = "debug-hooks")]
1436 prov_bg_latch: ProvBgAddrs::default(),
1437 #[cfg(feature = "debug-hooks")]
1438 prov_bg_cur: ProvBgAddrs::default(),
1439 #[cfg(feature = "debug-hooks")]
1440 prov_bg_next: ProvBgAddrs::default(),
1441 // The "no pattern" sentinel, not 0 — 0 is a legitimate CHR address.
1442 // Unreachable at emit time today (a sprite is only selected when
1443 // `spr_idx != 0`, which implies a real fetch), but the adjacent
1444 // `hd_spr_addr` uses its own sentinel for exactly this reason and a
1445 // future reader should not have to re-derive why 0 was safe.
1446 #[cfg(feature = "debug-hooks")]
1447 prov_spr_addr: [crate::provenance::PATTERN_ADDR_NONE; 8],
1448 };
1449 // Clear status flags that match power-on per nesdev wiki: VBL is
1450 // unspecified on power-on. We start clear.
1451 p.status = PpuStatus::empty();
1452 p
1453 }
1454
1455 /// Configure the PPU's hardware variant for Vs. System / PlayChoice-10
1456 /// arcade carts.
1457 ///
1458 /// `palette` selects the output palette (the RGB PPUs replace the 2C02
1459 /// composite palette with a fixed hardware RGB lookup). `is_2c05` enables
1460 /// the 2C05's register quirks: a write to `$2000` sets MASK and a write to
1461 /// `$2001` sets CTRL (swapped), and a `$2002` read ORs `id` into its low
1462 /// bits. For a 2C02 (the default NES/Famicom path) this is never called, so
1463 /// `active_palette` stays [`crate::palette::PpuPalette::Composite2C02`],
1464 /// `is_2c05` stays `false`, and behaviour is byte-for-byte unchanged.
1465 pub const fn set_palette(
1466 &mut self,
1467 palette: crate::palette::PpuPalette,
1468 is_2c05: bool,
1469 id: u8,
1470 ) {
1471 self.active_palette = palette;
1472 // v2.8.0 Phase 4 — keep the per-pixel RGBA lookup in sync with the active
1473 // palette (byte-identical by construction; see `rgba_lut`). A loaded `.pal`
1474 // (`custom_palette`) overrides the built-in table; `rebuild_rgba_lut`
1475 // honours it.
1476 self.rebuild_rgba_lut();
1477 self.is_2c05 = is_2c05;
1478 self.id_2c05 = id;
1479 }
1480
1481 /// v1.1.0 beta.1 (T-110-A3) — install (or clear with `None`) a custom 64-entry
1482 /// base palette from a loaded `.pal` file and rebuild the RGBA lookup. `None`
1483 /// restores the built-in palette for the active [`crate::palette::PpuPalette`]
1484 /// (byte-identical to default). A frontend presentation override.
1485 pub const fn set_custom_palette(&mut self, base: Option<[[u8; 3]; 64]>) {
1486 self.custom_palette = base;
1487 self.rebuild_rgba_lut();
1488 }
1489
1490 /// v1.7.0 "Forge" Workstream F3 — set the number of EXTRA blank vblank
1491 /// scanlines to insert per frame (the PPU extra-scanlines overclock).
1492 ///
1493 /// `0` (the default) is stock NES timing and is **byte-identical** to a PPU
1494 /// that never calls this. A non-zero value lengthens vblank by that many
1495 /// idle scanlines each frame (more CPU run-time, no visible change), at the
1496 /// existing dot resolution. Off by default; a frontend config knob, not part
1497 /// of the save-state. Distinct from the CPU-multiplier overclock (v2.0).
1498 ///
1499 /// Changing the count cancels any in-flight insertion for the current
1500 /// frame: the per-frame countdown (`extra_lines_remaining`) is
1501 /// reset to `0` so it cannot remain stale or out-of-bounds relative to
1502 /// the new `lines` (e.g. shrinking 8 → 2, or disabling N → 0). The next
1503 /// frame reloads the countdown from the new value at the insertion point.
1504 pub const fn set_extra_scanlines(&mut self, lines: u16) {
1505 self.extra_scanlines = lines;
1506 self.extra_lines_remaining = 0;
1507 }
1508
1509 /// v1.7.0 F3 — the currently-configured extra-scanline count (`0` = stock).
1510 #[must_use]
1511 pub const fn extra_scanlines(&self) -> u16 {
1512 self.extra_scanlines
1513 }
1514
1515 /// v2.1.8 A1 — enable/disable the specialized visible-scanline fast dot
1516 /// path. **Default ON since the v2.2.3 performance pass** (was OFF through
1517 /// v2.2.2); either setting produces the identical frame, so this selects a
1518 /// code path, not a behaviour. See [`Self::fast_dotloop`] and
1519 /// `docs/performance.md`.
1520 pub const fn set_fast_dotloop(&mut self, enabled: bool) {
1521 self.fast_dotloop = enabled;
1522 }
1523
1524 /// v2.1.8 A1 — whether the visible-scanline fast dot path is enabled.
1525 #[must_use]
1526 pub const fn fast_dotloop(&self) -> bool {
1527 self.fast_dotloop
1528 }
1529
1530 /// v2.1.4 F2.3 — enable or disable the optional OAM-decay accuracy model.
1531 ///
1532 /// **Off by default.** When off (the default) OAM reads/writes never consult
1533 /// the decay state and the deterministic output is **byte-identical** to a
1534 /// build without the feature — `AccuracyCoin`, the commercial oracle, and the
1535 /// visual/`external_real_games` regression suites are unaffected. When on, the
1536 /// PPU refreshes each 8-byte OAM row on every read (sprite evaluation + `$2004`)
1537 /// and write; a row that goes un-refreshed for more than `OAM_DECAY_CPU_CYCLES`
1538 /// (3000) CPU cycles decays to Mesen2's canonical garbage pattern on the next
1539 /// read (`oam_decay_on_read`). The model is NTSC/Dendy-only (PAL's refresh
1540 /// cadence masks decay) — the region gate lives in the hooks, so it is safe to
1541 /// enable on any region.
1542 ///
1543 /// A frontend/config knob (re-applied on load like `region` / `active_palette`),
1544 /// **not** part of the save-state. Turning the model ON re-bases every row's
1545 /// timestamp to the current CPU cycle so a freshly-enabled model does not report
1546 /// every row as instantly decayed; turning it OFF leaves the timestamps as-is
1547 /// (they are simply no longer consulted).
1548 pub const fn set_oam_decay(&mut self, enabled: bool) {
1549 if enabled && !self.oam_decay_enabled {
1550 // Freshly enabling: treat every row as just-refreshed so the first
1551 // post-enable reads don't spuriously report a multi-second-old row as
1552 // decayed. Idempotent re-enables (already on) skip this so a long
1553 // rendering-disabled span already in progress keeps decaying.
1554 let now = self.dot_counter / 3;
1555 let mut i = 0;
1556 while i < self.oam_decay_cycles.len() {
1557 self.oam_decay_cycles[i] = now;
1558 i += 1;
1559 }
1560 }
1561 self.oam_decay_enabled = enabled;
1562 }
1563
1564 /// v2.1.4 F2.3 — whether the optional OAM-decay model is currently enabled.
1565 #[must_use]
1566 pub const fn oam_decay_enabled(&self) -> bool {
1567 self.oam_decay_enabled
1568 }
1569
1570 /// v2.1.7 P5 — select the emulated 2C02 die revision (see [`PpuRevision`]).
1571 ///
1572 /// The [`PpuRevision::default`] ([`PpuRevision::Rp2c02H`]) models no extra
1573 /// quirks, so at the default this is behaviorally inert and the PPU is
1574 /// byte-identical to a build without the field. Selecting
1575 /// [`PpuRevision::Rp2c02G`] additionally arms the OAMADDR (`$2003`)
1576 /// write-during-rendering OAM corruption glitch. A construction/config knob,
1577 /// re-applied on load like the region / active palette — not part of the
1578 /// save-state.
1579 pub const fn set_revision(&mut self, revision: PpuRevision) {
1580 self.die_revision = revision;
1581 }
1582
1583 /// v2.1.7 P5 — the currently-selected 2C02 die revision.
1584 #[must_use]
1585 pub const fn revision(&self) -> PpuRevision {
1586 self.die_revision
1587 }
1588
1589 /// v2.1.7 P5 — apply a power-up palette-RAM pattern (see [`PaletteInit`]).
1590 ///
1591 /// Writes all 32 palette-RAM bytes to the selected pattern and records the
1592 /// selection so a subsequent power-cycle can re-apply it. The
1593 /// [`PaletteInit::default`] ([`PaletteInit::Zeroed`]) writes all-zero — the
1594 /// established power-up state — so at the default this leaves the PPU
1595 /// byte-identical. Intended to be called at construction / power-on (palette
1596 /// RAM is not cleared on a warm reset, matching real hardware). It writes
1597 /// [`Self::palette_ram`] directly, which the snapshot already serializes, so
1598 /// no snapshot-format change is required.
1599 pub const fn apply_power_up_palette(&mut self, init: PaletteInit) {
1600 self.power_up_palette = init;
1601 match init {
1602 PaletteInit::Zeroed => {
1603 let mut i = 0;
1604 while i < self.palette_ram.len() {
1605 self.palette_ram[i] = 0;
1606 i += 1;
1607 }
1608 }
1609 PaletteInit::Blargg => {
1610 let mut i = 0;
1611 while i < self.palette_ram.len() {
1612 // Palette-RAM cells are 6-bit; mask to match a `$2007` write.
1613 self.palette_ram[i] = BLARGG_POWER_UP_PALETTE[i] & 0x3F;
1614 i += 1;
1615 }
1616 }
1617 }
1618 }
1619
1620 /// v2.1.7 P5 — the currently-selected power-up palette pattern.
1621 #[must_use]
1622 pub const fn power_up_palette(&self) -> PaletteInit {
1623 self.power_up_palette
1624 }
1625
1626 /// v2.1.4 F2.3 — `true` when the OAM-decay model should act this access:
1627 /// enabled AND the region is NTSC/Dendy (PAL's frequent refresh masks decay,
1628 /// so Mesen2 never decays there). This is the single gate every decay hook
1629 /// funnels through; at the default (disabled) it is a single bool test and the
1630 /// hooks are behaviour-neutral.
1631 #[inline]
1632 const fn oam_decay_active(&self) -> bool {
1633 self.oam_decay_enabled && !matches!(self.region, PpuRegion::Pal)
1634 }
1635
1636 /// v2.1.4 F2.3 — OAM-read decay hook. Call **immediately before** reading
1637 /// `oam[addr]` at every primary-OAM read site (the `$2004` read and both
1638 /// sprite-evaluation read paths). Implements the `NESdev`-documented OAM DRAM
1639 /// decay-on-read behavior (`NESdev` wiki "PPU OAM" — sprite RAM is dynamic and
1640 /// its cells decay; a read recharges the touched row):
1641 ///
1642 /// - If the model is inactive (disabled or PAL), this is a no-op — `oam` and
1643 /// the timestamps are left untouched, so the read is byte-identical to stock.
1644 /// - Else, for the 8-byte row containing `addr`: if the last touch was within
1645 /// [`OAM_DECAY_CPU_CYCLES`] CPU cycles, refresh the row's timestamp (the DRAM
1646 /// cell was recharged by this access). Otherwise the row has decayed — rewrite
1647 /// all 8 of its bytes to the canonical pattern `((sprAddr & 3) == 2) ?
1648 /// (sprAddr & 0xE3) : sprAddr` (the attribute byte keeps only its implemented
1649 /// bits; the others read back their own low address) and leave the stale
1650 /// timestamp (so the row keeps reading decayed until a write refreshes it,
1651 /// matching the documented decay behavior).
1652 ///
1653 /// The subsequent `oam[addr]` read then returns the (possibly decayed) byte.
1654 #[inline]
1655 fn oam_decay_on_read(&mut self, addr: u8) {
1656 if !self.oam_decay_active() {
1657 return;
1658 }
1659 let row = (addr >> 3) as usize;
1660 let now = self.dot_counter / 3;
1661 // Saturating (wrapping) subtraction: `now` is monotone ≥ the stored
1662 // timestamp in practice, but `wrapping_sub` keeps this total even across a
1663 // (astronomically unlikely) u64 counter wrap.
1664 let elapsed = now.wrapping_sub(self.oam_decay_cycles[row]);
1665 if elapsed <= OAM_DECAY_CPU_CYCLES {
1666 self.oam_decay_cycles[row] = now;
1667 } else {
1668 let base = addr & 0xF8;
1669 for i in 0..8u8 {
1670 let spr_addr = base | i;
1671 self.oam[spr_addr as usize] = if spr_addr & 0x03 == 0x02 {
1672 spr_addr & 0xE3
1673 } else {
1674 spr_addr
1675 };
1676 }
1677 }
1678 }
1679
1680 /// v2.1.4 F2.3 — OAM-write decay hook. Call **after** writing `oam[addr]` at
1681 /// every primary-OAM write site (`$2004` / OAM DMA). Implements the documented
1682 /// OAM DRAM decay-on-write refresh (`NESdev` wiki "PPU OAM"): a write recharges
1683 /// the row's DRAM cells, so refresh the
1684 /// row's last-touch timestamp. Inactive (disabled or PAL) ⇒ no-op, so the write
1685 /// path is byte-identical to stock at the default.
1686 #[inline]
1687 const fn oam_decay_on_write(&mut self, addr: u8) {
1688 if !self.oam_decay_active() {
1689 return;
1690 }
1691 self.oam_decay_cycles[(addr >> 3) as usize] = self.dot_counter / 3;
1692 }
1693
1694 /// Rebuild [`Self::rgba_lut`] from the custom palette when one is loaded,
1695 /// otherwise from the active built-in [`crate::palette::PpuPalette`].
1696 const fn rebuild_rgba_lut(&mut self) {
1697 self.rgba_lut = match &self.custom_palette {
1698 Some(base) => build_rgba_lut_from_base(base),
1699 None => build_rgba_lut(self.active_palette),
1700 };
1701 }
1702
1703 /// Map a CPU-visible PPU register index (0-7) to the internal register,
1704 /// applying the 2C05 `$2000`<->`$2001` swap.
1705 ///
1706 /// On a 2C05 a write/read of `$2000` (reg 0) targets MASK (reg 1) and vice
1707 /// versa; all other registers are unaffected. On every other PPU (the
1708 /// default path) this is the identity, so normal NES behaviour is unchanged.
1709 const fn map_register(&self, reg: u8) -> u8 {
1710 if self.is_2c05 {
1711 match reg & 7 {
1712 0 => 1,
1713 1 => 0,
1714 other => other,
1715 }
1716 } else {
1717 reg & 7
1718 }
1719 }
1720
1721 /// Returns a reference to the internal CIRAM (nametables).
1722 pub fn vram_ref(&self) -> &[u8] {
1723 &self.ciram
1724 }
1725
1726 /// Returns a mutable reference to the internal CIRAM (nametables).
1727 pub fn vram_mut(&mut self) -> &mut [u8] {
1728 &mut self.ciram
1729 }
1730
1731 /// Performs a soft-reset of the PPU (warm boot). Per `docs/ppu-2c02.md`:
1732 /// - PPUCTRL := 0
1733 /// - PPUMASK := 0
1734 /// - w toggle := 0
1735 /// - PPUSTATUS bits 7 (VBL) unchanged on real hardware (we leave it
1736 /// as-is for parity with `$2002`-race tests)
1737 /// - PPUDATA buffer := 0
1738 /// - Mask window restarts (writes to $2000/$2001/$2005/$2006 ignored
1739 /// for the documented number of cycles after reset).
1740 pub const fn reset(&mut self) {
1741 self.ctrl = PpuCtrl::empty();
1742 self.mask = PpuMask::empty();
1743 self.mask_for_skip_check = PpuMask::empty();
1744 self.mask_skip_pipe1 = PpuMask::empty();
1745 self.w = false;
1746 self.data_buffer = 0;
1747 self.post_reset_mask_remaining = self.region.post_reset_mask_cycles();
1748 self.nmi_line = false;
1749 // v2.1.4 F2.3 — mark every OAM row freshly refreshed at reset, matching
1750 // Mesen2's `NesPpu::Reset` (which stamps `_oamDecayCycles` with the current
1751 // clock unconditionally). Doing this regardless of the enable flag keeps the
1752 // timestamps sane if decay is toggled on after a reset, and is inert while
1753 // decay is off (the array is never read). `dot_counter / 3` is the current
1754 // CPU cycle (NTSC/Dendy have 3 dots per CPU cycle).
1755 let now = self.dot_counter / 3;
1756 let mut i = 0;
1757 while i < self.oam_decay_cycles.len() {
1758 self.oam_decay_cycles[i] = now;
1759 i += 1;
1760 }
1761 }
1762
1763 /// Returns `true` if the PPU is asserting the NMI line.
1764 #[must_use]
1765 pub const fn nmi_line(&self) -> bool {
1766 self.nmi_line
1767 }
1768
1769 /// Consume and return the per-frame "frame complete" latch.
1770 pub const fn take_frame_complete(&mut self) -> bool {
1771 let r = self.frame_complete;
1772 self.frame_complete = false;
1773 r
1774 }
1775
1776 /// Install a state-trace buffer. Subsequent calls to
1777 /// [`Self::tick`] will append one [`PpuStateRecord`] per dot
1778 /// for dots inside the buffer's filter window. Pre-existing
1779 /// records (if any) are dropped.
1780 ///
1781 /// Read-only: every call to [`Self::tick`] reads PPU state
1782 /// after the dot's effects have applied; it never mutates
1783 /// emulator state, so the determinism contract is preserved
1784 /// (`docs/architecture.md` §Determinism).
1785 ///
1786 /// See `docs/adr/0005-ppu-state-trace.md` and the rustdoc on
1787 /// [`crate::state_trace`].
1788 ///
1789 /// [`PpuStateRecord`]: crate::state_trace::PpuStateRecord
1790 #[cfg(feature = "ppu-state-trace")]
1791 pub fn enable_state_trace(&mut self, trace: crate::state_trace::PpuStateTrace) {
1792 self.state_trace = Some(trace);
1793 }
1794
1795 /// Install a per-dot PPU bus address capture.
1796 ///
1797 /// The address bus is pin-observable, which is what makes it usable as a
1798 /// gate by an independent reimplementation; see [`crate::fetch_trace`] for
1799 /// why the address is captured rather than derived.
1800 #[cfg(feature = "ppu-fetch-trace")]
1801 pub fn enable_fetch_trace(&mut self, trace: crate::fetch_trace::FetchTrace) {
1802 self.fetch_trace = Some(trace);
1803 }
1804
1805 /// Remove and return the fetch trace, if one was installed.
1806 #[cfg(feature = "ppu-fetch-trace")]
1807 pub const fn take_fetch_trace(&mut self) -> Option<crate::fetch_trace::FetchTrace> {
1808 self.fetch_trace.take()
1809 }
1810
1811 /// Take the accumulated state trace, leaving the PPU's trace
1812 /// slot empty. Returns `None` if tracing was never enabled.
1813 #[cfg(feature = "ppu-state-trace")]
1814 #[must_use]
1815 pub const fn take_state_trace(&mut self) -> Option<crate::state_trace::PpuStateTrace> {
1816 self.state_trace.take()
1817 }
1818
1819 /// Borrow the in-flight state trace without taking it.
1820 #[cfg(feature = "ppu-state-trace")]
1821 #[must_use]
1822 pub const fn state_trace(&self) -> Option<&crate::state_trace::PpuStateTrace> {
1823 self.state_trace.as_ref()
1824 }
1825
1826 /// Build a [`PpuStateRecord`] snapshot from the PPU's
1827 /// current state. Used by the per-dot recording hook at the
1828 /// end of [`Self::tick`]; exposed publicly so external
1829 /// tooling (e.g. the trace fixture's end-of-frame snapshot)
1830 /// can re-use the canonical packer.
1831 ///
1832 /// [`PpuStateRecord`]: crate::state_trace::PpuStateRecord
1833 #[cfg(feature = "ppu-state-trace")]
1834 #[must_use]
1835 pub fn build_state_record(&self) -> crate::state_trace::PpuStateRecord {
1836 crate::state_trace::PpuStateRecord {
1837 // Frames easily exceed u16 over a 600-frame test run.
1838 // The `as u32` truncates the upper bits of the u64
1839 // counter — which is fine for any realistic capture
1840 // window (u32::MAX ≈ 71 days of NES wall time).
1841 frame: self.frame as u32,
1842 scanline: self.scanline,
1843 dot: self.dot,
1844 ctrl: self.ctrl.bits(),
1845 mask: self.mask.bits(),
1846 status: self.status.bits(),
1847 oam_addr: self.oam_addr,
1848 v: self.v,
1849 t: self.t,
1850 fine_x: self.x,
1851 w_toggle: self.w,
1852 sprite_eval_n: self.sprite_eval_n,
1853 sprite_eval_m: self.sprite_eval_m,
1854 sprite_eval_found: self.sprite_eval_found,
1855 sprite_eval_sec_idx: self.sprite_eval_sec_idx,
1856 sprite_eval_copying: self.sprite_eval_copying,
1857 sprite_eval_overflow_search: self.sprite_eval_overflow_search,
1858 sprite_eval_done: self.sprite_eval_done,
1859 sprite_eval_read_latch: self.sprite_eval_read_latch,
1860 spr_count: self.spr_count,
1861 spr_zero_in_line: self.spr_zero_in_line,
1862 spr_shift_lo: self.spr_shift_lo,
1863 spr_shift_hi: self.spr_shift_hi,
1864 spr_attr: self.spr_attr,
1865 spr_x: self.spr_x,
1866 bg_shift_lo: self.bg_shift_lo,
1867 bg_shift_hi: self.bg_shift_hi,
1868 at_shift_lo: self.at_shift_lo,
1869 at_shift_hi: self.at_shift_hi,
1870 nt_latch: self.nt_latch,
1871 at_latch: self.at_latch,
1872 bg_lo_latch: self.bg_lo_latch,
1873 bg_hi_latch: self.bg_hi_latch,
1874 secondary_oam: self.secondary_oam,
1875 oam_fnv1a64: crate::state_trace::fnv1a64(&self.oam),
1876 nmi_line: self.nmi_line,
1877 oam_bus_copybuffer: self.oam_data_bus_observed(),
1878 }
1879 }
1880
1881 /// Borrow the (possibly partial) framebuffer.
1882 #[must_use]
1883 pub fn framebuffer(&self) -> &[u8] {
1884 &self.framebuffer
1885 }
1886
1887 /// v1.7.0 "Forge" Workstream B (B3) — overwrite the RGBA8 output framebuffer
1888 /// (the Lua `emu:setScreenBuffer(t)` paints output only). Copies up to the
1889 /// framebuffer length; a short source leaves the tail untouched. Output-only
1890 /// — it touches only the display buffer the frontend presents, NOT any
1891 /// register / latch / scroll state, so the determinism contract is
1892 /// unaffected (a later real frame fully repaints it). `debug-hooks`-gated and
1893 /// reached only through the script crate's gated post-frame path, so the
1894 /// shipped build is byte-identical.
1895 #[cfg(feature = "debug-hooks")]
1896 pub fn debug_set_framebuffer(&mut self, rgba: &[u8]) {
1897 let n = rgba.len().min(self.framebuffer.len());
1898 self.framebuffer[..n].copy_from_slice(&rgba[..n]);
1899 }
1900
1901 /// Borrow the parallel per-pixel **palette-index** framebuffer
1902 /// (256 × 240 `u16`s, each `(emphasis << 6) | colour`, 0..=511) used by the
1903 /// true composite `NES_NTSC` filter (T-110-A1). A faithful index-space mirror
1904 /// of [`Self::framebuffer`]; output-only, so the determinism contract holds.
1905 #[must_use]
1906 pub fn index_framebuffer(&self) -> &[u16] {
1907 &self.index_framebuffer
1908 }
1909
1910 /// Pack a background tile's active palette into Mesen's `PaletteColors` key
1911 /// form (`pr[base+3] | pr[base+2]<<8 | pr[base+1]<<16 | pr[0]<<24`, with
1912 /// `base = $3F00 | group<<2` and `pr[0]` the universal backdrop). Used only
1913 /// to key HD-pack tile replacements; output-only.
1914 #[cfg(feature = "hd-pack")]
1915 fn hd_bg_palette_colors(&self, group: u8) -> u32 {
1916 let base = 0x3F00 | (u16::from(group) << 2);
1917 let p0 = u32::from(self.read_palette(0x3F00) & 0x3F);
1918 let p1 = u32::from(self.read_palette(base | 1) & 0x3F);
1919 let p2 = u32::from(self.read_palette(base | 2) & 0x3F);
1920 let p3 = u32::from(self.read_palette(base | 3) & 0x3F);
1921 p3 | (p2 << 8) | (p1 << 16) | (p0 << 24)
1922 }
1923
1924 /// Pack a sprite tile's palette (`0xFF000000 | pr[base+3] | pr[base+2]<<8 |
1925 /// pr[base+1]<<16`, `base = $3F10 | group<<2`; the `0xFF` top byte is the
1926 /// sprite/BG discriminator and there is no `pr[0]` term).
1927 #[cfg(feature = "hd-pack")]
1928 fn hd_sprite_palette_colors(&self, group: u8) -> u32 {
1929 let base = 0x3F10 | (u16::from(group) << 2);
1930 let p1 = u32::from(self.read_palette(base | 1) & 0x3F);
1931 let p2 = u32::from(self.read_palette(base | 2) & 0x3F);
1932 let p3 = u32::from(self.read_palette(base | 3) & 0x3F);
1933 0xFF00_0000 | p3 | (p2 << 8) | (p1 << 16)
1934 }
1935
1936 /// v1.2.0 beta.2 (Workstream C3) — borrow the per-pixel HD-pack
1937 /// tile-source buffer (256 × 240 [`HdTileSource`] records, parallel to
1938 /// [`Self::index_framebuffer`]). Each entry names the CHR tile that
1939 /// produced the pixel. Output-only telemetry; the determinism /
1940 /// `AccuracyCoin` contract is unaffected. See
1941 /// `docs/ppu-2c02.md` §HD-pack tile-source export.
1942 #[cfg(feature = "hd-pack")]
1943 #[must_use]
1944 pub fn hd_tile_source(&self) -> &[HdTileSource] {
1945 &self.hd_tile_source
1946 }
1947
1948 /// The per-frame NTSC composite colour phase — the `videoPhase` the
1949 /// `NES_NTSC` filter feeds its signal generator. `0..=2` on NTSC; on
1950 /// PAL/Dendy it is the frame parity (`0..=1`). Snapshotted at the last frame
1951 /// boundary. Cosmetic (drives only the optional filter's dot-crawl).
1952 #[must_use]
1953 pub const fn ntsc_phase(&self) -> u8 {
1954 self.frame_ntsc_phase
1955 }
1956
1957 /// Snapshot the per-frame NTSC colour phase from the master-cycle counter.
1958 /// NES NTSC steps the colour phase through 3 frame states (the source of the
1959 /// dot-crawl); PAL/Dendy have no equivalent 3-phase crawl, so the frame
1960 /// parity is exposed instead. Called at each frame boundary.
1961 const fn snapshot_ntsc_phase(&mut self) {
1962 self.frame_ntsc_phase = if matches!(self.region, PpuRegion::Ntsc) {
1963 (self.dot_counter % 3) as u8
1964 } else {
1965 (self.frame & 1) as u8
1966 };
1967 }
1968
1969 /// Current dot (0..=340).
1970 #[must_use]
1971 pub const fn dot(&self) -> u16 {
1972 self.dot
1973 }
1974
1975 /// Current scanline.
1976 #[must_use]
1977 pub const fn scanline(&self) -> i16 {
1978 self.scanline
1979 }
1980
1981 /// Current frame counter.
1982 #[must_use]
1983 pub const fn frame(&self) -> u64 {
1984 self.frame
1985 }
1986
1987 /// Snapshot of CPU-visible register bytes (for the debugger UI).
1988 ///
1989 /// Returns `[ctrl, mask, status, oam_addr]`. Read-only — does NOT clear
1990 /// VBL or toggle the write latch (unlike `cpu_read_register`).
1991 #[must_use]
1992 pub const fn debug_registers(&self) -> [u8; 4] {
1993 [
1994 self.ctrl.bits(),
1995 self.mask.bits(),
1996 self.status.bits(),
1997 self.oam_addr,
1998 ]
1999 }
2000
2001 /// Snapshot of loopy scroll registers `(v, t, x, w)`.
2002 #[must_use]
2003 pub const fn debug_scroll(&self) -> (u16, u16, u8, bool) {
2004 (self.v, self.t, self.x, self.w)
2005 }
2006
2007 /// v1.8.9 — the frame's background scroll `(x, y)` in NES pixels, decoded
2008 /// from the `t` (temp VRAM addr) register + fine-X, including the nametable
2009 /// bits (Mesen HD-pack `_scrollX`/`scrollY`). Used by the HD compositor to
2010 /// offset parallax `<background>` layers by `scroll * ratio`. A frame-level
2011 /// value (the scroll at `t`), not per-scanline. Output-only.
2012 #[must_use]
2013 pub const fn hd_bg_scroll(&self) -> (i32, i32) {
2014 let t = self.t;
2015 let x = ((t & 0x1F) << 3) | (self.x as u16) | if t & 0x0400 != 0 { 0x100 } else { 0 };
2016 let y =
2017 (((t & 0x03E0) >> 2) | ((t & 0x7000) >> 12)) + if t & 0x0800 != 0 { 240 } else { 0 };
2018 (x as i32, y as i32)
2019 }
2020
2021 /// Borrow the 32-byte palette RAM (read-only).
2022 #[must_use]
2023 pub const fn palette_ram(&self) -> &[u8; 32] {
2024 &self.palette_ram
2025 }
2026
2027 /// Borrow OAM (256 bytes = 64 sprites x 4 bytes).
2028 #[must_use]
2029 pub fn oam(&self) -> &[u8] {
2030 &self.oam
2031 }
2032
2033 /// Borrow nametable CIRAM (2 KiB).
2034 #[must_use]
2035 pub fn ciram(&self) -> &[u8] {
2036 &self.ciram
2037 }
2038
2039 /// v2.3.2 "Lucid" — arm or disarm per-byte write attribution.
2040 ///
2041 /// Arming allocates [`crate::provenance::WriteAttribution::HEAP_BYTES`] and
2042 /// starts stamping every subsequent CIRAM / OAM / palette write with the
2043 /// writing instruction's PC and cycle. Disarming frees the store outright, so
2044 /// re-arming starts from a clean slate rather than resurrecting stale records
2045 /// from a previous debugging session.
2046 ///
2047 /// Purely observational — nothing in the render or timing path reads it, so
2048 /// output is bit-identical either way.
2049 #[cfg(feature = "debug-hooks")]
2050 pub fn set_write_attribution(&mut self, enabled: bool) {
2051 self.write_attrib = if enabled {
2052 Some(Box::new(crate::provenance::WriteAttribution::new()))
2053 } else {
2054 None
2055 };
2056 }
2057
2058 /// The write-attribution store, or `None` when not armed.
2059 #[cfg(feature = "debug-hooks")]
2060 #[must_use]
2061 pub fn write_attribution(&self) -> Option<&crate::provenance::WriteAttribution> {
2062 self.write_attrib.as_deref()
2063 }
2064
2065 /// Forget every recorded attribution, keeping the store armed.
2066 ///
2067 /// The core calls this on power-cycle and on save-state restore: the restored
2068 /// bytes were not written by any instruction this session ran, and reporting
2069 /// the PCs that happened to write those offsets *before* the restore would be
2070 /// a confidently wrong answer rather than an absent one.
2071 #[cfg(feature = "debug-hooks")]
2072 pub fn clear_write_attribution(&mut self) {
2073 if let Some(attrib) = self.write_attrib.as_mut() {
2074 attrib.clear();
2075 }
2076 }
2077
2078 /// Push down the `(pc, cycle)` of the CPU instruction whose write is about to
2079 /// land, so the store site can stamp it. Called by the bus immediately before
2080 /// a `$2000-$3FFF` register write and before an OAM DMA burst.
2081 ///
2082 /// A no-op when attribution is not armed, and never read by emulation.
2083 #[cfg(feature = "debug-hooks")]
2084 pub const fn set_attrib_context(&mut self, pc: u16, cycle: u64) {
2085 self.attrib_pc = pc;
2086 self.attrib_cycle = cycle;
2087 }
2088
2089 /// v2.3.2 "Lucid" phase 2 — arm or disarm per-pixel provenance capture.
2090 ///
2091 /// Arming allocates
2092 /// [`crate::provenance::PixelProvenanceFrame::HEAP_BYTES`] and starts
2093 /// recording, for every emitted pixel, the layer that won, the exact palette
2094 /// address, and the nametable / attribute / pattern addresses of the tile
2095 /// actually on screen. Disarming frees the frame.
2096 ///
2097 /// Independent of [`Self::set_write_attribution`]: this says *which bytes*
2098 /// produced a pixel, that says *who wrote* those bytes. The panel wants
2099 /// both, but each is useful alone and neither depends on the other.
2100 ///
2101 /// Output-only, so emulation is bit-identical either way.
2102 #[cfg(feature = "debug-hooks")]
2103 pub fn set_pixel_provenance(&mut self, enabled: bool) {
2104 self.prov_frame = if enabled {
2105 Some(Box::new(crate::provenance::PixelProvenanceFrame::new()))
2106 } else {
2107 None
2108 };
2109 self.prov_armed = enabled;
2110 }
2111
2112 /// The current frame's per-pixel provenance, or `None` when not armed.
2113 #[cfg(feature = "debug-hooks")]
2114 #[must_use]
2115 pub fn pixel_provenance(&self) -> Option<&crate::provenance::PixelProvenanceFrame> {
2116 self.prov_frame.as_deref()
2117 }
2118
2119 /// Forget every recorded pixel, keeping the frame armed.
2120 ///
2121 /// Mirrors [`Self::clear_write_attribution`], and for the same reason: a
2122 /// restore lands mid-frame, so without this the panel would report tile and
2123 /// palette addresses from the abandoned timeline for every pixel above the
2124 /// current scanline, with nothing marking them stale.
2125 #[cfg(feature = "debug-hooks")]
2126 pub fn clear_pixel_provenance(&mut self) {
2127 if let Some(frame) = self.prov_frame.as_mut() {
2128 frame.clear();
2129 }
2130 }
2131
2132 /// Move both provenance stores out, leaving the PPU unarmed.
2133 ///
2134 /// Paired with [`Self::put_provenance`] to carry the stores across a
2135 /// same-timeline restore that would otherwise clear them — see
2136 /// [`crate::provenance::ProvenanceStash`] for why run-ahead needs that and
2137 /// save-state loads and netplay rollback do not.
2138 ///
2139 /// `prov_armed` is dropped to `false` alongside the frame it mirrors, so the
2140 /// invariant "`prov_armed` iff `prov_frame.is_some()`" holds while stashed
2141 /// and `emit_pixel` records nothing into the vacated slot.
2142 #[cfg(feature = "debug-hooks")]
2143 pub const fn take_provenance(&mut self) -> crate::provenance::ProvenanceStash {
2144 let stash = crate::provenance::ProvenanceStash {
2145 write_attrib: self.write_attrib.take(),
2146 prov_frame: self.prov_frame.take(),
2147 prov_armed: self.prov_armed,
2148 };
2149 self.prov_armed = false;
2150 stash
2151 }
2152
2153 /// Put back stores taken by [`Self::take_provenance`].
2154 ///
2155 /// Overwrites whatever is currently held, which is what the pairing wants:
2156 /// the only thing that can have appeared in between is a restore's cleared
2157 /// (or absent) store, and the stashed records are the ones the caller means
2158 /// to keep.
2159 #[cfg(feature = "debug-hooks")]
2160 pub fn put_provenance(&mut self, stash: crate::provenance::ProvenanceStash) {
2161 self.write_attrib = stash.write_attrib;
2162 self.prov_frame = stash.prov_frame;
2163 self.prov_armed = stash.prov_armed;
2164 }
2165
2166 /// Freeze the current instruction context as the cause of an OAM DMA burst.
2167 ///
2168 /// Called by the bus from the `$4014` write, i.e. while
2169 /// [`Self::set_attrib_context`] still holds the `STA $4014` itself.
2170 ///
2171 /// The burst cannot use the live context: `$4014` only arms the transfer, and
2172 /// its 513 or 514 cycles are then stolen from the instructions that follow,
2173 /// so by the time the first OAM byte lands the live context names whichever
2174 /// instruction is being halted — true about the timing, wrong about the cause.
2175 #[cfg(feature = "debug-hooks")]
2176 pub const fn latch_dma_attrib_context(&mut self) {
2177 self.dma_attrib_pc = self.attrib_pc;
2178 self.dma_attrib_cycle = self.attrib_cycle;
2179 }
2180
2181 /// v1.7.0 "Forge" Workstream A1 — debugger writeback: store one palette-RAM
2182 /// byte directly (`idx` masked to 0..32, value masked to the 6-bit palette
2183 /// width), reusing the same canonical mirroring/masking as the live
2184 /// `$2007` write path. Used only by the `debug-hooks` editor writeback,
2185 /// which routes through the gated post-frame poke path — so the default
2186 /// (no-edit) build never calls it and stays byte-identical.
2187 #[cfg(feature = "debug-hooks")]
2188 pub const fn debug_poke_palette(&mut self, idx: u8, value: u8) {
2189 // `palette_index` mirrors $3F10/$14/$18/$1C → $3F00/.. and folds the
2190 // 32-byte window; feed it the raw address so an editor index maps the
2191 // same way a $2007 write would.
2192 let addr = 0x3F00u16 | ((idx & 0x1F) as u16);
2193 let i = palette_index(addr);
2194 self.palette_ram[i] = value & 0x3F;
2195 }
2196
2197 /// v1.7.0 "Forge" Workstream A1 — debugger writeback: store one OAM byte
2198 /// directly. `debug-hooks`-gated; only reached through the gated post-frame
2199 /// poke path, so the default build is byte-identical.
2200 #[cfg(feature = "debug-hooks")]
2201 pub const fn debug_poke_oam(&mut self, idx: u8, value: u8) {
2202 self.oam[idx as usize] = value;
2203 }
2204
2205 /// v1.7.0 "Forge" Workstream A1 — debugger writeback: store one CIRAM byte
2206 /// at a physical offset (caller resolves mirroring via the mapper).
2207 /// `debug-hooks`-gated; only reached through the gated post-frame poke path.
2208 #[cfg(feature = "debug-hooks")]
2209 pub const fn debug_poke_ciram(&mut self, phys: usize, value: u8) {
2210 self.ciram[phys & 0x07FF] = value;
2211 }
2212
2213 /// `true` when sprites are rendered in 8x16 mode (CTRL bit 5).
2214 #[must_use]
2215 pub const fn sprite_size_16(&self) -> bool {
2216 self.ctrl
2217 .contains(crate::registers::PpuCtrl::SPRITE_SIZE_16)
2218 }
2219
2220 /// Base address of the BG pattern table (`$0000` or `$1000`).
2221 #[must_use]
2222 pub const fn bg_pattern_base(&self) -> u16 {
2223 if self
2224 .ctrl
2225 .contains(crate::registers::PpuCtrl::BG_PATTERN_HIGH)
2226 {
2227 0x1000
2228 } else {
2229 0x0000
2230 }
2231 }
2232
2233 /// Base address of the sprite pattern table (8x8 mode only).
2234 #[must_use]
2235 pub const fn sprite_pattern_base(&self) -> u16 {
2236 if self
2237 .ctrl
2238 .contains(crate::registers::PpuCtrl::SPRITE_PATTERN_HIGH)
2239 {
2240 0x1000
2241 } else {
2242 0x0000
2243 }
2244 }
2245
2246 /// OAM DMA byte write: place `value` at `oam[oam_addr]` and increment
2247 /// `oam_addr`. Used by the bus's OAM DMA state machine.
2248 ///
2249 /// Bypasses the OAMADDR-during-rendering corruption modeled by
2250 /// `cpu_write_register` for `$2004` direct writes — DMA writes always
2251 /// hit OAM directly per nesdev.
2252 pub fn oam_dma_write(&mut self, value: u8) {
2253 self.oam[self.oam_addr as usize] = value;
2254 // v2.3.2 "Lucid" — every byte of the burst is attributed to the ONE
2255 // `STA $4014` that triggered it (the LATCHED context, not the live one:
2256 // the burst steals cycles from the instructions AFTER the trigger, so
2257 // the live context names the halted instruction rather than the cause).
2258 // 256 bytes genuinely share one cause, and reporting anything else would
2259 // invent a history the program does not have.
2260 #[cfg(feature = "debug-hooks")]
2261 if let Some(attrib) = self.write_attrib.as_mut() {
2262 attrib.record_oam(
2263 self.oam_addr,
2264 self.dma_attrib_pc,
2265 self.dma_attrib_cycle,
2266 value,
2267 );
2268 }
2269 // v2.1.4 F2.3 — OAM-decay write hook (no-op at the default): the DMA byte
2270 // recharges the written row's DRAM cells, so refresh its timestamp. Mesen2
2271 // routes DMA writes through the same `WriteSpriteRam` refresh.
2272 self.oam_decay_on_write(self.oam_addr);
2273 self.oam_addr = self.oam_addr.wrapping_add(1);
2274 }
2275
2276 /// Notify the PPU that one CPU cycle has elapsed. Used to drive the
2277 /// post-reset masking window and the open-bus decay timers.
2278 pub const fn on_cpu_cycle(&mut self) {
2279 self.post_reset_mask_remaining = self.post_reset_mask_remaining.saturating_sub(1);
2280 // Open-bus decay: per-bit-group, three independent timers. When a
2281 // group's timer hits 0 those bits clear in the latch. Per
2282 // docs/ppu-2c02.md, real hardware decays in 3-30 ms; we use
2283 // **558.7 ms** — one million CPU cycles at NTSC.
2284 //
2285 // The figure in this comment used to read "~600 ms (≈ 1,073,447 CPU
2286 // cycles at NTSC, rounded to one million)". The arithmetic in the
2287 // parenthesis is right and the headline is not: one million cycles is
2288 // 558.7 ms, so "rounded" was a 7% cut, not a rounding. Corrected here
2289 // because the MiSTer co-simulation DUT has to reproduce this number
2290 // exactly and was quoting the wrong one back.
2291 //
2292 // SWEPT (v2.6.3), because "conservative but well within the window the
2293 // `ppu_open_bus` test cares about" turned out to understate how much
2294 // slack there is. That ROM's decay checks are 100 × `delay_msec 10`
2295 // loops asserting the value has reached zero, so its only real
2296 // constraint is < 1000 ms — and it passes at **3, 10, 20, 30 and
2297 // 100 ms** as well. AccuracyCoin holds 141/141 (RAM decoder), nestest
2298 // matches its golden log, and all eight `nes_blargg` tests pass at
2299 // 30 ms, the documented upper bound.
2300 //
2301 // So this constant is NOT forced by the corpus: a documentation-derived
2302 // value is measurably available. It is kept at one million because
2303 // changing it changes shipped emulator behaviour for every game that
2304 // reads open bus after a long gap, and no test in this repository can
2305 // adjudicate which is right — the wiki's band and this model differ by
2306 // ~19×, and neither has an independent oracle here. Recorded as a
2307 // deliberate hold rather than a derivation. See
2308 // `RustyNES_MiSTer/docs/rung3-ppu.md`, where the same constant is
2309 // reproduced as 3,000,000 dots and the DUT-side sweep is written up.
2310 // NOTE (v2.3.1 G5): reformulating this as a deadline comparison instead
2311 // of a per-cycle decrement was measured by DELETING the loop outright —
2312 // the ceiling any reformulation could reach — and the ceiling is ZERO.
2313 // ~29,780 calls/frame sounds expensive; it is three predictable
2314 // compare-and-decrement steps on data already in L1, which an
2315 // out-of-order core absorbs entirely. Do not re-attempt; see
2316 // `docs/performance.md`.
2317 let mut i = 0;
2318 while i < 3 {
2319 if self.open_bus_decay[i] > 0 {
2320 self.open_bus_decay[i] -= 1;
2321 if self.open_bus_decay[i] == 0 {
2322 self.open_bus &= !Self::OPEN_BUS_GROUP_MASKS[i];
2323 }
2324 }
2325 i += 1;
2326 }
2327 }
2328
2329 /// Per-bit-group masks for the open-bus latch decay model. Group 0 is
2330 /// bits 0-4 (refreshed by writes, $2004 reads, and $2007 reads — both
2331 /// palette and non-palette). Group 1 is bit 5 (refreshed by writes,
2332 /// $2002 reads, $2004 reads, and $2007 reads). Group 2 is bits 6-7
2333 /// (refreshed by writes, $2002 reads, $2004 reads, and $2007 non-palette
2334 /// reads — but not by palette reads).
2335 const OPEN_BUS_GROUP_MASKS: [u8; 3] = [0x1F, 0x20, 0xC0];
2336
2337 /// Decay-timer reload value (~600 ms at NTSC).
2338 const OPEN_BUS_DECAY_RELOAD: u32 = 1_000_000;
2339
2340 /// Refresh the open-bus latch. `group_mask` is a bitmap selecting which
2341 /// of the three decay groups to refresh: bit 0 = bits 0-4, bit 1 = bit 5,
2342 /// bit 2 = bits 6-7. Only the bits in those groups are copied from
2343 /// `value`; bits in groups not selected retain their previous latch value
2344 /// and their decay timer is left to keep counting down.
2345 const fn refresh_open_bus(&mut self, value: u8, group_mask: u8) {
2346 let mut i = 0;
2347 while i < 3 {
2348 if (group_mask >> i) & 1 == 1 {
2349 let m = Self::OPEN_BUS_GROUP_MASKS[i];
2350 self.open_bus = (self.open_bus & !m) | (value & m);
2351 self.open_bus_decay[i] = Self::OPEN_BUS_DECAY_RELOAD;
2352 }
2353 i += 1;
2354 }
2355 }
2356
2357 /// Refresh **all** bit groups of the open-bus latch — used by writes and
2358 /// any read that drives all 8 bits (e.g. $2004 OAMDATA, $2007 non-palette
2359 /// PPUDATA).
2360 const fn touch_open_bus(&mut self, value: u8) {
2361 self.refresh_open_bus(value, 0b111);
2362 }
2363
2364 /// Number of PPU dots between a `$2002` read's start (M2 high, when the
2365 /// VBL flag is latched) and its end (M2 low, when the *unlatched*
2366 /// sprite-0-hit / overflow flags are sampled). On a revision-G 2A03 M2's
2367 /// 15/24 duty cycle puts read-end ~1.875 PPU dots after read-start; we
2368 /// round to the nearest whole dot for the lockstep computed sample. This
2369 /// is the empirically-tuned knob for the `$2002 flag timing` test.
2370 ///
2371 /// Tuned to **1**: with the test's reads spaced 1 PPU dot apart, a 1-dot
2372 /// window yields the primary answer key `$E0,$E0,$80,$00` (read 3 = `$80`:
2373 /// VBL latched set, sprite flags read 0). A 2-dot window also passes Test 1
2374 /// (via the alt key `$E0,$80,$80,$00`) but masks one extra read position,
2375 /// which regresses `$2004 Stress` (whose `$2002` sync read lands there).
2376 const STATUS_READ_END_DOTS: u16 = 1;
2377
2378 /// True when advancing `dots` PPU dots from the current `(scanline, dot)`
2379 /// reaches or passes the pre-render dot-1 flag-clear, where the
2380 /// sprite-0-hit and overflow flags are cleared. Used by the `$2002`
2381 /// two-point read model to sample bits 6/5 as-of read-end. Forward
2382 /// distance only: a position already at/after this frame's clear is *not*
2383 /// "imminent" (its flags are already cleared in the status register, and
2384 /// the full-frame wrap distance never satisfies the small `dots` bound).
2385 fn sprite_flags_clear_imminent(&self, dots: u16) -> bool {
2386 const DOTS_PER_LINE: i32 = 341;
2387 let lines = i32::from(self.region.prerender_line()) + 1;
2388 let frame_dots = lines * DOTS_PER_LINE;
2389 let target = i32::from(self.region.prerender_line()) * DOTS_PER_LINE + 1;
2390 let cur = i32::from(self.scanline) * DOTS_PER_LINE + i32::from(self.dot);
2391 let until = (target - cur).rem_euclid(frame_dots);
2392 until > 0 && until <= i32::from(dots)
2393 }
2394
2395 /// CPU register read at `$2000-$3FFF` (only the low 3 bits matter).
2396 #[allow(clippy::too_many_lines)]
2397 pub fn cpu_read_register<B: PpuBus>(&mut self, reg: u8, bus: &mut B) -> u8 {
2398 // 2C05 swaps $2000<->$2001 (no-op on every other PPU).
2399 match self.map_register(reg) {
2400 // $2000 / $2001 / $2003 / $2005 / $2006 are write-only; reads
2401 // return open-bus.
2402 0 | 1 | 3 | 5 | 6 => self.open_bus,
2403 2 => {
2404 // $2002 PPUSTATUS. High 3 bits are real; low 5 are open-bus.
2405 // `v` is the value as-of read-start (M2 high); it both feeds the
2406 // open-bus I/O-latch refresh below and is the base for the
2407 // CPU-visible return value. The Tier-1.1 read-end sample (below)
2408 // is applied ONLY to the returned byte, NOT to the open-bus
2409 // refresh — keeping the decay model byte-identical so real games
2410 // that read open bus after a `$2002` read are unaffected.
2411 // On a 2C05 the low 5 bits return the PPU identifier (copy
2412 // protection) instead of PPU open bus; on every other PPU they
2413 // are the open-bus latch (byte-identical to the legacy path).
2414 let low5 = if self.is_2c05 {
2415 self.id_2c05 & 0x1F
2416 } else {
2417 self.open_bus & 0x1F
2418 };
2419 let v = (self.status.bits() & 0xE0) | low5;
2420 // Clear VBL and the w toggle as a side effect.
2421 self.status.remove(PpuStatus::VBLANK);
2422 self.w = false;
2423 // R2 (master-clock R1 substrate): a $2002 read drops the /NMI
2424 // line UNCONDITIONALLY (Mesen2 `UpdateStatusFlag:588`
2425 // `ClearNmiFlag()`; TetaNES `read_status: nmi_pending=false`).
2426 // Correct under R1's on-time access (the read lands at the
2427 // access's exact dot); the CPU's φ2 edge detector sees the
2428 // level fall on the same access.
2429 {
2430 self.nmi_line = false;
2431 }
2432 // Race: reading PPUSTATUS at exactly the cycle VBL would
2433 // have been set suppresses VBL + NMI for that frame. We
2434 // approximate the race window as scanline 241 dot 0 (the
2435 // dot before set) and dot 1 (the set dot).
2436 //
2437 // Session-18 / C1 attempt 16 (PPU-axis) investigated
2438 // tightening the predicate from `dot <= 1` to `dot == 0`
2439 // (matching Mesen2's `Core/NES/NesPpu.cpp::
2440 // UpdateStatusFlag()` `_cycle == 0` strict 1-dot window
2441 // and the nesdev wiki spec) but ROLLED IT BACK because
2442 // it did NOT flip the failing `cpu_interrupts_v2/{2,3,5}`
2443 // tests. The empirical oracle in
2444 // `ppu::tests::vbl_race_window_2002_read_sweep` (added
2445 // in Session-18) shows the predicate change cleanly
2446 // narrows the window AT the unit-test layer, but the
2447 // load-bearing axis at the integration-test layer is the
2448 // CPU-vs-PPU per-cycle access interleaving — a deeper
2449 // architectural surface that the Session-13 cold-boot
2450 // alignment closed at the FRAME-anchor level but NOT at
2451 // the INTRA-CYCLE phase level. See
2452 // `docs/audit/session-18-c1-attempt16-ppu-axis-rollback-2026-05-22.md`
2453 // and ADR-0002 §"Decision update (2026-05-22, Session-18)".
2454 // C1 attempt 18 (coordinated with the CPU-side φ1/φ2
2455 // split): when the access-reorder feature is enabled,
2456 // narrow the suppression window from `dot <= 1` to
2457 // `dot == 0` per Mesen2 line 590 + nesdev wiki spec.
2458 // The CPU-side shift puts our BIT $2002 reads at dot 1
2459 // (post-φ1-tick) instead of dot 0, and the dot-1 read
2460 // should NOT trigger suppression (it sees the
2461 // just-set VBL). Both changes together close the
2462 // `cpu_interrupts_v2/{2,3,5}` sync_vbl divergence
2463 // documented in Session-17/18 audits.
2464 // R2 (mc-r1-substrate) narrows the race window to `dot == 0`
2465 // (Mesen2 `:590`) — on the on-time substrate a dot-1 read is a
2466 // normal post-set read; only dot-0 (one PPU clock before VBL
2467 // set) arms suppression.
2468 let in_race_window =
2469 self.scanline == self.region.vblank_start_line() && self.dot == 0;
2470 if in_race_window {
2471 self.suppress_vbl_this_frame = true;
2472 // If NMI was already raised on dot 1 this same cycle,
2473 // pull it back down too.
2474 self.nmi_line = false;
2475 }
2476 // Reading PPUSTATUS only refreshes the upper 3 bits of the
2477 // open-bus latch (the bits sourced from the status register);
2478 // the lower 5 bits retain both their previous value AND their
2479 // decay timer. See nesdev wiki "PPU registers" §"Open bus",
2480 // `cpu_dummy_writes_ppumem` test ROM (open_bus_read_test 2),
2481 // and `ppu_open_bus.nes` test 7
2482 // ("Reading $2002 shouldn't refresh low 5 bits of decay value").
2483 // Refresh groups 1 (bit 5) and 2 (bits 6-7) only.
2484 // Uses the read-start value `v` (the Tier-1.1 read-end mask is
2485 // applied to the return value only, below).
2486 self.refresh_open_bus(v, 0b110);
2487 // v2.0 Tier 1.1 — $2002 two-point intra-read flag sampling.
2488 // VBL (bit 7) is latched at read-start (M2 high) = the current
2489 // dot, already captured in `v`. The sprite-0 (bit 6) and
2490 // overflow (bit 5) flags are NOT latched; the CPU samples them
2491 // at read-end (M2 low), ~1.875 PPU dots later. A read straddling
2492 // the pre-render dot-1 flag-clear therefore returns VBL still set
2493 // while the sprite flags already read 0 — the AccuracyCoin
2494 // `$2002 flag timing` answer key `$E0,$E0,$80,$00`. Mask bits 6/5
2495 // on the returned byte when read-end lands at/after the clear
2496 // (TriCNES `EmulateUntilEndOfRead`). Returned-value-only so the
2497 // open-bus latch stays byte-identical for real games.
2498 if self.sprite_flags_clear_imminent(Self::STATUS_READ_END_DOTS) {
2499 return v & !0x60;
2500 }
2501 v
2502 }
2503 4 => {
2504 // $2004 OAMDATA. Returns OAM[OAMADDR] without auto-increment.
2505 // Sprite attribute bytes (every 4th byte starting at offset 2)
2506 // have bits 2-4 unimplemented in OAM and always read as 0,
2507 // even though writes can store them. See nesdev wiki "PPU
2508 // OAM" → "Byte 2 (attributes)".
2509 //
2510 // v2.0 Tier 1.2: while the screen is being drawn on a visible
2511 // scanline, $2004 returns the value the PPU is currently using
2512 // for sprite evaluation / loading (the OAM data bus), NOT
2513 // OAM[OAMADDR]. The isolated `ppu-oam-data-bus` model
2514 // (`oam_data_bus_read`) reproduces this per AccuracyCoin
2515 // `$2004 Stress`; see Mesen2 `NesPpu.cpp:298-313/361-380`.
2516 {
2517 if self.oam_data_bus_is_live() {
2518 let v = self.oam_data_bus_read();
2519 self.touch_open_bus(v);
2520 return v;
2521 }
2522 }
2523 // v2.1.4 F2.3 — OAM-decay read hook (no-op at the default): a
2524 // non-rendering `$2004` read refreshes the row, or returns the
2525 // decayed pattern if it has gone stale. Must run before the read.
2526 self.oam_decay_on_read(self.oam_addr);
2527 let mut v = self.oam[self.oam_addr as usize];
2528 if (self.oam_addr & 0x03) == 0x02 {
2529 v &= 0xE3;
2530 }
2531 // Per nesdev wiki "PPU registers" §$2004 + AccuracyCoin
2532 // "Address $2004 behavior" sub-tests 4 + 9: during dots
2533 // 1-64 of every rendered scanline (the secondary-OAM
2534 // clear phase) AND during dots 257-320 (the sprite-tile-
2535 // loading interval — also when the secondary-OAM bytes
2536 // are being read out to the shift registers), $2004
2537 // reads return $FF.
2538 //
2539 // (When `ppu-oam-data-bus` is on, the rendering case returns
2540 // above; this fallback covers the flag-off build + the
2541 // non-rendering paths.)
2542 if self.is_render_scanline()
2543 && self.mask.rendering_enabled()
2544 && ((1..=64).contains(&self.dot) || (257..=320).contains(&self.dot))
2545 {
2546 v = 0xFF;
2547 }
2548 self.touch_open_bus(v);
2549 v
2550 }
2551 7 => {
2552 // Diagnostic: log where each $2007 read lands (scanline/dot/mask).
2553 if (4400..6200).contains(&self.frame) {
2554 use core::sync::atomic::Ordering::Relaxed;
2555 let i = read2007_diag::IDX.fetch_add(1, Relaxed) as usize;
2556 if i < 1024 {
2557 #[allow(clippy::cast_sign_loss)]
2558 let sl = ((i32::from(self.scanline) + 1) as u32) & 0x1FF;
2559 let packed = (sl << 18)
2560 | ((u32::from(self.dot) & 0x1FFF) << 5)
2561 | (u32::from(self.mask.rendering_enabled()) << 1)
2562 | u32::from(self.is_render_scanline());
2563 read2007_diag::LOG[i].store(packed, Relaxed);
2564 }
2565 }
2566 // $2007 PPUDATA. Buffered for $0000-$3EFF; palette reads
2567 // bypass the buffer but still update it with the underlying
2568 // nametable mirror.
2569 let addr = self.v & 0x3FFF;
2570 let is_palette = addr >= 0x3F00;
2571 // W2: set when THIS read arms the PPUDATA SM countdown with the
2572 // defer-v-inc sub-knob on — the v-glitch increment then happens
2573 // at the TStep (the countdown landing dot), not here.
2574 let mut defer_v_inc = false;
2575 let result = if is_palette {
2576 // Palette read: high 2 bits = open bus.
2577 let palette = self.read_palette(addr);
2578 let v_with_open_bus = (palette & 0x3F) | (self.open_bus & 0xC0);
2579 // Buffer gets the underlying nametable byte (from CIRAM).
2580 self.data_buffer = self.read_vram(bus, addr & 0x2FFF);
2581 v_with_open_bus
2582 } else {
2583 let r = self.data_buffer;
2584 // During rendering the buffer is NOT loaded from a read at
2585 // `v`. TriCNES (`Emulator.cs` `PPU_DATA_StateMachine` +
2586 // the `$2007` CPU read): the read-END arms a latch cascade
2587 // and the actual `PPU_ReadBuffer` reload happens ~4 dots
2588 // later, latching the value the BG/sprite FETCH cadence
2589 // drove on the VRAM bus at the LANDING dot (the fetch has
2590 // bus priority). Modeled as a PPU-dot countdown consumed
2591 // in `Ppu::tick`; the returned value stays the OLD buffer
2592 // (the priming-read contract). Delay 0 = immediate latch
2593 // of the current bus value.
2594 if self.mask.rendering_enabled() && self.is_render_scanline() {
2595 octal_trace::push(
2596 octal_trace::K_R2007,
2597 self.frame,
2598 self.scanline,
2599 self.dot,
2600 u32::from(self.v & 0x3FFF),
2601 );
2602 let n = read2007_diag::RENDER_BUFFER_DOT_DELAY
2603 .load(core::sync::atomic::Ordering::Relaxed)
2604 as u8;
2605 if n == 0 {
2606 self.data_buffer = self.render_data_bus;
2607 } else {
2608 self.ppudata_sm_countdown = n;
2609 // TriCNES `PPU_DATA_StateMachine_Half`: the TStep
2610 // (v-glitch increment) fires at the SAME dot as the
2611 // PD_RB buffer reload, AFTER it — so the fetches in
2612 // the read-to-reload window still use the OLD `v`.
2613 if read2007_diag::RENDER_BUFFER_DEFER_V_INC
2614 .load(core::sync::atomic::Ordering::Relaxed)
2615 != 0
2616 {
2617 self.ppudata_v_inc_pending = true;
2618 defer_v_inc = true;
2619 }
2620 }
2621 } else {
2622 self.data_buffer = self.read_vram(bus, addr);
2623 }
2624 r
2625 };
2626 // Per nesdev "PPU rendering"
2627 // (https://www.nesdev.org/wiki/PPU_scrolling#$2007_reads_and_writes_during_rendering):
2628 // "Reading or writing PPUDATA during rendering (on the
2629 // pre-render line and the visible lines 0-239, only when
2630 // rendering is enabled) does not increment the address
2631 // normally, but instead increments both coarse X scroll
2632 // and Y scroll simultaneously, with normal wrapping."
2633 // This is the canonical "$2007 read w/ rendering" quirk
2634 // that AccuracyCoin's `PPU Behavior :: $2007 read w/
2635 // rendering` Test 2 brackets.
2636 //
2637 // W2 (`mc-ppu-2007-render-buffer` + defer-v-inc sub-knob): when
2638 // this read armed the PPUDATA SM countdown, the increment is
2639 // performed at the TStep (the countdown landing dot in
2640 // `Ppu::tick`) instead of here.
2641 let apply_inc_now = !defer_v_inc;
2642 if apply_inc_now {
2643 if self.mask.rendering_enabled() && self.is_render_scanline() {
2644 self.inc_hori_v();
2645 self.inc_vert_v();
2646 } else {
2647 let inc = if self.ctrl.contains(PpuCtrl::VRAM_INCREMENT_32) {
2648 32
2649 } else {
2650 1
2651 };
2652 self.v = self.v.wrapping_add(inc) & 0x7FFF;
2653 }
2654 }
2655 // A12 transition can occur here.
2656 self.observe_a12(bus);
2657 if is_palette {
2658 // Palette reads only refresh bits 0-5 of the decay model
2659 // (palette is 6-bit); bits 6-7 retain their previous
2660 // value AND timer. Required by `ppu_open_bus.nes` test 9.
2661 self.refresh_open_bus(result, 0b011);
2662 } else {
2663 self.touch_open_bus(result);
2664 }
2665 result
2666 }
2667 _ => unreachable!(),
2668 }
2669 }
2670
2671 /// CPU register write.
2672 // Large by nature: an 8-way `$2000-$2007` register-dispatch match, each arm
2673 // carrying its own hardware-quirk handling (the v2.0.2 octal-latch `$2006`
2674 // hook nudged it past the 100-line lint threshold).
2675 #[allow(clippy::too_many_lines)]
2676 pub fn cpu_write_register<B: PpuBus>(&mut self, reg: u8, value: u8, bus: &mut B) {
2677 // Open-bus latch always picks up the written value.
2678 self.touch_open_bus(value);
2679 // 2C05 swaps $2000<->$2001 (no-op on every other PPU).
2680 match self.map_register(reg) {
2681 0 => {
2682 // $2000 PPUCTRL.
2683 if self.post_reset_mask_remaining > 0 {
2684 return;
2685 }
2686 let prev_nmi_enable = self.ctrl.contains(PpuCtrl::NMI_ENABLE);
2687 self.ctrl = PpuCtrl::from_bits_truncate(value);
2688 // t bits 11-10 = nametable bits 1-0.
2689 self.t = (self.t & 0xF3FF) | ((u16::from(value) & 0x03) << 10);
2690 // NMI bit 0->1 transition while VBL set asserts NMI immediately.
2691 let new_nmi_enable = self.ctrl.contains(PpuCtrl::NMI_ENABLE);
2692 if !prev_nmi_enable && new_nmi_enable && self.status.contains(PpuStatus::VBLANK) {
2693 self.nmi_line = true;
2694 }
2695 if !new_nmi_enable {
2696 // Disabling NMI lowers the line.
2697 self.nmi_line = false;
2698 }
2699 }
2700 1 => {
2701 // $2001 PPUMASK.
2702 if self.post_reset_mask_remaining > 0 {
2703 return;
2704 }
2705 let was_rendering = self.mask.rendering_enabled();
2706 self.mask = PpuMask::from_bits_truncate(value);
2707 self.arm_oam_corruption_disable(was_rendering);
2708 // v2.0 Phase 6 (mc-ppu-subpos): arm the analog `$2001` BG-reload
2709 // delay. `self.mask` (and so the sprite-eval / shift / pixel
2710 // path) updates IMMEDIATELY — only the BG shift-register RELOAD
2711 // is gated on a value delayed `MASK_WRITE_DELAY` dots behind the
2712 // mask (TriCNES gates `PPU_Render_ShiftRegistersAndBitPlanes` ->
2713 // the reload on `PPU_Mask_Show*_Delayed`, while the per-half-dot
2714 // SHIFT runs on the IMMEDIATE mask). On a render re-enable edge
2715 // the shifter therefore advances for several dots (injecting the
2716 // serial-in '1') BEFORE the reload resumes — so one reload is
2717 // SKIPPED and the accumulated '1's reach the output (BG Serial
2718 // In) WITHOUT perturbing the sprite path (Stale Sprite Shift
2719 // Regs) or normal rendering (the reload value latched between
2720 // toggles equals the live mask -> byte-identical).
2721 // Freeze the BG-reload gate at its prior value for the analog
2722 // write-delay window; `tick` re-syncs it to the live mask once
2723 // the countdown settles.
2724 {
2725 self.mask_write_delay =
2726 MASK_WRITE_DELAY.load(core::sync::atomic::Ordering::Relaxed);
2727 }
2728 }
2729 2 => {
2730 // $2002 is read-only; writes only update the open-bus latch
2731 // (already done above) and otherwise have no effect.
2732 }
2733 3 => {
2734 // $2003 OAMADDR.
2735 self.oam_addr = value;
2736 // v2.1.7 P5 — OAMADDR ($2003) write-during-rendering OAM
2737 // corruption, modeled only on the earlier `Rp2c02G` revision
2738 // (default `Rp2c02H` skips this entirely → byte-identical). On
2739 // real "rev E+" 2C02 silicon, writing $2003 while rendering is
2740 // active corrupts one OAM "row"; RustyNES arms the shared
2741 // `CorruptOAM` row-copy (see `process_oam_corruption`) targeting
2742 // the row the write's high bits select, committed on the next
2743 // rendered dot. The `!oam_corruption_pending` guard defers to an
2744 // already-armed corruption (e.g. the rendering-disable model) so
2745 // the two sources never race. See `docs/ppu-2c02.md` (§OAMADDR
2746 // corruption) and `docs/accuracy-ledger.md` for the honesty note.
2747 if self.die_revision.models_oamaddr_corruption()
2748 && self.mask.rendering_enabled()
2749 && self.is_render_scanline()
2750 && !self.oam_corruption_pending
2751 {
2752 self.oam_corruption_pending = true;
2753 self.oam_corruption_index = (value >> 3) & 0x1F;
2754 }
2755 }
2756 4 => {
2757 // $2004 OAMDATA write. Per nesdev §PPU OAM:
2758 //
2759 // - Outside rendering (or rendering disabled): write the
2760 // value to OAM[OAMADDR] and increment OAMADDR by 1.
2761 // - During rendering (visible / pre-render scanline with
2762 // rendering enabled): the write is BLOCKED (real chip
2763 // does a glitchy "OAM read" instead, value discarded),
2764 // but OAMADDR is still incremented by **4** (NOT 1) —
2765 // the silicon's OAMADDR-bump-on-rendering-write quirk
2766 // that AccuracyCoin's `Sprite Evaluation :: Misaligned
2767 // OAM behavior` test (T-60-002, 2026-05-17) brackets.
2768 //
2769 // Pre-fix our impl always incremented by 1; matches the
2770 // outside-rendering path but is wrong during rendering.
2771 if self.mask.rendering_enabled() && self.is_render_scanline() {
2772 // During-rendering quirk: OAMADDR += 4, then mask
2773 // with $FC (clear bottom 2 bits — re-align to a
2774 // 4-byte sprite boundary). Required for
2775 // AccuracyCoin's "Address $2004 behavior" sub-test
2776 // A which writes $2004 with OAMADDR=1 during
2777 // rendering, then expects subsequent reads at
2778 // OAMADDR=4 (= (1+4) & $FC) to read OAM[4].
2779 self.oam_addr = self.oam_addr.wrapping_add(4) & 0xFC;
2780 } else {
2781 self.oam[self.oam_addr as usize] = value;
2782 // v2.3.2 "Lucid" — attribute only the branch that actually
2783 // stores. The during-rendering branch above is BLOCKED by the
2784 // hardware quirk, so recording it would attribute a byte to an
2785 // instruction that demonstrably did not write it.
2786 #[cfg(feature = "debug-hooks")]
2787 if let Some(attrib) = self.write_attrib.as_mut() {
2788 attrib.record_oam(self.oam_addr, self.attrib_pc, self.attrib_cycle, value);
2789 }
2790 // v2.1.4 F2.3 — OAM-decay write hook (no-op at the default):
2791 // a direct `$2004` write refreshes the written row.
2792 self.oam_decay_on_write(self.oam_addr);
2793 self.oam_addr = self.oam_addr.wrapping_add(1);
2794 }
2795 }
2796 5 => {
2797 // $2005 PPUSCROLL.
2798 if self.post_reset_mask_remaining > 0 {
2799 return;
2800 }
2801 if self.w {
2802 // Second write — Y scroll.
2803 self.t = (self.t & 0x8C1F)
2804 | ((u16::from(value) & 0xF8) << 2)
2805 | ((u16::from(value) & 0x07) << 12);
2806 self.w = false;
2807 } else {
2808 // First write — X scroll.
2809 self.t = (self.t & 0xFFE0) | (u16::from(value) >> 3);
2810 self.x = value & 0x07;
2811 self.w = true;
2812 }
2813 }
2814 6 => {
2815 // $2006 PPUADDR.
2816 if self.post_reset_mask_remaining > 0 {
2817 return;
2818 }
2819 if self.w {
2820 // Second write — low byte; copy t to v.
2821 self.t = (self.t & 0xFF00) | u16::from(value);
2822 // v2.0.3 (ADR 0030, Option 1) — "Hybrid Addresses" the natural
2823 // way. During rendering, a `$2006` second write does NOT copy
2824 // `t -> v` immediately; it stages the delayed-`CopyV` countdown
2825 // (`TriCNES` `PPU_Update2006Delay`). The `v = t` and the
2826 // `address_bus = v` splice happen when the countdown lands
2827 // (`Self::tick`), by which point the fetch cadence has advanced
2828 // coarse-X and the per-group phase-0 nametable ALE has NATURALLY
2829 // loaded `octal_latch` with the one-tile-ahead NT-low (`$19`),
2830 // so the landing read splices `$2F00 | $19 = $2F19` with no
2831 // reconstruction. Outside rendering the copy is immediate (the
2832 // delay is unobservable there and would only risk shifting
2833 // tightly-timed non-render code), so non-render behavior is
2834 // unchanged.
2835 let deferred_copy_v = self.mask.rendering_enabled()
2836 && self.is_render_scanline()
2837 // Only within the active BG-fetch window (visible dots
2838 // 1..=256 + the dots-321..=336 prefetch). The "Hybrid
2839 // Addresses" corruption can ONLY manifest when a background
2840 // fetch is in flight to consume the stale octal latch; a
2841 // `$2006` write during the sprite/HBlank interval
2842 // (257..=320) has no BG-fetch consumer, so deferring `v = t`
2843 // there would serve no accuracy purpose and only risk
2844 // shifting the many commercial mid-frame scroll splits
2845 // (SMB3's status-bar `$2006`/`$2005`, MMC5 titles) that
2846 // write during HBlank. Narrowing to the fetch window keeps
2847 // the delayed-`CopyV` surgical to the modeled artifact.
2848 && ((1..=256).contains(&self.dot) || (321..=336).contains(&self.dot))
2849 && {
2850 // Alignment-dependent delay (TriCNES uses 4 for three of
2851 // four CPU/PPU phases, 5 for one). The `$2006` write is
2852 // applied at the START of a CPU cycle in RustyNES's
2853 // lockstep bus (before that cycle's 3 PPU ticks); the
2854 // corrupted NT read is the phase-1 dot of the fetch group
2855 // one coarse-X past the write. Empirically calibrated
2856 // against the TriCNES per-dot trace (see the campaign
2857 // plan); the AccuracyCoin test also retries across
2858 // frames/alignments so at least one alignment lands.
2859 self.copy_v_delay = COPY_V_DELAY;
2860 octal_trace::push(
2861 octal_trace::K_W2006,
2862 self.frame,
2863 self.scanline,
2864 self.dot,
2865 u32::from(self.t & 0x3FFF),
2866 );
2867 true
2868 };
2869 if !deferred_copy_v {
2870 self.v = self.t;
2871 // PPUADDR write can flip A12.
2872 self.observe_a12(bus);
2873 }
2874 self.w = false;
2875 } else {
2876 // First write — high byte (clears bit 14 of t).
2877 self.t = (self.t & 0x00FF) | ((u16::from(value) & 0x3F) << 8);
2878 self.w = true;
2879 }
2880 }
2881 7 => {
2882 // $2007 PPUDATA write. Same rendering quirk as the
2883 // read path (see `cpu_read_register` case 7 docstring):
2884 // writes during rendering increment both coarse-X and
2885 // Y scroll instead of the normal `inc` value.
2886 let addr = self.v & 0x3FFF;
2887 if addr >= 0x3F00 {
2888 self.write_palette(addr, value);
2889 } else {
2890 self.write_vram(bus, addr, value);
2891 }
2892 if self.mask.rendering_enabled() && self.is_render_scanline() {
2893 self.inc_hori_v();
2894 self.inc_vert_v();
2895 } else {
2896 let inc = if self.ctrl.contains(PpuCtrl::VRAM_INCREMENT_32) {
2897 32
2898 } else {
2899 1
2900 };
2901 self.v = self.v.wrapping_add(inc) & 0x7FFF;
2902 }
2903 self.observe_a12(bus);
2904 }
2905 _ => unreachable!(),
2906 }
2907 }
2908
2909 /// Address-bus A12 = `v` bit 12 during `$0000-$3FFF` accesses. Notify the
2910 /// mapper on every transition. Also called by `observe_a12_addr` for
2911 /// the actual pattern fetch addresses (background and sprite fetches
2912 /// directly read CHR via the address bus, not via `v`).
2913 fn observe_a12<B: PpuBus>(&mut self, bus: &mut B) {
2914 let level = (self.v & 0x1000) != 0;
2915 if level != self.last_a12_level {
2916 bus.notify_a12(level);
2917 self.last_a12_level = level;
2918 }
2919 }
2920
2921 /// Notify the mapper of an A12 transition implied by an explicit
2922 /// pattern-table fetch address (BG / sprite fetches that bypass `v`).
2923 fn observe_a12_addr<B: PpuBus>(&mut self, bus: &mut B, addr: u16) {
2924 let level = (addr & 0x1000) != 0;
2925 if level != self.last_a12_level {
2926 bus.notify_a12(level);
2927 self.last_a12_level = level;
2928 }
2929 }
2930
2931 /// Read from PPU memory `$0000-$3EFF` honoring CIRAM ownership: CHR
2932 /// (`$0000-$1FFF`) goes to the bus/mapper; nametable (`$2000-$3EFF`)
2933 /// reads come from the PPU-owned CIRAM through the mapper-supplied
2934 /// mirroring map.
2935 ///
2936 /// The bus is consulted via `peek_nametable` first; mappers like MMC5
2937 /// in fill mode or ExRAM-as-nametable mode synthesize the byte
2938 /// directly. Only when the bus declines (`None`) do we hit CIRAM.
2939 // `&mut self` is required under `mc-ppu-2007-render-buffer` (it latches
2940 // `self.render_data_bus` below); clippy's needless-pass-by-ref-mut only fires
2941 // on the default build where that cfg is off, so allow it here.
2942 #[allow(clippy::needless_pass_by_ref_mut)]
2943 fn read_vram<B: PpuBus>(&mut self, bus: &mut B, addr: u16) -> u8 {
2944 let a = addr & 0x3FFF;
2945 // Every PPU bus read passes through here -- pattern fetches, nametable
2946 // and attribute fetches, sprite pattern fetches, and `$2007`. Capturing
2947 // at the choke point rather than at each call site is what makes the
2948 // trace complete by construction: a fetch added later cannot forget to
2949 // record itself.
2950 #[cfg(feature = "ppu-fetch-trace")]
2951 if let Some(trace) = self.fetch_trace.as_mut() {
2952 trace.push(crate::fetch_trace::FetchRecord {
2953 frame: u32::try_from(self.frame).unwrap_or(u32::MAX),
2954 scanline: self.scanline,
2955 dot: self.dot,
2956 addr: a,
2957 });
2958 }
2959 let val = if a < 0x2000 {
2960 bus.ppu_read(a)
2961 } else {
2962 // Mirror $3000-$3EFF to $2000-$2EFF.
2963 let nt_addr = if a >= 0x3000 { a - 0x1000 } else { a };
2964 if let Some(v) = bus.peek_nametable(nt_addr) {
2965 v
2966 } else {
2967 let off = bus.nametable_address(nt_addr) as usize;
2968 self.ciram[off & 0x07FF]
2969 }
2970 };
2971 // The VRAM data bus latches every read (the rendering fetches drive it);
2972 // a `$2007` read during rendering returns this, not a read at `v`.
2973 {
2974 self.render_data_bus = val;
2975 }
2976 // v2.0.3 (ADR 0030) — TriCNES `FetchPPU`: after the read the multiplexed
2977 // bus's low 8 bits hold the DATA (AD7-0). Crucially, `octal_latch` is NOT
2978 // refreshed here — it retains the ADDRESS low it was loaded with at ALE, so
2979 // a following `$2007`-read ALE-overlap freezes it on this stale data byte
2980 // (the "ALE + Read" corruption; the latch is managed by `ale_splice` /
2981 // `drive_bus`).
2982 val
2983 }
2984
2985 /// W2 ($2007 Stress) — per-dot sprite-tile fetch read cadence (dots
2986 /// 257-320), feeding `render_data_bus` for the deferred `$2007` `PPUDATA`
2987 /// buffer reload. Per `AccuracyCoin` `$2007 Stress` (and `TriCNES`'s per-dot
2988 /// PPU), each 8-dot sprite slot does TWO nametable reads in a row (not
2989 /// NT+AT) then the sprite PT-lo / PT-hi. Both garbage NT reads use the
2990 /// (horizontally-reset) `v` address — the sprite-fetch interval does no
2991 /// coarse-X increment, so it is constant across all 8 slots. Reads land
2992 /// on the slot-local odd dots (1,3,5,7). The PT bytes come from the raw
2993 /// stash captured by `fetch_sprite_tile` (no fresh CHR read, so no new
2994 /// A12/mapper events); the NT reads go through `read_vram`, which latches
2995 /// `render_data_bus` itself.
2996 fn tick_sprite_fetch_read<B: PpuBus>(&mut self, bus: &mut B) {
2997 let local = (self.dot - 257) % 8;
2998 let slot = ((self.dot - 257) / 8) as usize;
2999 match local {
3000 1 | 3 => {
3001 // Slot 0's FIRST garbage NT read straddles the dot-257
3002 // copy-hori boundary: its ALE (dot 257) latched the OLD `v`
3003 // address (`ppudata_spr0_nt_addr`). Every later garbage NT
3004 // read uses the (horizontally reset) live `v`.
3005 let nt = if local == 1 && slot == 0 {
3006 self.ppudata_spr0_nt_addr
3007 } else {
3008 0x2000 | (self.v & 0x0FFF)
3009 };
3010 // `read_vram` latches `render_data_bus` with the value read.
3011 let _ = self.read_vram(bus, nt);
3012 }
3013 5 if slot < 8 => self.render_data_bus = self.spr_fetch_lo_raw[slot],
3014 7 if slot < 8 => self.render_data_bus = self.spr_fetch_hi_raw[slot],
3015 _ => {}
3016 }
3017 }
3018
3019 /// Write to PPU memory `$0000-$3EFF`. Mirrors [`Self::read_vram`].
3020 fn write_vram<B: PpuBus>(&mut self, bus: &mut B, addr: u16, value: u8) {
3021 let a = addr & 0x3FFF;
3022 if a < 0x2000 {
3023 bus.ppu_write(a, value);
3024 } else {
3025 let nt_addr = if a >= 0x3000 { a - 0x1000 } else { a };
3026 // Give the mapper a chance to absorb the write (ExRAM
3027 // nametables, fill-mode drops, etc.). If declined, write CIRAM.
3028 if !bus.write_nametable(nt_addr, value) {
3029 let off = bus.nametable_address(nt_addr) as usize;
3030 self.ciram[off & 0x07FF] = value;
3031 // v2.3.2 "Lucid" — attribute the byte to the instruction that
3032 // stored it. Recorded here rather than at the CPU-write boundary
3033 // because only this site knows the resolved physical offset: the
3034 // caller wrote `$2007`, and `v` plus the mapper's mirroring is
3035 // what turned that into `off`.
3036 #[cfg(feature = "debug-hooks")]
3037 if let Some(attrib) = self.write_attrib.as_mut() {
3038 attrib.record_ciram(off, self.attrib_pc, self.attrib_cycle, value);
3039 }
3040 }
3041 }
3042 }
3043
3044 /// Read palette RAM. Mirrors:
3045 /// $3F10/$14/$18/$1C → $3F00/$04/$08/$0C
3046 /// anything past $3F1F mirrors back into the 32-byte window.
3047 const fn read_palette(&self, addr: u16) -> u8 {
3048 let idx = palette_index(addr);
3049 // Apply the greyscale mask if PPUMASK bit 0 is set.
3050 let raw = self.palette_ram[idx];
3051 if self.mask.contains(PpuMask::GREYSCALE) {
3052 raw & 0x30
3053 } else {
3054 raw
3055 }
3056 }
3057
3058 // Const-promotable only when `debug-hooks` is off (the attribution branch
3059 // below dereferences a `Box`, which is not const). Allowing the lint keeps
3060 // ONE definition instead of two cfg'd copies of the same three lines.
3061 #[allow(clippy::missing_const_for_fn)]
3062 fn write_palette(&mut self, addr: u16, value: u8) {
3063 let idx = palette_index(addr);
3064 // Palette is 6-bit storage.
3065 self.palette_ram[idx] = value & 0x3F;
3066 // v2.3.2 "Lucid" — record the MASKED value, so the attribution matches
3067 // what a later read returns rather than what the CPU put on the bus.
3068 // `idx` is post-mirroring, so an attribution looked up through `$3F10`
3069 // and through `$3F00` resolves to the same record — correct, since they
3070 // are the same byte.
3071 #[cfg(feature = "debug-hooks")]
3072 if let Some(attrib) = self.write_attrib.as_mut() {
3073 attrib.record_palette(idx, self.attrib_pc, self.attrib_cycle, value & 0x3F);
3074 }
3075 }
3076
3077 /// Tick exactly one dot.
3078 #[allow(clippy::too_many_lines)] // the per-dot FSM + the ppu-oam-data-bus tick hook
3079 #[allow(clippy::cognitive_complexity)] // + the ppu-sprite-shifter-counter render-toggle branches
3080 pub fn tick<B: PpuBus>(&mut self, bus: &mut B) {
3081 // Advance the dot/scanline FSM first, then handle per-dot events at
3082 // the post-advance position.
3083 self.advance_dot();
3084
3085 // === v2.1.8 A1 — specialized visible-scanline fast dot path ===
3086 //
3087 // The per-dot `tick` FSM below is the emulator's single hottest
3088 // function (`Ppu::tick` ~46% of a representative frame's self-time,
3089 // `docs/performance.md`). The overwhelming majority of its 89,342
3090 // per-frame invocations are visible-scanline BG-render dots whose
3091 // surrounding event/bookkeeping branches are all statically dead —
3092 // no scanline-241 VBL set, no pre-render clear, no OAM-corruption
3093 // edge, no PPUDATA state machine in flight, no `$2006` copy-V or
3094 // PPUMASK write delay pending, rendering stably enabled. This gate
3095 // detects that regime cheaply and, when the (default-OFF) runtime
3096 // knob is on, dispatches to [`Self::tick_visible_render_fast`], which
3097 // runs the *identical* helper sequence with the dead branches pruned.
3098 //
3099 // BYTE-IDENTITY: the fast handler is byte-identical BY CONSTRUCTION —
3100 // it calls the same helpers (`tick_oam_corruption`,
3101 // `tick_sprite_eval_per_dot`, `tick_oam_bus`, `reload_bg_shift_regs`,
3102 // the `ale_drive_*` / `fetch_*` pair, `inc_hori_v`, `inc_vert_v`,
3103 // `emit_pixel`, `shift_bg`) in the same order the general path would
3104 // for a dot satisfying the guard, and executes NONE of the branches
3105 // the guard proves un-taken. The guard is conservative: any doubt
3106 // (delay counters non-zero, corruption armed, cache cold, rendering
3107 // toggling) falls through to the exact path below. Empirically pinned
3108 // bit-for-bit by the differential test
3109 // (`crates/rustynes-test-harness/tests/fast_dotloop_diff.rs`) and the
3110 // full AccuracyCoin / visual-regression / nestest oracle.
3111 //
3112 // Compiled out under `ppu-state-trace` (whose end-of-tick hook must
3113 // observe every dot); under that feature the knob is inert.
3114 // Guard-condition ORDER is tuned to fail fast (short-circuit) for the
3115 // common non-covered dots so the flag costs little when it does not
3116 // apply: the dot-range and rendering-enabled tests eliminate the
3117 // out-of-window and rendering-disabled dots first (a rendering-disabled
3118 // frame — e.g. the `flowing_palette` all-64-colour backdrop-override
3119 // demo — bails at `rendering_enabled()` before the more numerous
3120 // sub-dot-disturbance checks). AND is commutative, so the reorder is
3121 // byte-identity-neutral. NOTE: `cached_visible` is only meaningful once
3122 // the classification cache is warm, so `scanline == flags_cached_scanline`
3123 // MUST be tested before it.
3124 #[cfg(not(feature = "ppu-state-trace"))]
3125 if self.fast_dotloop
3126 && self.dot <= 256
3127 && self.dot >= 1
3128 // Rendering stably enabled: immediate == 1-dot-delayed == previous
3129 // dot's value, so `rendering`, `rendering_gate`, `bg_reload_render`
3130 // and the shift gate all collapse to `true` with no edge to model.
3131 && self.mask.rendering_enabled()
3132 && self.rendering_enabled_delayed
3133 && self.prev_rendering_enabled
3134 // Scanline classification cache warm (dot 0 of the line, taken on
3135 // the general path, warms it) AND this is a visible scanline.
3136 && self.scanline == self.flags_cached_scanline
3137 && self.cached_visible
3138 // No sub-dot disturbance in flight.
3139 && self.copy_v_delay == 0
3140 && self.mask_write_delay == 0
3141 && self.ppudata_sm_countdown == 0
3142 && !self.oam_corruption_pending
3143 && !self.oam_corruption_disabled
3144 && !self.oam_corruption_disabled_instant
3145 {
3146 #[cfg(feature = "ppu-fetch-trace")]
3147 {
3148 self.fast_path_hits = self.fast_path_hits.saturating_add(1);
3149 }
3150 self.tick_visible_render_fast(bus);
3151 return;
3152 }
3153
3154 // === v2.2.3 P2 — specialized IDLE-LINE fast dot path ===
3155 //
3156 // A1 (above) covers visible dots 1..=256 — 61,440 of the 89,342 NTSC
3157 // dots, 68.8%. The remaining 31.2% still walk the whole general body.
3158 // The cheapest slice of that remainder to prove is the **idle line**:
3159 // the post-render line (240) plus every vblank line except the VBL-set
3160 // line 241, i.e. 20 of 262 lines / 6,820 dots per frame.
3161 //
3162 // On such a dot the general path below reduces, provably, to exactly
3163 // three assignments — every other branch is gated on `render_line`,
3164 // `visible`, `pre_render`, `scanline == vblank_start_line()`, or a
3165 // disturbance counter this guard requires to be zero:
3166 //
3167 // * `bg_reload_render = mask.rendering_enabled()` (the
3168 // `mask_write_delay == 0` arm),
3169 // * `prev_rendering_enabled = rendering`,
3170 // * `rendering_enabled_delayed = rendering`.
3171 //
3172 // `tick_idle_line_fast` performs precisely those, in that order, from
3173 // the same single `mask.rendering_enabled()` read — so it is
3174 // byte-identical BY CONSTRUCTION, on the same terms as A1.
3175 //
3176 // The guard requires the classification cache to be WARM for this
3177 // scanline, which is what makes `cached_idle_line` trustworthy: dot 0
3178 // of every line misses the cache and takes the general path (warming
3179 // it), so the fast path serves dots 1..=340 — 340 of each idle line's
3180 // 341 dots.
3181 //
3182 // It also requires the three sub-dot disturbance countdowns to be
3183 // idle. `$2006` (`copy_v_delay`) and `$2001` (`mask_write_delay`) are
3184 // load-bearing: both are perfectly legal during vblank — that is when
3185 // most games issue them — and each has real work to do on landing.
3186 // `ppudata_sm_countdown` is BELT-AND-BRACES: it is armed only under
3187 // `mask.rendering_enabled() && is_render_scanline()` (see the `$2007`
3188 // read handler), so it cannot currently be live on an idle line at all.
3189 // It is tested anyway so the guard's correctness is self-evident from
3190 // the guard itself, rather than resting on an invariant enforced three
3191 // hundred lines away that a future change could quietly break. One
3192 // comparison is a fair price for that.
3193 //
3194 // NOTE for anyone extending this: the three assignments in
3195 // `tick_idle_line_fast` are, given this guard, provably redundant —
3196 // the mask cannot change without a `$2001` write, which arms
3197 // `mask_write_delay` and routes the affected dots through the general
3198 // path, so the values are already correct on every dot the fast path
3199 // serves. Verified empirically: deleting any one of them leaves the
3200 // whole differential suite green. They are kept regardless, because
3201 // "runs the same assignments in the same order" is a claim that can be
3202 // checked by reading twenty lines, whereas "these stores are dead" is a
3203 // reachability argument that must be re-derived every time the guard
3204 // moves. Three stores per idle dot is not worth trading that away.
3205 //
3206 // Compiled out under `ppu-state-trace`, whose end-of-tick hook must
3207 // observe every dot (same treatment as A1).
3208 #[cfg(all(feature = "ppu-idle-line-fast", not(feature = "ppu-state-trace")))]
3209 if self.fast_dotloop
3210 && self.scanline == self.flags_cached_scanline
3211 && self.cached_idle_line
3212 && self.copy_v_delay == 0
3213 && self.mask_write_delay == 0
3214 && self.ppudata_sm_countdown == 0
3215 {
3216 self.tick_idle_line_fast();
3217 return;
3218 }
3219
3220 // v2.0.3 (ADR 0030, Option 1) — the delayed-`CopyV` landing (`TriCNES`
3221 // `Emulator.cs:1684-1704`). Ticked at the TOP of the dot, BEFORE the fetch
3222 // dispatch, so the `address_bus = v` splice is in place for THIS dot's
3223 // nametable read (the corrupted "Hybrid Addresses" fetch). The phase-0 NT
3224 // ALE of the corrupt group ran on the PREVIOUS dot and already loaded
3225 // `octal_latch` with the one-tile-ahead NT-low, so the armed splice at the
3226 // read below yields `(v & 0x3F00) | octal_latch = $2F19` naturally.
3227 if self.copy_v_delay > 0 {
3228 self.copy_v_delay -= 1;
3229 if self.copy_v_delay == 0 {
3230 self.v = self.t;
3231 self.address_bus = self.v;
3232 // Preserve the `$2006`-write A12 edge (MMC3 timing). It is delayed
3233 // by the countdown vs the flag-off immediate copy, but `$2006`
3234 // writes during active render are rare and A12 during render is
3235 // dominated by the dot-260 sprite fetch, so this stays inside the
3236 // fetch-address-derived-timing budget (verified by the battery).
3237 self.observe_a12(bus);
3238 }
3239 }
3240
3241 // v2.0 Phase 6 (mc-ppu-subpos): track the BG-reload gate. It follows the
3242 // live `self.mask` rendering bit EXCEPT during the analog `$2001`
3243 // write-delay window, where it stays frozen at the prior value (TriCNES
3244 // `PPU_Update2001Delay` -> `PPU_Mask_Show*_Delayed`). Re-syncing to the
3245 // live mask when settled keeps it consistent under a direct mask set
3246 // (unit tests / save-state restore). Done at the top of the dot, before
3247 // the reload runs; the live `self.mask` is unaffected.
3248 if self.mask_write_delay > 0 {
3249 self.mask_write_delay -= 1;
3250 } else {
3251 self.bg_reload_render = self.mask.rendering_enabled();
3252 }
3253
3254 // v1.4.0 Workstream F (F1): the scanline-classification flags are pure
3255 // functions of `self.scanline` + `self.region`, so recompute them only
3256 // when the scanline changes; every other dot reads the cached copies.
3257 // Byte-identical (same values), self-healing on reset / restore.
3258 if self.scanline != self.flags_cached_scanline {
3259 self.cached_visible =
3260 self.scanline >= 0 && self.scanline <= self.region.last_visible_line();
3261 self.cached_pre_render = self.scanline == self.region.prerender_line();
3262 self.cached_render_line = self.cached_visible || self.cached_pre_render;
3263 // v2.2.3 P2 — an "idle" line: neither visible nor pre-render, and not
3264 // the VBL-set line. That is the post-render line (240) plus every
3265 // vblank line except 241 — 20 of the 262 NTSC lines. On such a line
3266 // no dot fetches, renders, evaluates sprites, or raises an event, so
3267 // the whole per-dot body collapses to the rendering-flag bookkeeping
3268 // (see `tick_idle_line_fast`). Cached here with the other
3269 // classification flags because it is the same pure function of
3270 // `scanline` + `region` and shares their `flags_cached_scanline` key.
3271 #[cfg(feature = "ppu-idle-line-fast")]
3272 {
3273 self.cached_idle_line =
3274 !self.cached_render_line && self.scanline != self.region.vblank_start_line();
3275 }
3276 self.flags_cached_scanline = self.scanline;
3277 }
3278 let visible = self.cached_visible;
3279 let pre_render = self.cached_pre_render;
3280 let render_line = self.cached_render_line;
3281 let rendering = self.mask.rendering_enabled();
3282 // v2.0 (ae30785): the fetch/shift/sprite-eval pipeline gates on the
3283 // 1-PPU-dot-delayed rendering value under `ppu-sprite-shifter-counter`
3284 // (a mid-scanline `$2001` toggle takes effect one dot later — Stale
3285 // BG/Sprite). Default build = the immediate value (byte-identical).
3286 let rendering_gate = self.rendering_enabled_delayed;
3287
3288 // OAM corruption (TriCNES eval-pointer model). The disable edge
3289 // itself is armed by the `$2001` write (see the PPUMASK handler);
3290 // the index is captured against the live secondary-OAM eval
3291 // pointer during the dots 1-64 window (`capture_oam_corruption`,
3292 // called from the sprite-eval FSM); and the actual corruption is
3293 // committed when rendering RE-ENABLES on a render line, or at the
3294 // pre-render line. Here we only retain the unrelated BG-shifter
3295 // fix-up that the prior model happened to share the 1->0 edge with.
3296 if render_line && rendering != self.prev_rendering_enabled && !rendering {
3297 // v2.0 (ppu-sprite-shifter-counter): if rendering is disabled
3298 // mid-pre-fetch (dots 329-336, the SECOND fetch group after the
3299 // dot-329 reload), the in-progress group's pending `<<= 8` would be
3300 // skipped by the now-gated pipeline, freezing the just-reloaded tile
3301 // in the BG shifter's bits 0-7. Complete it ONCE here (one-time on
3302 // the 1->0 edge) so the tile lands in bits 8-15 and surfaces at the
3303 // correct pixel on re-enable (Stale Sprite Shift Regs t5/6).
3304 if (329..=336).contains(&self.dot) {
3305 self.prefetch_shift_bg_regs();
3306 }
3307 }
3308 // Commit pending OAM corruption at the START of the pre-render line
3309 // (TriCNES handles this via the dots 1-64 eval path on the
3310 // pre-render line; the dot-0 hook covers the case where rendering
3311 // was re-enabled during VBlank and stays on into pre-render).
3312 // Per TriCNES `CorruptOAM`, the corruption applies on the first
3313 // rendered dot once rendering is (re-)enabled.
3314 if self.scanline == self.region.prerender_line()
3315 && self.dot == 0
3316 && rendering
3317 && self.oam_corruption_pending
3318 {
3319 self.process_oam_corruption();
3320 }
3321 // OAM corruption (TriCNES eval-pointer model): maintain the
3322 // `OAM2Address` analogue across the dots 1-64 clear window, capture
3323 // the corruption index at the disable edge, and commit on re-enable.
3324 // Driven every render-line dot independent of the rendering gate so
3325 // the disable edge is observed even though the sprite-eval FSM below
3326 // stops once rendering is gated off.
3327 if render_line {
3328 self.tick_oam_corruption(rendering);
3329 }
3330 self.prev_rendering_enabled = rendering;
3331 // v2.0 (ae30785): update the 1-dot-delayed copy AFTER this dot's gate
3332 // read above, so the next dot sees the delayed value.
3333 {
3334 self.rendering_enabled_delayed = rendering;
3335 }
3336
3337 // === Dot-1 / dot-0 events ===
3338 // VBL flag is set at scanline 241 dot 1 per nesdev wiki. The
3339 // PPU's /NMI line is pulled low one PPU clock later (dot 2),
3340 // matching the behavior blargg's `ppu_vbl_nmi/05-nmi_timing` and
3341 // `08-nmi_off_timing` were calibrated to: a /NMI assertion-edge
3342 // sample ~6-7 PPU clocks after VBL set, given how our bus
3343 // interleaves CPU bus accesses before the 3-PPU-tick `on_cpu_cycle`
3344 // hook (vs. real hardware's mid-cycle phi1 access).
3345 if self.scanline == self.region.vblank_start_line()
3346 && self.dot == 1
3347 && !self.suppress_vbl_this_frame
3348 {
3349 self.status.insert(PpuStatus::VBLANK);
3350 // Inform the mapper that we have entered VBL — MMC5 uses this
3351 // to clear its in-frame flag.
3352 bus.notify_vblank();
3353 // R2 (mc-r1-substrate): /NMI is asserted on the SAME dot as
3354 // VBL-set when NMI_ENABLE is set (Mesen2 `NesPpu.cpp:1339-1343`).
3355 // On R1's on-time access the CPU read at dot 1 lands AFTER
3356 // VBL+NMI set, so the v1.x `dot==3` lag-comp band-aid (below) is
3357 // obsolete and disabled.
3358 if self.ctrl.contains(PpuCtrl::NMI_ENABLE) {
3359 self.nmi_line = true;
3360 }
3361 }
3362 // v1.x default path: /NMI raised at dot 3 to compensate for the
3363 // lockstep PPU running ~8 mc late. Disabled under the on-time R1
3364 // substrate (R2 raises /NMI at dot 1, above).
3365 if pre_render && self.dot == 1 {
3366 self.status.remove(
3367 PpuStatus::VBLANK
3368 .union(PpuStatus::SPRITE_ZERO_HIT)
3369 .union(PpuStatus::SPRITE_OVERFLOW),
3370 );
3371 self.nmi_line = false;
3372 self.suppress_vbl_this_frame = false;
3373 }
3374
3375 // Notify the mapper that a rendered scanline has started. We fire
3376 // this on dot 0 of every visible line and the pre-render line,
3377 // before any pattern/attribute fetches happen. MMC5 uses this to
3378 // tick its scanline IRQ counter (which conceptually fires at PPU
3379 // cycle ~4 of each rendered line — close enough for v0). Other
3380 // mappers default to no-op.
3381 if render_line && self.dot == 0 {
3382 bus.notify_scanline_start();
3383 }
3384
3385 // === Background rendering pipeline (visible + pre-render lines) ===
3386 if render_line && rendering_gate {
3387 // Sprite evaluation: per-PPU-dot FSM matching real-hardware
3388 // behavior (cycles 1-64 secondary-OAM clear, 65-256 alternating
3389 // odd/even read/write with the documented buggy `n+m`
3390 // overflow-detection increment). Visible scanlines evaluate
3391 // for the next visible scanline; the pre-render line evaluates
3392 // for scanline 0. Without the pre-render eval, secondary OAM
3393 // from the last visible scanline would leak into pre-render's
3394 // dummy sprite tile fetches, causing wrong A12 emissions and
3395 // incorrect sprite-zero state.
3396 if visible || pre_render {
3397 self.tick_sprite_eval_per_dot();
3398 }
3399 // v2.0 Tier 1.2: drive the isolated OAM-data-bus model on visible
3400 // scanlines when rendering, so a CPU $2004 read mid-frame observes
3401 // the sprite-eval / load data bus (AccuracyCoin `$2004 Stress`).
3402 // Side-effect-free w.r.t. the rendering FSM above.
3403 if visible && self.mask.rendering_enabled() {
3404 self.tick_oam_bus();
3405 }
3406 // Sprite tile fetch + A12 emission. Real hardware spreads the
3407 // 8 sprite slots' pattern fetches across cycles 257..=320 — for
3408 // each slot, garbage NT bytes at +1/+3, sprite pattern lo at
3409 // +5/+6, sprite pattern hi at +7/+8. We collapse that to per-
3410 // slot emission at dot 260 for slot 0, 268 for slot 1, …, 316
3411 // for slot 7. This is the canonical "MMC3 IRQ at PPU dot 260"
3412 // timing — the first A12 rise to the sprite pattern table
3413 // happens here for standard pattern-table layout (BG=$0000,
3414 // sprites=$1000), per `docs/mappers.md` §MMC3 → IRQ counter
3415 // mechanism.
3416 //
3417 // CRITICAL for MMC3: even unused sprite slots ALWAYS perform
3418 // the dummy sprite-pattern fetch on real hardware (using the
3419 // cleared secondary-OAM tile $FF), so A12 toggles into the
3420 // sprite pattern table once per scanline regardless of how
3421 // many real sprites are visible. This must run on both
3422 // visible scanlines and the pre-render line — pre-render
3423 // sprite fetches are for scanline 0's sprites and contribute
3424 // the 241st A12 rising edge per frame (240 visible + 1
3425 // pre-render) that MMC3's IRQ counter expects.
3426 if (260..=316).contains(&self.dot) {
3427 let phase = self.dot.wrapping_sub(260);
3428 if phase.trailing_zeros() >= 3 {
3429 let slot = (phase >> 3) as usize;
3430 self.fetch_sprite_tile(bus, slot);
3431 }
3432 }
3433
3434 // OAMADDR reset: per nesdev wiki "PPU registers" §OAMADDR,
3435 // "OAMADDR is set to 0 during each of ticks 257-320 (the
3436 // sprite tile loading interval) of the pre-render and visible
3437 // scanlines." This is the hardware behaviour that lets games
3438 // STX $4014 their OAM-staging page after rendering without
3439 // having to remember to STA $2003 #0 first. Required for
3440 // AccuracyCoin TEST_Sprite0Hit_Behavior subtest 1 (which
3441 // relies on the prior test-runner's OAMADDR perturbation
3442 // being washed away by the previous frame's rendering).
3443 if (257..=320).contains(&self.dot) {
3444 self.oam_addr = 0;
3445 }
3446
3447 // v2.0 (ppu-sprite-shifter-counter): at dot 339 of a render line,
3448 // re-arm the LOADED sprite counters (the `spr_count` slots fetched
3449 // for the next scanline) to "counting". This whole block is gated on
3450 // `rendering_gate`, so a render-disable across dot 339 leaves the
3451 // loaded slots halted — a reloaded-but-halted counter draws
3452 // immediately on re-enable (Stale Sprite Shift Regs t5/6). Slots
3453 // beyond `spr_count` retain their halted latch.
3454 if self.dot == 339 {
3455 for i in 0..self.spr_count as usize {
3456 self.spr_halted[i] = false;
3457 }
3458 }
3459
3460 // BG fetches happen at dots 1..=256 and 321..=336.
3461 //
3462 // CYCLE-PRECISE BG PIPELINE (Mesen2-faithful, fixes Cascade A
3463 // VerifySpriteZeroHits step-2 off-by-one):
3464 //
3465 // Per nesdev wiki "PPU rendering": "The shifters are reloaded
3466 // during ticks 9, 17, 25, ..., 257." Per Mesen2
3467 // `Core/NES/NesPpu.cpp::LoadTileInfo()` (line 667), the reload
3468 // is `case 1` of `(_cycle & 0x07)` — i.e., phase 0 of each
3469 // 8-cycle group, OR'ing the latched LowByte/HighByte into the
3470 // shifter's low 8 bits. The PRIOR group's 8 shifts (one per
3471 // cycle of dots 1..=256 of a visible scanline) leave bits 0-7
3472 // zeroed, so the OR is effectively an overwrite. The pre-fetch
3473 // groups at dots 321..=336 do NOT shift per-cycle; instead
3474 // Mesen2 substitutes a `<<= 8` at phase 7 (dots 328 and 336)
3475 // to clear bits 0-7 for the next reload.
3476 //
3477 // The matching pixel-emit + shift ordering is: emit_pixel reads
3478 // bit (15 - fine_x) FIRST, then shift_bg runs LAST. This is the
3479 // critical change from the prior (off-by-one) implementation
3480 // that shifted BEFORE emit and reloaded at phase 7 (cycle 8).
3481 // See `docs/audit/cascade-a-investigation-2026-05-19.md` for
3482 // the empirical analysis and the per-cycle trace of
3483 // VerifySpriteZeroHits step 2 demonstrating why this is the
3484 // load-bearing change.
3485 let in_bg_fetch = (1..=256).contains(&self.dot) || (321..=336).contains(&self.dot);
3486 if in_bg_fetch {
3487 let phase = (self.dot.wrapping_sub(1)) & 7;
3488 // Phase 0 (cycles 1, 9, 17, ..., 249, 321, 329): reload the
3489 // shifter's low 8 bits from the latches written by the
3490 // PRIOR fetch group. Implementation note: `reload_bg_shift_regs`
3491 // overwrites bits 0-7 via `(shift & 0xFF00) | latch`; this
3492 // matches Mesen2's `|=` because the 8 shifts since the prior
3493 // reload guarantee bits 0-7 are zero before the OR.
3494 if phase == 0 {
3495 // v2.0 Phase 6 (mc-ppu-subpos): the reload is additionally
3496 // gated on the analog-delayed `$2001` value, so a render
3497 // re-enable lands the reload `MASK_WRITE_DELAY` dots after
3498 // the shifter has already resumed advancing -> one reload is
3499 // SKIPPED and the serial-in '1's survive (BG Serial In).
3500 // When stable, `bg_reload_render` == the live rendering gate,
3501 // so this is byte-identical.
3502 let reload_gate = self.bg_reload_render;
3503 if reload_gate {
3504 self.reload_bg_shift_regs();
3505 }
3506 }
3507
3508 // v2.0.3 (ADR 0030, Option 1) — the ALE (address-latch-enable)
3509 // half of each 2-cycle VRAM access. On the EVEN dot of each pair
3510 // (phases 0/2/4/6, i.e. one dot BEFORE the corresponding read at
3511 // phases 1/3/5/7) the PPU drives the full 14-bit fetch address onto
3512 // `address_bus` and captures its low byte into `octal_latch`; the
3513 // read half (the existing `fetch_*` below) reads via the splice
3514 // `(address_bus & 0x3F00) | octal_latch`. For a coherent fetch the
3515 // splice returns the intended address, so this is behavior-neutral;
3516 // the split exists so a later phase's `$2006`/`$2007` corruption can
3517 // desync the two halves naturally.
3518 match phase {
3519 // Phase 0 (NT ALE): drive the PLAIN nametable address
3520 // (`0x2000 | (v & 0x0FFF)`) and load `octal_latch` with its low
3521 // byte. This is a TRUE two-dot ALE for the common (non-MMC5-
3522 // split) case, so the latch naturally carries the NT-low the
3523 // "Hybrid Addresses" corruption needs. The MMC5 vertical-split
3524 // query stays at the read dot (phase 1, `fetch_nt`) because its
3525 // `split_chr_bank_latch` side effect is mapper-observable; when
3526 // that query turns out to be split-active, `fetch_nt` disarms
3527 // this ALE and reads `split.nt_addr` co-located instead, so
3528 // split rendering is byte-identical (no phase-0 mapper query).
3529 0 => self.ale_drive_nt(),
3530 2 => self.ale_drive_at(),
3531 4 => self.ale_drive_bg_lo(),
3532 6 => self.ale_drive_bg_hi(),
3533 _ => {}
3534 }
3535
3536 // 8-cycle fetch group: dot phase = (dot - 1) & 7
3537 // 1 -> NT byte fetch (cycle 2 of group)
3538 // 3 -> AT byte fetch (cycle 4 of group)
3539 // 5 -> BG-low fetch (cycle 6 of group)
3540 // 7 -> BG-high fetch +
3541 // coarse-X increment (cycle 8 of group)
3542 match phase {
3543 1 => self.fetch_nt(bus),
3544 3 => self.fetch_at(bus),
3545 5 => self.fetch_bg_lo(bus),
3546 7 => self.fetch_bg_hi(bus),
3547 _ => {}
3548 }
3549 if phase == 7 {
3550 self.inc_hori_v();
3551 // Pre-fetch region only (dots 328 and 336): explicit
3552 // `<<= 8` to substitute for the missing per-cycle
3553 // shifts during pre-fetch. Per Mesen2
3554 // `ProcessScanlineImpl()` lines 941-944.
3555 if (321..=336).contains(&self.dot) {
3556 self.prefetch_shift_bg_regs();
3557 }
3558 }
3559 }
3560 // Dot 257: the LAST shift-register reload of the visible region
3561 // consumes the latches from the dots-249..=256 fetch group. Dot
3562 // 257 is outside the dots 1..=256 `in_bg_fetch` range above (it
3563 // belongs to the sprite-tile-fetch window 257..=320), but per
3564 // Mesen2's `_cycle <= 256` LoadTileInfo cycle range, this reload
3565 // actually never fires in Mesen2 either — the dot-256 fetch's
3566 // bg_lo/bg_hi latches are consumed by the dot-321 reload (which
3567 // OR's them in, then the dot-328 `<<= 8` shifts them up to
3568 // bits 8-15). So for our model, the dot-256 fetch's latches
3569 // similarly persist past dot 256 into dot 321's reload.
3570 // (Intentionally no dot-257 reload here.)
3571
3572 // Cycle 256: vertical-V increment.
3573 if self.dot == 256 {
3574 self.inc_vert_v();
3575 }
3576 // Cycle 257: copy hori(t) -> hori(v).
3577 if self.dot == 257 {
3578 // W2 ($2007 Stress): the FIRST garbage NT read of sprite slot
3579 // 0 has its ALE at dot 257, BEFORE hori(v) is reset — so its
3580 // ADDRESS uses the OLD `v` (coarse-x wrapped past the row's
3581 // last column) even though the data lands at dot 258. Latch
3582 // the address here; `tick_sprite_fetch_read` uses it for the
3583 // slot-0 first read (key idx 128 = `02`). The later garbage
3584 // NT reads (ALE dots 259+) all use the reset `v`.
3585 {
3586 self.ppudata_spr0_nt_addr = 0x2000 | (self.v & 0x0FFF);
3587 }
3588 self.copy_hori_t_to_v();
3589 }
3590 // Pre-render cycles 280..=304: copy vert(t) -> vert(v).
3591 if pre_render && (280..=304).contains(&self.dot) {
3592 self.copy_vert_t_to_v();
3593 }
3594 // Sprite tile fetch happens in fetch_sprite_tile (dots 260, 268, ..., 316).
3595 // Cycles 337..=340: 2 garbage NT fetches (no-op except A12).
3596 if (337..=340).contains(&self.dot) && (self.dot & 1) == 1 {
3597 self.fetch_nt(bus);
3598 }
3599
3600 // W2 ($2007 Stress): the per-dot sprite-fetch read cadence (dots
3601 // 257-320) feeds `render_data_bus` (NT,NT,PT-lo,PT-hi per 8-dot
3602 // slot) so a deferred `$2007` buffer reload landing in HBlank
3603 // captures the byte the sprite fetch drove on the VRAM bus. The
3604 // real fetch is the collapsed `fetch_sprite_tile` above; this is
3605 // side-effect-free w.r.t. rendering and A12 (the garbage NT reads
3606 // are CIRAM/nametable reads; the PT bytes come from the stash).
3607 if (257..=320).contains(&self.dot) {
3608 self.tick_sprite_fetch_read(bus);
3609 }
3610 }
3611
3612 // W2 ($2007 Stress): the PPUDATA state machine's read step (PD_RB) +
3613 // TStep. A `$2007` read during rendering armed this countdown; one
3614 // tick per PPU dot (unconditionally, so a mid-flight rendering
3615 // disable cannot wedge it), and at 0:
3616 // 1. `data_buffer` latches the value the FETCH cadence drove on the
3617 // VRAM bus at THIS dot (`render_data_bus`, freshly set by the
3618 // fetch dispatch above). Latched bus value only — never a fresh
3619 // VRAM read (zero new A12/mapper events).
3620 // 2. The deferred v-glitch increment (the TStep) fires AFTER the
3621 // reload, per TriCNES `PPU_DATA_StateMachine_Half` — so every
3622 // fetch in the read-to-reload window used the OLD `v`. The
3623 // rendering-vs-blanking choice uses the state AT the TStep dot
3624 // (mirrors TriCNES's `PPU_2007_BLNK_Latch`).
3625 if self.ppudata_sm_countdown > 0 {
3626 self.ppudata_sm_countdown -= 1;
3627 if self.ppudata_sm_countdown == 0 {
3628 self.data_buffer = self.render_data_bus;
3629 // v2.0.2 (ADR 0030) — "ALE + Read": the $2007 read's PPUDATA
3630 // state machine takes 3 PPU cycles to the background cadence's 2,
3631 // so on the reload dot its ALE overlaps a fetch's read. Both ALE
3632 // and READ are asserted, so the octal latch is frozen on the
3633 // read's DATA byte (`render_data_bus`) instead of the next
3634 // Pattern-Address-Register low byte. The next pattern fetch then
3635 // reads `{PAR high 6}:{stale data low}` (`$0F03` -> `$0FFF`).
3636 if self.mask.rendering_enabled() && self.is_render_scanline() {
3637 // Freeze the latch on the read's DATA byte here. The frozen byte
3638 // is then carried by `drive_bus` (which suppresses the next
3639 // pattern ALE's latch reload) and consumed by the pattern read's
3640 // natural `ale_splice`, so the next pattern fetch reads
3641 // `(PAR high 6):(stale $FF) = $0FFF` with no explicit splice.
3642 self.octal_latch = self.render_data_bus;
3643 self.pattern_latch_stale = true;
3644 octal_trace::push(
3645 octal_trace::K_SMLAND,
3646 self.frame,
3647 self.scanline,
3648 self.dot,
3649 u32::from(self.render_data_bus),
3650 );
3651 }
3652 if self.ppudata_v_inc_pending {
3653 self.ppudata_v_inc_pending = false;
3654 if self.mask.rendering_enabled() && self.is_render_scanline() {
3655 self.inc_hori_v();
3656 self.inc_vert_v();
3657 } else {
3658 let inc = if self.ctrl.contains(PpuCtrl::VRAM_INCREMENT_32) {
3659 32
3660 } else {
3661 1
3662 };
3663 self.v = self.v.wrapping_add(inc) & 0x7FFF;
3664 }
3665 // The v change can move A12 (mirrors the read-time path).
3666 self.observe_a12(bus);
3667 }
3668 }
3669 }
3670
3671 // === Pixel emission (visible scanlines, dots 1..=256) ===
3672 // Per Mesen2 `ProcessScanlineImpl()` (lines 881-884), the
3673 // canonical order is: LoadTileInfo (reload at phase 0, fetches at
3674 // phases 1/3/5/7) THEN DrawPixel THEN ShiftTileRegisters. The
3675 // shift-AFTER-emit ordering is the load-bearing other half of the
3676 // Cascade A BG-pipeline fix: emit reads bit (15 - fine_x) of the
3677 // shifter at its CURRENT state (post-reload, pre-shift), then the
3678 // shift advances the register for the next emit. Combined with
3679 // the phase-0 reload above, this places the newly-fetched tile's
3680 // MSB at shift-register bit 15 (the emit read point) at exactly
3681 // PPU dot 9 of each 8-cycle group = pixel column 8.
3682 if visible && (1..=256).contains(&self.dot) {
3683 self.emit_pixel();
3684 // v2.0 (ae30785): the BG shifter advances on the 1-dot-delayed
3685 // rendering gate (under the feature), so a precisely-timed `$2001`
3686 // toggle that skips the reload while rendering stays enabled
3687 // surfaces the serial-in (BG Serial In / Stale BG Shift).
3688 //
3689 // v2.0 Phase 6 (mc-ppu-subpos): TriCNES drives the SHIFT off the
3690 // IMMEDIATE PPUMASK (`_EmulateHalfPPU` -> `PPU_UpdateBackground
3691 // ShiftRegisters`, gated on `PPU_Mask_Show*` not `*_Delayed`) while
3692 // the fetch+RELOAD ride the 1-dot-DELAYED mask
3693 // (`PPU_Render_ShiftRegistersAndBitPlanes`, gated on `*_Delayed`).
3694 // So on a render re-enable edge there is a 1-dot window where the
3695 // shifter advances (injecting the serial-in '1') but the reload is
3696 // still gated off -> a single reload is SKIPPED while shifting
3697 // continues, surfacing the accumulated serial-in '1's at the output
3698 // (AccuracyCoin "BG Serial In"). When no mid-scanline toggle is in
3699 // flight immediate == delayed, so normal rendering is byte-identical.
3700 let shift_gate = render_line && rendering;
3701 if shift_gate {
3702 self.shift_bg();
3703 }
3704 }
3705
3706 // === Per-PPU-dot state-trace recording (Session-10) ===
3707 //
3708 // Gated on the `ppu-state-trace` cargo feature so the
3709 // default build's hot tick path is byte-identical to
3710 // pre-Session-10. The hook reads `self`'s state AFTER
3711 // all this dot's effects have applied, so the captured
3712 // record reflects "PPU state at the end of dot
3713 // (scanline, dot)". It NEVER writes to PPU state — the
3714 // determinism contract is preserved.
3715 //
3716 // See `docs/adr/0005-ppu-state-trace.md`.
3717 #[cfg(feature = "ppu-state-trace")]
3718 if self.state_trace.is_some() {
3719 let rec = self.build_state_record();
3720 if let Some(t) = self.state_trace.as_mut() {
3721 t.maybe_push(rec);
3722 }
3723 }
3724 }
3725
3726 /// v2.1.8 A1 — the specialized straight-line body for a "clean" visible
3727 /// BG-render dot: a visible scanline, `dot` in `1..=256`, rendering stably
3728 /// enabled, and no sub-dot disturbance in flight. Dispatched from
3729 /// [`Self::tick`] behind the [`Self::fast_dotloop`] guard (default ON
3730 /// since v2.2.3).
3731 ///
3732 /// This executes the *exact same* helper sequence the general per-dot path
3733 /// runs for such a dot — in the same order — with every event and
3734 /// bookkeeping branch the guard proves un-taken (VBL/NMI set/clear, the
3735 /// pre-render vertical reload, sprite-tile fetch dots 260..=316, the
3736 /// OAMADDR-reset window, the dot-257 hori-copy, the PPUDATA state machine,
3737 /// the OAM-corruption commit, the odd-frame skip) elided. It is therefore
3738 /// byte-identical to the general path by construction, and is additionally
3739 /// pinned bit-for-bit by the differential test + the full oracle. See the
3740 /// extensive rationale at the dispatch site in [`Self::tick`].
3741 /// v2.2.3 P2 — the specialized straight-line body for an **idle-line** dot:
3742 /// a post-render or vblank line other than the VBL-set line, with no
3743 /// sub-dot disturbance in flight. Dispatched from [`Self::tick`] behind the
3744 /// [`Self::fast_dotloop`] guard.
3745 ///
3746 /// An idle line issues no VRAM fetch, emits no pixel, runs no sprite
3747 /// evaluation, clocks no shifter, and raises no VBL / NMI / A12 /
3748 /// scanline-start event. Walking the general per-dot body for it therefore
3749 /// evaluates ~30 predicates to perform three assignments. This is those
3750 /// three assignments, in the general path's order, derived from one
3751 /// `mask.rendering_enabled()` read exactly as it does:
3752 ///
3753 /// 1. `bg_reload_render` — the general path's `mask_write_delay` `else`
3754 /// arm. The guard proves the countdown is zero, so that arm is the one
3755 /// taken.
3756 /// 2. `prev_rendering_enabled` — assigned unconditionally there.
3757 /// 3. `rendering_enabled_delayed` — likewise, and deliberately AFTER (2):
3758 /// the general path updates the 1-dot-delayed copy last so the *next*
3759 /// dot observes it, and reordering here would shift a mid-vblank
3760 /// `$2001` toggle by one dot.
3761 ///
3762 /// Byte-identical by construction, and pinned bit-for-bit by
3763 /// `fast_dotloop_diff` (which compares whole frames — every idle dot
3764 /// included — and by `idle_line_fast_path_matches_exact_under_vblank_io`,
3765 /// which drives `$2000`/`$2001`/`$2006`/`$2007` during vblank so the
3766 /// guard's fall-through arms are exercised rather than assumed).
3767 #[cfg(feature = "ppu-idle-line-fast")]
3768 #[inline]
3769 const fn tick_idle_line_fast(&mut self) {
3770 let rendering = self.mask.rendering_enabled();
3771 self.bg_reload_render = rendering;
3772 self.prev_rendering_enabled = rendering;
3773 self.rendering_enabled_delayed = rendering;
3774 }
3775
3776 // Dead under `ppu-state-trace`, and legitimately so: the dispatch above is
3777 // `#[cfg(not(feature = "ppu-state-trace"))]`, because the trace hook must
3778 // observe EVERY dot and the fast path exists precisely to skip per-dot work.
3779 // Live by default, dead under one feature -- the one shape that earns an
3780 // `allow` rather than a deletion. Scoped to that feature so the attribute
3781 // cannot silently start suppressing a real finding in the default build.
3782 #[cfg_attr(feature = "ppu-state-trace", allow(dead_code))]
3783 #[inline]
3784 fn tick_visible_render_fast<B: PpuBus>(&mut self, bus: &mut B) {
3785 let dot = self.dot;
3786
3787 // General-path top: with `mask_write_delay == 0` (guard) the BG-reload
3788 // gate follows the stably-enabled rendering bit.
3789 self.bg_reload_render = true;
3790
3791 // OAM-corruption pointer bookkeeping. The guard proved nothing is
3792 // armed/pending/disabled, so this only maintains `oam2_addr` across the
3793 // dots 1..=64 secondary-OAM clear window (and is a two-compare no-op for
3794 // dots 65..=256) — exactly what the general path's
3795 // `if render_line { tick_oam_corruption(rendering) }` does here.
3796 self.tick_oam_corruption(true);
3797
3798 // Rendering-edge bookkeeping the NEXT dot's gate consumes. Both are
3799 // already `true` (guard), but the general path assigns them every dot,
3800 // so keep the writes to stay byte-identical across a fast→general dot
3801 // boundary.
3802 self.prev_rendering_enabled = true;
3803 self.rendering_enabled_delayed = true;
3804
3805 // Sprite-evaluation FSM (visible scanline) + isolated OAM data-bus model.
3806 self.tick_sprite_eval_per_dot();
3807 self.tick_oam_bus();
3808
3809 // Background fetch pipeline: dots 1..=256 are all in the fetch window,
3810 // `phase = (dot - 1) & 7`.
3811 let phase = dot.wrapping_sub(1) & 7;
3812 // Phase 0: shift-register reload (reload gate == `bg_reload_render`).
3813 if phase == 0 {
3814 self.reload_bg_shift_regs();
3815 }
3816 // 2-cycle-ALE address-latch half (even phases).
3817 match phase {
3818 0 => self.ale_drive_nt(),
3819 2 => self.ale_drive_at(),
3820 4 => self.ale_drive_bg_lo(),
3821 6 => self.ale_drive_bg_hi(),
3822 _ => {}
3823 }
3824 // Read half (odd phases).
3825 match phase {
3826 1 => self.fetch_nt(bus),
3827 3 => self.fetch_at(bus),
3828 5 => self.fetch_bg_lo(bus),
3829 7 => self.fetch_bg_hi(bus),
3830 _ => {}
3831 }
3832 // Phase 7 (cycle 8 of the group): coarse-X increment. The dots
3833 // 321..=336 prefetch `<<= 8` is out of the 1..=256 range, so it never
3834 // applies here.
3835 if phase == 7 {
3836 self.inc_hori_v();
3837 }
3838 // Dot 256: vertical-V increment (with the 29→0 wrap-and-flip quirk).
3839 if dot == 256 {
3840 self.inc_vert_v();
3841 }
3842
3843 // Pixel emission + BG shift. The shift gate `render_line && rendering`
3844 // is `true` throughout the covered window.
3845 self.emit_pixel();
3846 self.shift_bg();
3847 }
3848
3849 // ------------------------------------------------------------------
3850 // Background fetch + shift + increment helpers.
3851 // ------------------------------------------------------------------
3852
3853 /// Fetch the nametable byte for the current `v`. Address: `$2000 |
3854 /// (v & 0x0FFF)`.
3855 ///
3856 /// MMC5 vertical split-screen: at the boundary of each 8-dot BG fetch
3857 /// group, the mapper is consulted via `bus.bg_split_state(...)`. If
3858 /// the current tile column falls within the alt region, the returned
3859 /// state supplies the synthesized NT / AT addresses, the alt fine-Y,
3860 /// and the 4 KiB CHR bank index. We latch it onto `bg_split_latch` for
3861 /// consumption by AT / BG-lo / BG-hi within the same fetch group.
3862 #[allow(clippy::cast_sign_loss)]
3863 #[inline]
3864 fn fetch_nt<B: PpuBus>(&mut self, bus: &mut B) {
3865 // Compute the (scanline_y, coarse_x) the alt region would be sampled
3866 // at. The pre-render line passes 0 (the alt region only renders on
3867 // visible lines, but the query is benign for pre-render).
3868 let scanline_y = if self.scanline < 0 {
3869 0
3870 } else {
3871 self.scanline as u16
3872 };
3873 let coarse_x = self.v & 0x001F;
3874 // NOTE (v2.0.3 / ADR 0030, Option 1): the MMC5 vertical-split query stays
3875 // HERE at the read dot — its `split_chr_bank_latch` side effect (which
3876 // `nametable_fetch`/`chr_offset` read) is mapper-observable, and moving it
3877 // one dot earlier to a phase-0 ALE shifts the Uchuu Keibitai SDF split
3878 // rendering. Consequently the NT fetch's octal-latch load co-locates with
3879 // its read (via `ale_splice`'s not-armed path below) rather than a phase-0
3880 // ALE; the AT / pattern fetches ARE true two-dot ALEs. See the plan.
3881 self.bg_split_latch = bus.bg_split_state(scanline_y, coarse_x);
3882
3883 let nt_addr = if let Some(split) = self.bg_split_latch {
3884 split.nt_addr
3885 } else {
3886 0x2000 | (self.v & 0x0FFF)
3887 };
3888 // v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE read half. For the common
3889 // (non-split) case the phase-0 NT ALE already drove the plain address and
3890 // loaded `octal_latch`, so `ale_splice` takes its armed path and the read
3891 // address is the true multiplexed splice `(address_bus & 0x3F00) |
3892 // octal_latch` — transparent for a coherent fetch, and the divergence
3893 // point for the delayed-`CopyV` "Hybrid Addresses" corruption. For an
3894 // MMC5-split fetch the phase-0 ALE drove the PLAIN address (the split
3895 // query lives HERE for its mapper-observable side effect), so disarm and
3896 // read `split.nt_addr` co-located instead — byte-identical to a coherent
3897 // fetch.
3898 let nt_addr = {
3899 if self.bg_split_latch.is_some() {
3900 self.ale_armed = false;
3901 }
3902 self.ale_splice(nt_addr)
3903 };
3904 self.nt_latch = self.read_vram(bus, nt_addr);
3905 // v2.3.2 "Lucid" — capture the address this tile's number came from. The
3906 // SPLICED address, i.e. the one actually driven, so a hybrid-address
3907 // corruption shows the address the hardware really read rather than the
3908 // one it meant to. Telemetry only.
3909 #[cfg(feature = "debug-hooks")]
3910 {
3911 self.prov_nt_pending = nt_addr;
3912 }
3913 // Data phase: drive the byte just read back onto the multiplexed bus's low
3914 // 8 bits (AD7-0). Behavior-neutral (the next fetch's ALE overwrites it).
3915 self.ale_drive_data(self.nt_latch);
3916 // Latch any per-tile extended-attribute info (MMC5 ExGrafix). Skip
3917 // when split is active: the alt region uses standard 4-bit AT
3918 // semantics, not ExGrafix.
3919 self.ex_attr_latch = if self.bg_split_latch.is_some() {
3920 None
3921 } else {
3922 bus.peek_ex_attribute(self.v)
3923 };
3924 }
3925
3926 /// Fetch the attribute byte for the current `v`. Address:
3927 /// `$23C0 | (v & 0x0C00) | ((v >> 4) & 0x38) | ((v >> 2) & 0x07)`.
3928 #[inline]
3929 fn fetch_at<B: PpuBus>(&mut self, bus: &mut B) {
3930 // Split active: use the alt AT address and recover coarse-X / coarse-Y
3931 // from the latched split state's NT address (where coarse-X = bits
3932 // 0..=4, coarse-Y = bits 5..=9).
3933 if let Some(split) = self.bg_split_latch {
3934 let at_addr = split.at_addr;
3935 // v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE read half (split AT path):
3936 // splice / consume the ALE arm so it can't leak to the next fetch.
3937 let at_addr = self.ale_splice(at_addr);
3938 let byte = self.read_vram(bus, at_addr);
3939 // v2.3.2 "Lucid" — the split's own attribute address, which the
3940 // standard `$23C0 | ...` arithmetic cannot reproduce. This branch is
3941 // exactly why the record carries `at` instead of deriving it.
3942 #[cfg(feature = "debug-hooks")]
3943 {
3944 self.prov_at_pending = at_addr;
3945 }
3946 self.ale_drive_data(byte);
3947 let coarse_x = (split.nt_addr & 0x001F) as u8;
3948 let coarse_y = ((split.nt_addr >> 5) & 0x001F) as u8;
3949 let shift = ((coarse_y & 0x02) << 1) | (coarse_x & 0x02);
3950 self.at_latch = (byte >> shift) & 0x03;
3951 return;
3952 }
3953 let v = self.v;
3954 let at_addr = 0x23C0 | (v & 0x0C00) | ((v >> 4) & 0x38) | ((v >> 2) & 0x07);
3955 // v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE read half (normal AT path).
3956 let at_addr = self.ale_splice(at_addr);
3957 let byte = self.read_vram(bus, at_addr);
3958 // v2.3.2 "Lucid" — see the split branch above.
3959 #[cfg(feature = "debug-hooks")]
3960 {
3961 self.prov_at_pending = at_addr;
3962 }
3963 self.ale_drive_data(byte);
3964 // Pick the 2-bit attribute based on coarse-X[1] and coarse-Y[1].
3965 let coarse_x = (v & 0x1F) as u8;
3966 let coarse_y = ((v >> 5) & 0x1F) as u8;
3967 let shift = ((coarse_y & 0x02) << 1) | (coarse_x & 0x02);
3968 let standard_palette = (byte >> shift) & 0x03;
3969 // ExGrafix override: replace the 2-bit palette with the per-tile
3970 // value latched at NT-fetch time.
3971 self.at_latch = self
3972 .ex_attr_latch
3973 .map_or(standard_palette, |ex| ex.palette & 0x03);
3974 }
3975
3976 /// Fetch BG pattern low byte for the current `nt_latch` + fine-Y of `v`.
3977 ///
3978 /// In MMC5 `ExGrafix` mode the mapper has internally latched a per-tile
3979 /// 4 KiB CHR bank from the most recent `peek_ex_attribute` call; it
3980 /// will resolve this `addr` against that bank rather than the standard
3981 /// BG bank registers. No address-bus rerouting required.
3982 ///
3983 /// In MMC5 vertical split-screen mode the mapper has likewise latched
3984 /// the `$5202` 4 KiB CHR bank from the most recent `bg_split_state`
3985 /// call, and the alt fine-Y replaces `v`'s fine-Y.
3986 #[inline]
3987 fn fetch_bg_lo<B: PpuBus>(&mut self, bus: &mut B) {
3988 let bg_table = u16::from(self.ctrl.contains(PpuCtrl::BG_PATTERN_HIGH)) << 12;
3989 let fine_y = self
3990 .bg_split_latch
3991 .map_or((self.v >> 12) & 0x07, |s| u16::from(s.fine_y) & 0x07);
3992 let addr = bg_table | (u16::from(self.nt_latch) << 4) | fine_y;
3993 self.observe_a12_addr(bus, addr);
3994 // v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE read half: A12 above stays on the
3995 // INTENDED `addr`; only the DATA read address goes through the ALE splice
3996 // (stale-latch "ALE + Read"). `addr` itself is preserved for the hd-pack
3997 // tile-base latch below.
3998 let read_addr = self.ale_splice(addr);
3999 self.bg_lo_latch = self.read_vram(bus, read_addr);
4000 // v2.3.2 "Lucid" — the pattern ROW address (fine-Y kept, unlike the
4001 // `hd-pack` latch below which masks it off to get the 16-byte tile base):
4002 // provenance answers "which CHR byte fed THIS pixel", which is a row, not
4003 // a tile. The SPLICED address again, so a hybrid-address corruption shows
4004 // what was really read.
4005 #[cfg(feature = "debug-hooks")]
4006 {
4007 self.prov_bg_latch = ProvBgAddrs {
4008 nt: self.prov_nt_pending,
4009 at: self.prov_at_pending,
4010 pattern: read_addr,
4011 };
4012 }
4013 self.ale_drive_data(self.bg_lo_latch);
4014 // v1.2.0 C3 (hd-pack): latch the 16-byte tile base (fine-Y masked off)
4015 // for this fetch group. Promoted into the `hd_bg_addr_*` queue at the
4016 // next shifter reload so it tracks the BG pattern shifters tile-for-tile.
4017 // Output-only; no new VRAM read, no A12.
4018 #[cfg(feature = "hd-pack")]
4019 {
4020 self.hd_bg_addr_latch = addr & 0x1FF0;
4021 // CHR-ROM absolute tile index (offset/16), or the CHR-RAM sentinel.
4022 self.hd_bg_idx_latch = bus.chr_phys(addr).map_or(HD_CHR_RAM, |o| o / 16);
4023 }
4024 }
4025
4026 /// Fetch BG pattern high byte (offset +8 from the low fetch).
4027 #[inline]
4028 fn fetch_bg_hi<B: PpuBus>(&mut self, bus: &mut B) {
4029 let bg_table = u16::from(self.ctrl.contains(PpuCtrl::BG_PATTERN_HIGH)) << 12;
4030 let fine_y = self
4031 .bg_split_latch
4032 .map_or((self.v >> 12) & 0x07, |s| u16::from(s.fine_y) & 0x07);
4033 let addr = bg_table | (u16::from(self.nt_latch) << 4) | 0x08 | fine_y;
4034 self.observe_a12_addr(bus, addr);
4035 // v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE read half (see `fetch_bg_lo`):
4036 // A12 above stays on the INTENDED `addr`; only the DATA read address goes
4037 // through the ALE splice (stale-latch "ALE + Read").
4038 let read_addr = self.ale_splice(addr);
4039 self.bg_hi_latch = self.read_vram(bus, read_addr);
4040 self.ale_drive_data(self.bg_hi_latch);
4041 }
4042
4043 // === v2.0.3 (ADR 0030, Option 1) — 2-cycle-ALE fetch model ===============
4044 //
4045 // A genuine two-dot VRAM transaction. The attribute + pattern fetches' EVEN
4046 // dot (the ALE half, phases 2/4/6) drives the full 14-bit address onto
4047 // `address_bus` and captures its low byte into `octal_latch` via
4048 // [`Self::drive_bus`]; the following ODD dot (the read half, phases 3/5/7 —
4049 // the existing `fetch_*`) resolves the effective address through
4050 // [`Self::ale_splice`] and drives the DATA byte back onto the low bus via
4051 // [`Self::ale_drive_data`]. For a coherent fetch the address the ALE drove
4052 // equals the address the read would compute (`v` is constant across the 8-dot
4053 // group's phases 0..=6; the coarse-X increment is at phase 7 AFTER the read),
4054 // so the splice returns the intended address and this is behavior-neutral.
4055 //
4056 // The NAMETABLE fetch is the exception: its MMC5 vertical-split query
4057 // (`bg_split_state`, whose `split_chr_bank_latch` side effect
4058 // `nametable_fetch`/`chr_offset` read) is mapper-observable and must fire at
4059 // the read dot (phase 1), so the NT octal-latch load co-locates with the read
4060 // via `ale_splice`'s not-armed path (there is no phase-0 NT ALE); the NT ALE
4061 // (`ale_drive_nt`) still drives the plain address so the latch naturally
4062 // carries the one-tile-ahead NT-low the "Hybrid Addresses" corruption needs.
4063
4064 /// ALE half of the nametable fetch (phase 0). Drives the PLAIN nametable
4065 /// address `0x2000 | (v & 0x0FFF)` and loads the octal latch with its low
4066 /// byte — a true two-dot ALE for the common (non-split) case, which is what
4067 /// lets `octal_latch` naturally carry the one-tile-ahead NT-low the "Hybrid
4068 /// Addresses" corruption needs. The MMC5-split query is deferred to the read
4069 /// dot (phase 1, `fetch_nt`); when it turns out split-active, `fetch_nt`
4070 /// disarms this ALE and reads the synthesized `split.nt_addr` co-located.
4071 const fn ale_drive_nt(&mut self) {
4072 let nt_addr = 0x2000 | (self.v & 0x0FFF);
4073 self.drive_bus(nt_addr, false);
4074 }
4075
4076 /// ALE half of the attribute fetch (phase 2). Uses the `bg_split_latch`
4077 /// already set by the nametable read at phase 1.
4078 const fn ale_drive_at(&mut self) {
4079 let at_addr = if let Some(split) = self.bg_split_latch {
4080 split.at_addr
4081 } else {
4082 let v = self.v;
4083 0x23C0 | (v & 0x0C00) | ((v >> 4) & 0x38) | ((v >> 2) & 0x07)
4084 };
4085 self.drive_bus(at_addr, false);
4086 }
4087
4088 /// ALE half of the BG pattern-low fetch (phase 4). Uses `nt_latch` (set at
4089 /// the phase-1 nametable read) and `v`'s fine-Y (or the split fine-Y).
4090 fn ale_drive_bg_lo(&mut self) {
4091 let bg_table = u16::from(self.ctrl.contains(PpuCtrl::BG_PATTERN_HIGH)) << 12;
4092 let fine_y = self
4093 .bg_split_latch
4094 .map_or((self.v >> 12) & 0x07, |s| u16::from(s.fine_y) & 0x07);
4095 let addr = bg_table | (u16::from(self.nt_latch) << 4) | fine_y;
4096 self.drive_bus(addr, true);
4097 }
4098
4099 /// ALE half of the BG pattern-high fetch (phase 6). Same as the low plane
4100 /// with bit 3 set (the +8 byte offset).
4101 fn ale_drive_bg_hi(&mut self) {
4102 let bg_table = u16::from(self.ctrl.contains(PpuCtrl::BG_PATTERN_HIGH)) << 12;
4103 let fine_y = self
4104 .bg_split_latch
4105 .map_or((self.v >> 12) & 0x07, |s| u16::from(s.fine_y) & 0x07);
4106 let addr = bg_table | (u16::from(self.nt_latch) << 4) | 0x08 | fine_y;
4107 self.drive_bus(addr, true);
4108 }
4109
4110 /// Drive a full 14-bit fetch address onto the multiplexed bus (the ALE
4111 /// half): `address_bus` takes the whole address and the 74LS373 octal latch
4112 /// captures A7-A0. Arms `ale_armed` for the matching read half.
4113 ///
4114 /// `is_pattern` marks the two BG-pattern ALEs (phases 4/6). While the "ALE +
4115 /// Read" freeze (`pattern_latch_stale`) is pending — a `$2007`-read ALE
4116 /// overlapped the fetch cadence and froze `octal_latch` on the read's DATA
4117 /// byte — the latch is NOT reloaded (the frozen DATA byte survives across any
4118 /// intervening ALE); the first pattern ALE afterwards consumes the flag, so
4119 /// its read splices `(PAR high 6):(stale DATA low 8)` = `$0FFF`.
4120 const fn drive_bus(&mut self, addr: u16, is_pattern: bool) {
4121 self.address_bus = addr;
4122 if self.pattern_latch_stale {
4123 if is_pattern {
4124 self.pattern_latch_stale = false;
4125 }
4126 } else {
4127 self.octal_latch = (addr & 0xFF) as u8;
4128 }
4129 self.ale_armed = true;
4130 }
4131
4132 /// Resolve a fetch's effective read address through the multiplexed bus (the
4133 /// read half). When a real ALE preceded this read (`ale_armed`), the address
4134 /// is the splice of the ALE-driven high 6 bits with the latched low 8:
4135 /// `(address_bus & 0x3F00) | octal_latch` — transparent for a coherent fetch
4136 /// (Phase 1), the divergence point for the Phase-3 corruptions. With NO
4137 /// preceding ALE (the dot-337-340 garbage nametable fetches), drive + latch
4138 /// `intended` in place so the read stays behavior-neutral.
4139 #[allow(clippy::missing_const_for_fn)] // u16::from is not yet const-stable
4140 fn ale_splice(&mut self, intended: u16) -> u16 {
4141 if self.ale_armed {
4142 self.ale_armed = false;
4143 // v2.3.6 — high 6 bits from `intended` (recomputed from the LIVE `v` at
4144 // the read dot), not from the ALE-time `address_bus` snapshot. Upstream
4145 // AccuracyCoin's commentary was rewritten to say the address bus is
4146 // driven EVERY ppu cycle and its upper 6 bits track `v`, so the hybrid
4147 // address is what a continuously-driven bus produces when `v` moves
4148 // between a fetch's ALE half and its read half. Behaviour-neutral for a
4149 // coherent fetch: `v` unchanged => `intended` == the driven address.
4150 let effective = (intended & 0x3F00) | u16::from(self.octal_latch);
4151 // Diagnostic: record any read whose spliced effective address diverges
4152 // from the intended one (the two corruptions) for the TriCNES per-dot
4153 // cross-diff. `push` self-filters to scanlines 2-5, so this is cheap.
4154 if effective != intended {
4155 octal_trace::push(
4156 if intended >= 0x2000 {
4157 octal_trace::K_HYBRID
4158 } else {
4159 octal_trace::K_STALE
4160 },
4161 self.frame,
4162 self.scanline,
4163 self.dot,
4164 u32::from(effective),
4165 );
4166 }
4167 effective
4168 } else {
4169 self.address_bus = intended;
4170 self.octal_latch = (intended & 0xFF) as u8;
4171 intended
4172 }
4173 }
4174
4175 /// Data half of a VRAM access: drive the byte just read back onto the
4176 /// multiplexed bus's low 8 bits (AD7-0). `octal_latch` is NOT refreshed here
4177 /// (the 74LS373 latch holds the ADDRESS low from the ALE) — that retention is
4178 /// what a `$2007`-read ALE overlap exploits (the "ALE + Read" corruption). It
4179 /// is otherwise transparent: the next fetch's ALE overwrites `address_bus`
4180 /// wholesale.
4181 #[allow(clippy::missing_const_for_fn)] // u16::from is not yet const-stable
4182 fn ale_drive_data(&mut self, data: u8) {
4183 self.address_bus = (self.address_bus & 0xFF00) | u16::from(data);
4184 }
4185
4186 /// Shift the BG pattern and attribute shift registers by one bit.
4187 ///
4188 /// All four registers are 16-bit and advance in lockstep so the
4189 /// attribute palette tracks the same tile column as the pattern bits.
4190 const fn shift_bg(&mut self) {
4191 self.bg_shift_lo <<= 1;
4192 self.bg_shift_hi <<= 1;
4193 self.at_shift_lo <<= 1;
4194 self.at_shift_hi <<= 1;
4195 // v2.0 Phase 6 (mc-ppu-subpos): BG-shifter SERIAL-IN. Per nesdev "PPU
4196 // signals", the bit shifted into the pattern shifters from the right is
4197 // a constant 0 for the LOW plane and a constant 1 for the HIGH plane.
4198 // It is normally invisible: the dot%8==1 reload overwrites bits 0-7
4199 // every 8 shifts, so the injected '1' never reaches the output bits
4200 // 8-15 before being washed (=> framebuffer byte-identical for normal
4201 // rendering, oracle-safe). It surfaces ONLY when a precisely-timed
4202 // `$2001` render-toggle SKIPS a reload while shifting continues, drawing
4203 // opaque BG pixels on an all-translucent nametable (AccuracyCoin "BG
4204 // Serial In"). The attribute shifters have no serial-in (the test only
4205 // needs a non-transparent pattern bit, not a specific palette).
4206 {
4207 self.bg_shift_hi |= 1;
4208 }
4209 }
4210
4211 /// Pre-fetch (dots 328 / 336) byte shift: advance all four BG shift
4212 /// registers by 8 bits in lockstep, moving the just-reloaded tile
4213 /// data from bits 0-7 to bits 8-15 and clearing bits 0-7 for the next
4214 /// reload. This substitutes for the per-cycle `shift_bg` that does not
4215 /// run during the dots 321-336 pre-fetch region. The attribute
4216 /// registers MUST shift identically to the pattern registers here —
4217 /// omitting them was the 086ce4d left-edge palette regression.
4218 #[inline]
4219 const fn prefetch_shift_bg_regs(&mut self) {
4220 self.bg_shift_lo <<= 8;
4221 self.bg_shift_hi <<= 8;
4222 self.at_shift_lo <<= 8;
4223 self.at_shift_hi <<= 8;
4224 // v1.2.0 C3 (hd-pack): the `<<= 8` promotes the low (next) tile into the
4225 // high (displayed) byte — mirror the address queue. Telemetry only.
4226 #[cfg(feature = "hd-pack")]
4227 {
4228 self.hd_bg_addr_cur = self.hd_bg_addr_next;
4229 self.hd_bg_idx_cur = self.hd_bg_idx_next;
4230 }
4231 // v2.3.2 "Lucid": same promotion for the provenance cascade.
4232 //
4233 // A/B'd rather than assumed. For the VISIBLE region this is redundant —
4234 // every displayed tile passes through a `reload_bg_shift_regs` that
4235 // overwrites `cur` from `next` anyway, and the provenance test passes
4236 // identically with this removed. It is kept because this function is also
4237 // called on the rendering-DISABLE edge (dots 329-336) to complete a
4238 // frozen group's pending shift, and there pixels can be emitted from the
4239 // shifters before any further reload — so mirroring the shift is what
4240 // keeps the reported tile matching the one actually on screen.
4241 #[cfg(feature = "debug-hooks")]
4242 {
4243 self.prov_bg_cur = self.prov_bg_next;
4244 }
4245 }
4246
4247 /// Reload the low bytes of the BG pattern and attribute shift
4248 /// registers from the latched fetch bytes.
4249 ///
4250 /// The 2-bit attribute is constant across all 8 pixels of a tile, so
4251 /// each attribute bit is expanded to a full `0xFF`/`0x00` byte into
4252 /// bits 0-7 — the same low-byte slot the pattern bytes occupy. This
4253 /// keeps the attribute shifter bit-for-bit aligned with the pattern
4254 /// shifters through both the per-cycle shifts (dots 1-256) and the
4255 /// pre-fetch `<<= 8` (dots 328 / 336).
4256 #[inline]
4257 const fn reload_bg_shift_regs(&mut self) {
4258 self.bg_shift_lo = (self.bg_shift_lo & 0xFF00) | self.bg_lo_latch as u16;
4259 self.bg_shift_hi = (self.bg_shift_hi & 0xFF00) | self.bg_hi_latch as u16;
4260 let at_lo = if (self.at_latch & 0x01) != 0 {
4261 0xFF
4262 } else {
4263 0x00
4264 };
4265 let at_hi = if (self.at_latch & 0x02) != 0 {
4266 0xFF
4267 } else {
4268 0x00
4269 };
4270 self.at_shift_lo = (self.at_shift_lo & 0xFF00) | at_lo;
4271 self.at_shift_hi = (self.at_shift_hi & 0xFF00) | at_hi;
4272 // v1.2.0 C3 (hd-pack): mirror the pattern reload — the prior `next`
4273 // tile is now in the high byte (displayed), and the freshly-latched
4274 // tile fills the low byte. Telemetry only; no state effect.
4275 #[cfg(feature = "hd-pack")]
4276 {
4277 self.hd_bg_addr_cur = self.hd_bg_addr_next;
4278 self.hd_bg_addr_next = self.hd_bg_addr_latch;
4279 self.hd_bg_idx_cur = self.hd_bg_idx_next;
4280 self.hd_bg_idx_next = self.hd_bg_idx_latch;
4281 }
4282 // v2.3.2 "Lucid": same promotion for the provenance address cascade —
4283 // one struct copy per stage, so the three addresses cannot drift apart.
4284 #[cfg(feature = "debug-hooks")]
4285 {
4286 self.prov_bg_cur = self.prov_bg_next;
4287 self.prov_bg_next = self.prov_bg_latch;
4288 }
4289 }
4290
4291 /// Increment coarse X with nametable-X wrap.
4292 ///
4293 /// Note: this is an internal loopy-register increment. It does NOT
4294 /// drive the PPU address bus, so it must not emit A12 transitions —
4295 /// the address bus stays on the last-fetched address (BG-high) until
4296 /// the next fetch. An earlier version of this code called
4297 /// `observe_a12` here, which spuriously interpreted `v`'s fine-Y bit
4298 /// 0 as A12 and produced ~16 false A12 rising edges per scanline,
4299 /// breaking MMC3's IRQ count (which expects exactly 1 rise per
4300 /// rendered scanline, at PPU dot ~260, with standard pattern-table
4301 /// layout).
4302 const fn inc_hori_v(&mut self) {
4303 if (self.v & 0x001F) == 31 {
4304 self.v &= !0x001F;
4305 self.v ^= 0x0400;
4306 } else {
4307 self.v += 1;
4308 }
4309 }
4310
4311 /// Increment fine Y, with the 29->0 wrap-and-flip-nametable-Y quirk.
4312 ///
4313 /// Same A12 caveat as [`Self::inc_hori_v`]: this is an internal
4314 /// register increment, not an address-bus driver.
4315 const fn inc_vert_v(&mut self) {
4316 if (self.v & 0x7000) == 0x7000 {
4317 self.v &= !0x7000;
4318 let mut y = (self.v & 0x03E0) >> 5;
4319 if y == 29 {
4320 y = 0;
4321 self.v ^= 0x0800;
4322 } else if y == 31 {
4323 y = 0;
4324 } else {
4325 y += 1;
4326 }
4327 self.v = (self.v & !0x03E0) | (y << 5);
4328 } else {
4329 self.v += 0x1000;
4330 }
4331 }
4332
4333 /// Copy horizontal bits of `t` into `v` (bits 0-4 + 10).
4334 const fn copy_hori_t_to_v(&mut self) {
4335 self.v = (self.v & !0x041F) | (self.t & 0x041F);
4336 }
4337
4338 /// Copy vertical bits of `t` into `v` (bits 5-9 + 11-14).
4339 const fn copy_vert_t_to_v(&mut self) {
4340 self.v = (self.v & !0x7BE0) | (self.t & 0x7BE0);
4341 }
4342
4343 // ------------------------------------------------------------------
4344 // Pixel emission.
4345 // ------------------------------------------------------------------
4346
4347 /// Emit one pixel into the framebuffer at the current `(scanline, dot)`.
4348 #[allow(clippy::cast_sign_loss)]
4349 #[allow(clippy::too_many_lines)] // + the ppu-sprite-shifter-counter X-counter/shift loop
4350 fn emit_pixel(&mut self) {
4351 let pixel_x = self.dot - 1;
4352 let pixel_y = self.scanline as u16; // already validated >= 0 by caller
4353 let fx = self.x;
4354 // BG pixel (bits 0-1 = pattern, bits 2-3 = palette)
4355 let (bg_idx, bg_pal) = if self.mask.contains(PpuMask::SHOW_BG)
4356 && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_BG_LEFT))
4357 {
4358 let mask = 0x8000u16 >> fx;
4359 let p0 = u8::from((self.bg_shift_lo & mask) != 0);
4360 let p1 = u8::from((self.bg_shift_hi & mask) != 0);
4361 let idx = (p1 << 1) | p0;
4362 let a0 = u8::from((self.at_shift_lo & mask) != 0);
4363 let a1 = u8::from((self.at_shift_hi & mask) != 0);
4364 (idx, (a1 << 1) | a0)
4365 } else {
4366 (0, 0)
4367 };
4368
4369 // Sprite pixel evaluation (Sprint 2-3).
4370 let mut spr_idx: u8 = 0;
4371 let mut spr_pal: u8 = 0;
4372 let mut spr_priority_front = false;
4373 let mut spr_zero_pixel = false;
4374 #[cfg(any(feature = "hd-pack", feature = "debug-hooks"))]
4375 let mut spr_slot: usize = 0;
4376 // v1.8.9 — every opaque sprite covering this pixel (slot indices), for the
4377 // HD-pack multi-sprite conditions. Collected but never consulted by the
4378 // winner logic below, so the framebuffer stays byte-identical.
4379 #[cfg(feature = "hd-pack")]
4380 let mut hd_sprites: [usize; 4] = [0; 4];
4381 #[cfg(feature = "hd-pack")]
4382 let mut hd_spr_n: usize = 0;
4383 if self.mask.contains(PpuMask::SHOW_SPRITE)
4384 && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT))
4385 {
4386 for i in 0..self.spr_count as usize {
4387 // v2.0 (ppu-sprite-shifter-counter): a sprite emits when its
4388 // X-counter is 0 OR it is in the persistent halted state. The
4389 // `spr_x == 0` term keeps the legacy px-0 emit timing (an X=0
4390 // sprite re-armed at dot 339 must emit at px 0, not px 1 — else a
4391 // spurious Sprite-0-Hit test-8 hit), while `spr_halted` carries
4392 // Stale Sprite t5/6. Default build: the legacy `spr_x == 0`.
4393 let emit_active = self.spr_x[i] == 0 || self.spr_halted[i];
4394 if !emit_active {
4395 continue;
4396 }
4397 let lo = u8::from((self.spr_shift_lo[i] & 0x80) != 0);
4398 let hi = u8::from((self.spr_shift_hi[i] & 0x80) != 0);
4399 let val = (hi << 1) | lo;
4400 if val == 0 {
4401 continue;
4402 }
4403 // hd-pack: record every opaque sprite covering this pixel.
4404 #[cfg(feature = "hd-pack")]
4405 if hd_spr_n < 4 {
4406 hd_sprites[hd_spr_n] = i;
4407 hd_spr_n += 1;
4408 }
4409 // The first opaque sprite (priority order) is the VISIBLE winner;
4410 // `spr_idx == 0` gates it so only the first sets the render state.
4411 if spr_idx == 0 {
4412 spr_idx = val;
4413 spr_pal = self.spr_attr[i] & 0x03;
4414 spr_priority_front = (self.spr_attr[i] & 0x20) == 0;
4415 #[cfg(any(feature = "hd-pack", feature = "debug-hooks"))]
4416 {
4417 spr_slot = i;
4418 }
4419 if i == 0 && self.spr_zero_in_line {
4420 spr_zero_pixel = true;
4421 }
4422 }
4423 // Default build stops at the winner (byte-identical); the hd-pack
4424 // build keeps scanning to collect the hidden sprites above (which
4425 // never touch the winner state, so the framebuffer is unchanged).
4426 #[cfg(not(feature = "hd-pack"))]
4427 break;
4428 }
4429 }
4430
4431 // Combine BG + sprite per priority.
4432 //
4433 // v2.3.2 "Lucid": the priority chain now yields the palette ADDRESS and
4434 // the single `read_palette` happens after it, instead of each arm reading
4435 // inline. Semantically identical, and it makes the address available to
4436 // the provenance record below — so the panel reports the exact `$3Fxx`
4437 // this pixel came from, including the `$3F10` family pre-mirroring and
4438 // the rendering-disabled backdrop-override address, rather than
4439 // re-deriving it from the priority result and getting the corners wrong.
4440 let pal_addr: u16 = if bg_idx == 0 && spr_idx == 0 {
4441 // Universal background ($3F00) — EXCEPT the palette backdrop-override
4442 // (F1.1): with rendering DISABLED and the VRAM address `v` pointing
4443 // into palette space ($3F00-$3FFF), the palette's shared address line
4444 // is driven by `v`, so hardware outputs the color at `v & 0x1F`
4445 // INSTEAD of the backdrop (`NESdev` "PPU palettes"; Mesen2 `NesPpu.cpp`
4446 // / ares output stage). This is a DISPLAY artifact only — palette RAM
4447 // is not mutated. It cannot fire while rendering is enabled: there
4448 // the fetch pipeline owns `v` and this branch means a transparent
4449 // pixel, which is the genuine backdrop. `read_palette` applies the
4450 // $10/$14/$18/$1C mirror + greyscale, so the override is mirror- and
4451 // greyscale-correct with no extra handling.
4452 if !self.mask.rendering_enabled() && (self.v & 0x3F00) == 0x3F00 {
4453 0x3F00 | (self.v & 0x1F)
4454 } else {
4455 0x3F00
4456 }
4457 } else if bg_idx == 0 {
4458 0x3F10 | (u16::from(spr_pal) << 2) | u16::from(spr_idx)
4459 } else if spr_idx == 0 {
4460 0x3F00 | (u16::from(bg_pal) << 2) | u16::from(bg_idx)
4461 } else {
4462 // Both opaque. Sprite-0 hit detection (constraints per nesdev).
4463 if spr_zero_pixel
4464 && pixel_x < 255
4465 && !(pixel_x < 8
4466 && (!self.mask.contains(PpuMask::SHOW_BG_LEFT)
4467 || !self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)))
4468 {
4469 self.status.insert(PpuStatus::SPRITE_ZERO_HIT);
4470 }
4471 if spr_priority_front {
4472 0x3F10 | (u16::from(spr_pal) << 2) | u16::from(spr_idx)
4473 } else {
4474 0x3F00 | (u16::from(bg_pal) << 2) | u16::from(bg_idx)
4475 }
4476 };
4477 // One read at the end instead of one per arm. `read_palette` is a pure
4478 // read (the greyscale mask it consults is not touched by the sprite-0-hit
4479 // insert above), so hoisting it out of the branches is behaviour-
4480 // preserving as well as what clippy's `branches_sharing_code` wants.
4481 let final_idx = self.read_palette(pal_addr) & 0x3F;
4482
4483 // Write RGBA8 to framebuffer.
4484 let off = ((pixel_y as usize) * 256 + pixel_x as usize) * 4;
4485 // v2.8.0 Phase 4 — route through the precomputed
4486 // `(emphasis << 6) | color` lookup (built from the same pure
4487 // `palette_color_to_rgba`, so byte-identical to the old per-pixel
4488 // call for both the 2C02 composite default and the Vs./PC10 RGB
4489 // palettes) and store all four bytes with one bounds-checked slice
4490 // copy instead of four indexed stores.
4491 let emph = usize::from((self.mask.bits() >> 5) & 0x07);
4492 let lut_idx = (emph << 6) | usize::from(final_idx);
4493 let rgba = self.rgba_lut[lut_idx];
4494 self.framebuffer[off..off + 4].copy_from_slice(&rgba);
4495 // Parallel palette-index output for the `NES_NTSC` composite filter
4496 // (T-110-A1). Same `(emphasis << 6) | colour` value, in index space;
4497 // `off` is the RGBA byte offset, so `off >> 2` is the pixel index.
4498 // NOTE (v2.3.1 G4): making this store conditional on a consumer wanting
4499 // it was measured by deleting it outright — the ceiling any opt-in gate
4500 // could reach — and the ceiling is ZERO on the shipped configuration.
4501 // `perf` attributes ~0.78% to this line, but a line's sample share is not
4502 // its marginal cost: this is a sequential `u16` store the store buffer
4503 // absorbs off the critical path, so removing it frees nothing and the
4504 // samples simply redistribute. Not worth the correctness hazard of
4505 // gating a buffer the NTSC filter, the mobile API, `fast_dotloop_diff`
4506 // and a unit test all read. See `docs/performance.md`.
4507 self.index_framebuffer[off >> 2] = lut_idx as u16;
4508
4509 // v1.2.0 C3 (hd-pack): record the CHR tile that produced this pixel,
4510 // mirroring the BG-vs-sprite priority decision above. Output-only; this
4511 // reads only already-computed local state, so the framebuffer and all
4512 // timing are byte-identical whether the feature is on or off.
4513 #[cfg(feature = "hd-pack")]
4514 {
4515 // A pixel shows the SPRITE iff the sprite pixel is opaque AND
4516 // (the BG pixel is transparent OR the sprite has front priority) —
4517 // the same condition the `final_idx` priority match encodes.
4518 let shows_sprite = spr_idx != 0 && (bg_idx == 0 || spr_priority_front);
4519 // Multi-sprite telemetry: the identity of every opaque sprite covering
4520 // this pixel (front-to-back), for `spriteAtPosition` / `spriteNearby`.
4521 let hd_sprite_list: [HdSprite; 4] = {
4522 let mut arr = [HdSprite::default(); 4];
4523 for (k, slot) in hd_sprites.iter().take(hd_spr_n).enumerate() {
4524 arr[k] = HdSprite {
4525 chr_tile_index: self.hd_spr_idx[*slot],
4526 palette_colors: self.hd_sprite_palette_colors(self.spr_attr[*slot] & 0x03),
4527 };
4528 }
4529 arr
4530 };
4531 let hd_sprite_n = u8::try_from(hd_spr_n).unwrap_or(4);
4532 let rec = if shows_sprite {
4533 let attr = self.spr_attr[spr_slot];
4534 let flip_h = (attr & 0x40) != 0;
4535 // Column within the sprite (screen X minus the sprite's origin X),
4536 // then flip so the captured offset samples the UNFLIPPED
4537 // replacement directly (composite is flip-free).
4538 let col = pixel_x.wrapping_sub(u16::from(self.hd_spr_x[spr_slot])) & 7;
4539 let off_x = if flip_h { 7 - col } else { col };
4540 HdTileSource {
4541 chr_addr: self.hd_spr_addr[spr_slot],
4542 palette: spr_pal,
4543 is_sprite: true,
4544 flip_h,
4545 flip_v: (attr & 0x80) != 0,
4546 palette_colors: self.hd_sprite_palette_colors(spr_pal),
4547 offset_x: u8::try_from(off_x).unwrap_or(0),
4548 offset_y: self.hd_spr_off_y[spr_slot], // already flip-baked at fetch
4549 chr_tile_index: self.hd_spr_idx[spr_slot],
4550 color_mask: self.mask.bits() & 0xE1,
4551 sprites: hd_sprite_list,
4552 sprite_count: hd_sprite_n,
4553 }
4554 } else if bg_idx != 0 {
4555 // Fine-X picks which of the two shifter tiles this pixel shows +
4556 // the column within it (Mesen usePrev / OffsetX); fine-Y is the row.
4557 let pos = u16::from(fx) + (pixel_x & 7);
4558 let chr = if pos < 8 {
4559 self.hd_bg_addr_cur
4560 } else {
4561 self.hd_bg_addr_next
4562 };
4563 HdTileSource {
4564 chr_addr: chr,
4565 palette: bg_pal,
4566 is_sprite: false,
4567 flip_h: false,
4568 flip_v: false,
4569 palette_colors: self.hd_bg_palette_colors(bg_pal),
4570 offset_x: u8::try_from(pos & 7).unwrap_or(0),
4571 offset_y: u8::try_from((self.v >> 12) & 7).unwrap_or(0),
4572 chr_tile_index: if pos < 8 {
4573 self.hd_bg_idx_cur
4574 } else {
4575 self.hd_bg_idx_next
4576 },
4577 color_mask: self.mask.bits() & 0xE1,
4578 sprites: hd_sprite_list,
4579 sprite_count: hd_sprite_n,
4580 }
4581 } else {
4582 // Universal background — no tile to substitute.
4583 HdTileSource {
4584 chr_addr: HD_TILE_NONE,
4585 palette: 0,
4586 is_sprite: false,
4587 flip_h: false,
4588 flip_v: false,
4589 offset_x: 0,
4590 offset_y: 0,
4591 chr_tile_index: HD_CHR_RAM,
4592 palette_colors: 0,
4593 color_mask: 0,
4594 sprites: hd_sprite_list,
4595 sprite_count: hd_sprite_n,
4596 }
4597 };
4598 self.hd_tile_source[off >> 2] = rec;
4599 }
4600
4601 // v2.3.2 "Lucid" phase 2 — the per-pixel causal record.
4602 //
4603 // Guarded on a plain `bool` rather than `prov_frame.is_some()`: this runs
4604 // 61,440 times a frame in one of the two hottest functions in the
4605 // emulator, so the unarmed cost is one predicted branch on an already-hot
4606 // cache line instead of an `Option` discriminant behind a pointer chase.
4607 // Everything recorded is already computed above or carried in the
4608 // fetch-time cascade — no new VRAM reads, no new arithmetic on the
4609 // shipped path — so the framebuffer and all timing are byte-identical
4610 // whether provenance is armed or not.
4611 #[cfg(feature = "debug-hooks")]
4612 if self.prov_armed {
4613 use crate::provenance::{PATTERN_ADDR_NONE, PixelLayer, PixelProvenance};
4614 // The same condition `final_idx` encoded above: a sprite is visible
4615 // iff it is opaque AND (the BG is transparent OR it has front
4616 // priority). Re-deriving it here rather than threading a flag keeps
4617 // the shipped path free of a variable that only telemetry reads.
4618 let shows_sprite = spr_idx != 0 && (bg_idx == 0 || spr_priority_front);
4619 let layer = if shows_sprite {
4620 PixelLayer::Sprite
4621 } else if bg_idx != 0 {
4622 PixelLayer::Background
4623 } else {
4624 PixelLayer::Backdrop
4625 };
4626 let rec = PixelProvenance {
4627 scanline: self.scanline,
4628 dot: self.dot,
4629 layer,
4630 palette_addr: pal_addr,
4631 palette_index: u8::try_from(palette_index(pal_addr)).unwrap_or(0),
4632 color: final_idx,
4633 color_mask: self.mask.bits() & 0xE1,
4634 // The DISPLAYED tile's addresses, from the cascade — `v` has
4635 // already advanced two tiles past this pixel.
4636 nt_addr: self.prov_bg_cur.nt,
4637 at_addr: self.prov_bg_cur.at,
4638 pattern_addr: match layer {
4639 PixelLayer::Sprite => self.prov_spr_addr[spr_slot],
4640 PixelLayer::Background => self.prov_bg_cur.pattern,
4641 PixelLayer::Backdrop => PATTERN_ADDR_NONE,
4642 },
4643 bg_idx,
4644 bg_pal,
4645 spr_idx,
4646 spr_pal,
4647 sprite_slot: if shows_sprite {
4648 u8::try_from(spr_slot).unwrap_or(0)
4649 } else {
4650 crate::provenance::SPRITE_SLOT_NONE
4651 },
4652 sprite_front: spr_priority_front,
4653 sprite_zero: spr_zero_pixel,
4654 fine_x: fx,
4655 fine_y: u8::try_from((self.v >> 12) & 7).unwrap_or(0),
4656 };
4657 if let Some(frame) = self.prov_frame.as_mut() {
4658 frame.set(pixel_x as usize, pixel_y as usize, rec);
4659 }
4660 }
4661
4662 // Decrement sprite X-counters / shift sprite shift regs.
4663 //
4664 // v2.0 (ppu-sprite-shifter-counter): the X-COUNTER decrements every
4665 // visible dot regardless of rendering (Stale Sprite test 2 — forced
4666 // blank does NOT halt the counters), but the SHIFTER only advances while
4667 // rendering is ENABLED on the 1-PPU-dot-delayed gate (`rendering_enabled_
4668 // delayed`, the same gate `shift_bg` uses — test 3: the shifter PAUSES in
4669 // forced blank so a sprite's data survives a long blank and still draws
4670 // on re-enable). `spr_halted` is the persistent latch (set at counter==0
4671 // or across a disable; re-armed at dot 339) carrying Stale Sprite t5/6.
4672 // Default build: the legacy unconditional shift.
4673 for i in 0..self.spr_count as usize {
4674 if self.spr_halted[i] || self.spr_x[i] == 0 {
4675 // Halted / drawing: latch and shift while rendering is enabled.
4676 // The `spr_x == 0` term is load-bearing — a slot re-armed at dot
4677 // 339 with the counter already 0 must SHIFT this dot (not just
4678 // latch) to match the legacy `spr_x == 0 => shift` timing.
4679 self.spr_halted[i] = true;
4680 if self.rendering_enabled_delayed {
4681 self.spr_shift_lo[i] <<= 1;
4682 self.spr_shift_hi[i] <<= 1;
4683 }
4684 } else {
4685 // Counting: decrement every visible dot (forced blank does not
4686 // halt — test 2). On reaching 0, halt this tick.
4687 self.spr_x[i] -= 1;
4688 if self.spr_x[i] == 0 {
4689 self.spr_halted[i] = true;
4690 }
4691 }
4692 }
4693 }
4694
4695 // ------------------------------------------------------------------
4696 // Sprite evaluation + tile fetch.
4697 // ------------------------------------------------------------------
4698
4699 /// Per-PPU-dot sprite-evaluation FSM.
4700 ///
4701 /// Reproduces the 2C02's three-phase sprite-eval state machine across
4702 /// dots 1..=256 of every visible scanline and the pre-render line:
4703 ///
4704 /// - **Dot 0**: reset FSM working state.
4705 /// - **Dots 1..=64**: clear secondary OAM to `$FF`. One byte cleared
4706 /// every two dots (32 bytes over 64 dots). Reads of `$2004` during
4707 /// this phase return `$FF` on real hardware.
4708 /// - **Dots 65..=256**: 192 dots = 96 read/write pairs. Odd dots read
4709 /// a byte from primary OAM into a latch; even dots commit the latch
4710 /// into secondary OAM (when copying is enabled). The buggy `n+m`
4711 /// increment for overflow detection (when 8 sprites are already
4712 /// latched) matches the documented hardware quirk that
4713 /// `sprite_overflow_tests/4-Obscure` and `/5-Emulator` exercise.
4714 /// - **Dot 256**: commit `spr_count` and pre-clear unused slot
4715 /// rendering-side arrays so the pixel pipeline never emits stale
4716 /// sprite pixels.
4717 ///
4718 /// The actual per-slot pattern-table fetch (and its A12 transitions)
4719 /// happens later, in [`Self::fetch_sprite_tile`], unchanged. Sprite-
4720 /// tile fetches still dispatch at dots 260, 268, ..., 316 — preserving
4721 /// the canonical "241 A12 rises per NTSC frame" MMC3 IRQ count.
4722 /// v2.0 Tier 1.2 — value `$2004` returns while the screen is being drawn.
4723 ///
4724 /// Mirrors Mesen2 `NesPpu::ReadRam`'s `SpriteData` case
4725 /// (`NesPpu.cpp:361-380`): during the sprite-tile-load window (dots
4726 /// 257-320) the OAM data bus carries `secondary_oam[sprite*4 + min(step,3)]`
4727 /// (the 4th byte held for the 5 idle fetch cycles); at every other rendered
4728 /// dot it carries `oam_bus_copybuffer` (the sprite-eval data latch
4729 /// maintained by [`Self::tick_oam_bus`]). Caller has already checked
4730 /// `scanline <= 239 && rendering`.
4731 /// Is the isolated OAM-data-bus model the thing a `$2004` read observes
4732 /// right now?
4733 ///
4734 /// EXTRACTED so the register read and the diagnostic trace cannot drift.
4735 /// `tick_oam_bus` runs only on visible scanlines with rendering enabled, so
4736 /// off that window `oam_bus_copybuffer` holds whatever the last rendered dot
4737 /// left in it. `cpu_read_register` has always guarded against that; the
4738 /// v2.5.6 state-trace field did not, and would have reported a stale
4739 /// secondary-OAM byte as though it were the `$2004` value for the dot --
4740 /// which defeats the entire reason that field exists.
4741 #[must_use]
4742 pub(crate) const fn oam_data_bus_is_live(&self) -> bool {
4743 self.scanline <= 239 && self.is_render_scanline() && self.mask.rendering_enabled()
4744 }
4745
4746 /// What a `$2004` read would return at this exact dot, model included.
4747 ///
4748 /// This is the diagnostic counterpart of the `$2004` arm in
4749 /// `cpu_read_register`, minus that arm's side effects (open-bus touch, OAM
4750 /// decay refresh) -- a trace must not perturb what it observes.
4751 /// Feature-gated rather than `#[allow(dead_code)]`: its only caller is
4752 /// `build_state_record`, which is itself behind `ppu-state-trace`. An item
4753 /// that is live under one feature and dead by default is the case that
4754 /// earns an attribute — and compiling it out entirely is better than
4755 /// suppressing the warning about it.
4756 #[cfg(feature = "ppu-state-trace")]
4757 #[must_use]
4758 pub(crate) fn oam_data_bus_observed(&self) -> u8 {
4759 if self.oam_data_bus_is_live() {
4760 self.oam_data_bus_read()
4761 } else {
4762 let v = self.oam[self.oam_addr as usize];
4763 if (self.oam_addr & 0x03) == 0x02 {
4764 v & 0xE3
4765 } else {
4766 v
4767 }
4768 }
4769 }
4770
4771 fn oam_data_bus_read(&self) -> u8 {
4772 if (257..=320).contains(&self.dot) {
4773 let phase = (self.dot - 257) % 8;
4774 let step = if phase > 3 { 3 } else { phase };
4775 let oam_addr = ((self.dot - 257) / 8) * 4 + step;
4776 self.oam_bus_secondary[(oam_addr & 0x1F) as usize]
4777 } else {
4778 self.oam_bus_copybuffer
4779 }
4780 }
4781
4782 /// v2.0 Tier 1.2 — per-dot driver for the isolated OAM-data-bus model.
4783 ///
4784 /// A side-effect-free model of the `NESdev`-documented PPU sprite-evaluation
4785 /// sequence (`NESdev` wiki "PPU sprite evaluation" + "PPU rendering"):
4786 /// secondary-OAM clear (dots 1-64), evaluation (65-256), and sprite fetch
4787 /// (257-320) in the default configuration (the optional OAMADDR sprite-eval
4788 /// corruption glitch disabled; the 8-sprite overflow bug is still modeled),
4789 /// plus the cycle-321 copy-buffer reset. It maintains ONLY
4790 /// `oam_bus_copybuffer` +
4791 /// the parallel `oam_bus_secondary`; it reads primary `oam` read-only and
4792 /// NEVER touches the real sprite-eval / overflow / sprite-zero state (so
4793 /// the existing rendering FSM is unperturbed — `$2004` reads are the sole
4794 /// observable effect of this whole feature). Called each dot on visible
4795 /// scanlines (0-239) when rendering is enabled.
4796 fn tick_oam_bus(&mut self) {
4797 let cycle = self.dot;
4798 // v2.3.0 (perf) — take the dot-0 early-out BEFORE deriving the sprite
4799 // height and y-test reference; both were computed unconditionally and
4800 // then discarded on this dot. Byte-identical: neither value is observable
4801 // on the path that returns here.
4802 if cycle == 0 {
4803 return;
4804 }
4805 // NOTE (v2.3.1 G3): pushing these two below the `cycle < 65` early-out
4806 // as well — they are dead across the dots 1..=64 clear window — was
4807 // measured and produced NO change on any workload across two runs. LLVM
4808 // already sinks pure computations past branches that do not use them.
4809 // Do not re-attempt as a performance change; see `docs/performance.md`.
4810 let sprite_height: i16 = if self.ctrl.contains(PpuCtrl::SPRITE_SIZE_16) {
4811 16
4812 } else {
4813 8
4814 };
4815 // Y-test reference: the scanline being evaluated (sprites render on
4816 // scanline+1).
4817 let scan = self.scanline;
4818 if cycle < 65 {
4819 // Secondary-OAM clear (cycles 1-64): the bus carries $FF and the
4820 // parallel secondary OAM is filled with $FF, 1 byte per 2 dots.
4821 self.oam_bus_copybuffer = 0xFF;
4822 self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF;
4823 return;
4824 }
4825 if cycle <= 256 {
4826 if cycle & 1 == 1 {
4827 // Odd cycle: read a byte from primary OAM into the bus latch.
4828 if cycle == 65 {
4829 // ProcessSpriteEvaluationStart: seed the eval pointer from
4830 // OAMADDR (eval can begin mid-sprite if $2003 was written).
4831 self.oam_bus_sprite_in_range = false;
4832 self.oam_bus_secondary_addr = 0;
4833 self.oam_bus_overflow_counter = 0;
4834 self.oam_bus_copy_done = false;
4835 self.oam_bus_addr_h = (self.oam_addr >> 2) & 0x3F;
4836 self.oam_bus_addr_l = self.oam_addr & 0x03;
4837 }
4838 let addr = ((self.oam_bus_addr_l & 0x03) | (self.oam_bus_addr_h << 2)) as usize;
4839 // v2.1.4 F2.3 — OAM-decay read hook (no-op at the default): a
4840 // sprite-evaluation primary-OAM read refreshes the row's DRAM
4841 // cells (this is what keeps OAM alive during normal rendering).
4842 self.oam_decay_on_read((addr & 0xFF) as u8);
4843 let raw = self.oam[addr & 0xFF];
4844 // OAM byte 2 (attributes) bits 2-4 are unimplemented (read 0).
4845 self.oam_bus_copybuffer = if addr & 0x03 == 0x02 { raw & 0xE3 } else { raw };
4846 } else {
4847 // Even cycle: copy / decide.
4848 let cb = self.oam_bus_copybuffer as i16;
4849 let cb_in_range = scan >= cb && scan < cb + sprite_height;
4850 if self.oam_bus_copy_done {
4851 self.oam_bus_addr_h = (self.oam_bus_addr_h + 1) & 0x3F;
4852 // OAM write-disable turns secondary-OAM writes into reads.
4853 // On early (pre-rev-G) 2C02s the data bus reads back the
4854 // last byte the OAM-address counter rests on EVEN when fewer
4855 // than 8 sprites were found (secondary_addr < 0x20) — the
4856 // "OAM2[OAM2Address] every other cycle" behavior AccuracyCoin
4857 // `$2004 Stress` section 6 documents. Mesen2 gates this on
4858 // `secondary_addr >= 0x20` (rev-G+), which is why no Mesen
4859 // config reproduces the section-6 `$03`; the test's answer
4860 // key (the spec) wants the unconditional read. Each
4861 // out-of-range sprite's Y was already written to
4862 // `secondary[secondary_addr]` (the frozen index) below, so
4863 // this reads back that last-written Y.
4864 self.oam_bus_copybuffer =
4865 self.oam_bus_secondary[(self.oam_bus_secondary_addr & 0x1F) as usize];
4866 } else {
4867 if !self.oam_bus_sprite_in_range && cb_in_range {
4868 self.oam_bus_sprite_in_range = true;
4869 }
4870 if self.oam_bus_secondary_addr < 0x20 {
4871 // Copy one byte to (parallel) secondary OAM.
4872 self.oam_bus_secondary[self.oam_bus_secondary_addr as usize] =
4873 self.oam_bus_copybuffer;
4874 if self.oam_bus_sprite_in_range {
4875 self.oam_bus_addr_l += 1;
4876 self.oam_bus_secondary_addr += 1;
4877 if self.oam_bus_addr_l >= 4 {
4878 self.oam_bus_addr_h = (self.oam_bus_addr_h + 1) & 0x3F;
4879 self.oam_bus_addr_l = 0;
4880 if self.oam_bus_addr_h == 0 {
4881 self.oam_bus_copy_done = true;
4882 }
4883 }
4884 if self.oam_bus_secondary_addr.trailing_zeros() >= 2 {
4885 // Finished copying all 4 bytes of this sprite.
4886 self.oam_bus_sprite_in_range = false;
4887 if self.oam_bus_addr_l != 0 && !cb_in_range {
4888 self.oam_bus_addr_l = 0;
4889 }
4890 }
4891 } else {
4892 // Nothing to copy — skip to the next sprite.
4893 self.oam_bus_addr_h = (self.oam_bus_addr_h + 1) & 0x3F;
4894 self.oam_bus_addr_l = 0;
4895 if self.oam_bus_addr_h == 0 {
4896 self.oam_bus_copy_done = true;
4897 }
4898 }
4899 } else {
4900 // 8 sprites found: secondary-OAM writes become reads.
4901 self.oam_bus_copybuffer =
4902 self.oam_bus_secondary[(self.oam_bus_secondary_addr & 0x1F) as usize];
4903 if self.oam_bus_sprite_in_range {
4904 // Overflow detected. (NOTE: the REAL SpriteOverflow
4905 // flag is owned by the existing eval FSM — this
4906 // isolated model deliberately does not set it.)
4907 self.oam_bus_addr_l += 1;
4908 if self.oam_bus_addr_l == 4 {
4909 self.oam_bus_addr_h = (self.oam_bus_addr_h + 1) & 0x3F;
4910 self.oam_bus_addr_l = 0;
4911 }
4912 if self.oam_bus_overflow_counter == 0 {
4913 self.oam_bus_overflow_counter = 3;
4914 } else {
4915 self.oam_bus_overflow_counter -= 1;
4916 if self.oam_bus_overflow_counter == 0 {
4917 self.oam_bus_copy_done = true;
4918 self.oam_bus_addr_l = 0;
4919 }
4920 }
4921 } else {
4922 // Sprite-eval bug: increment BOTH H and L.
4923 self.oam_bus_addr_h = (self.oam_bus_addr_h + 1) & 0x3F;
4924 self.oam_bus_addr_l = (self.oam_bus_addr_l + 1) & 0x03;
4925 if self.oam_bus_addr_h == 0 {
4926 self.oam_bus_copy_done = true;
4927 }
4928 }
4929 }
4930 }
4931 }
4932 return;
4933 }
4934 if cycle == 321 {
4935 // After sprite loading, the bus rests on secondary OAM index 0.
4936 self.oam_bus_copybuffer = self.oam_bus_secondary[0];
4937 }
4938 }
4939
4940 // v2.3.0 (perf) — called once per ELIGIBLE dot on the fast dot path (visible
4941 // dots 1..=256 with rendering enabled: up to 61,440/frame, not all 89,342 —
4942 // idle lines and rendering-disabled paths bypass it entirely);
4943 // `perf annotate` showed its own prologue/epilogue (`push`/`ret`) as the two
4944 // hottest instructions in the body, i.e. pure call overhead LLVM had declined
4945 // to remove. `inline` lets it be folded into the dot loop. Byte-identical (an
4946 // inlining hint changes no behavior); adopted only if it clears the >3% bar.
4947 #[inline]
4948 pub(crate) fn tick_sprite_eval_per_dot(&mut self) {
4949 // Y-test reference line for sprite evaluation. Per nesdev
4950 // "PPU OAM" (Byte 0): "The first scanline that the sprite is
4951 // rendered on is one greater than this value." Hardware
4952 // performs the y-test `(scanline - y) in [0, h-1]` using the
4953 // CURRENT scanline counter — the eval at scanline N produces
4954 // sprites that render on scanline N+1. So sprite Y=N renders
4955 // on scanlines N+1..=N+h.
4956 //
4957 // Pre-render (scanline 261) prepares for scanline 0, but
4958 // scanline 0 never displays sprites per nesdev. We model
4959 // this by using -1 as the y-test reference, which makes
4960 // `-1 - y < 0` for all OAM y values, so the y-test always
4961 // fails at pre-render and scanline 0 sees no sprites.
4962 //
4963 // NOTE (v2.3.1 G3): sinking these two to their single use site in the
4964 // `65..=256` arm — they are dead on 149 of 341 dots — was measured and
4965 // produced NO change on any workload across two runs. LLVM already sinks
4966 // pure computations past branches that do not use them. Do not re-attempt
4967 // as a performance change; see `docs/performance.md`.
4968 let next_line: i16 = if self.scanline == self.region.prerender_line() {
4969 -1
4970 } else {
4971 self.scanline
4972 };
4973 let sprite_height: i16 = if self.ctrl.contains(PpuCtrl::SPRITE_SIZE_16) {
4974 16
4975 } else {
4976 8
4977 };
4978
4979 match self.dot {
4980 0 => {
4981 // Start-of-scanline: reset FSM working state. We do NOT
4982 // touch the rendering-side `spr_*` arrays or
4983 // `spr_zero_in_line` here — they were committed at the
4984 // PREVIOUS scanline's dot 256 and are about to be read
4985 // by this scanline's sprite-pixel evaluator on dots
4986 // 1..=256.
4987 self.sprite_eval_n = 0;
4988 self.sprite_eval_m = 0;
4989 self.sprite_eval_found = 0;
4990 self.sprite_eval_sec_idx = 0;
4991 self.sprite_eval_copying = false;
4992 self.sprite_eval_done = false;
4993 self.sprite_eval_overflow_search = false;
4994 self.sprite_eval_read_latch = 0xFF;
4995 self.sprite_eval_zero_found = false;
4996 // Phase 3a: capture eval base from OAMADDR at the
4997 // dot-0 reset so the dots 65-256 active loop starts
4998 // walking from the captured `(start_n, start_m)`
4999 // position. Mesen2 captures at cycle 65 (in
5000 // ProcessSpriteEvaluationStart); we capture at dot 0
5001 // because our FSM does the eval-base read BEFORE
5002 // dot 65 (the first read at dot 65 already needs
5003 // the offset). This matters when the CPU writes
5004 // $2003 mid-vblank to set OAMADDR before the next
5005 // scanline's eval begins.
5006 {
5007 self.sprite_eval_n = (self.oam_addr >> 2) & 0x3F;
5008 self.sprite_eval_m = self.oam_addr & 0x03;
5009 }
5010 self.sprite_eval_first_iter = true;
5011 }
5012 1..=64 => {
5013 // Clear phase. Even-dot writes a $FF into secondary OAM
5014 // (1 byte per 2 dots, 32 bytes over 64 dots). Odd dots
5015 // are idle reads (driving $FF onto the bus).
5016 //
5017 // The pre-2026-05-17 implementation also reset the
5018 // rendering-side `spr_*` arrays + `spr_count` +
5019 // `spr_zero_in_line` here at dot 64. That was a B8a
5020 // regression: the rendering loop at line 1146..=1220
5021 // READS those arrays on dots 1..=256 of the CURRENT
5022 // scanline, so resetting them mid-scanline destroyed
5023 // sprites for dots 64..=256 (the right ~75% of every
5024 // scanline). The dot 256 End-of-eval fixup below is
5025 // the correct time to commit the NEXT scanline's
5026 // values; the dot 64 reset has been removed.
5027 if (self.dot & 1) == 0 {
5028 let idx = ((self.dot - 1) >> 1) as usize;
5029 if idx < self.secondary_oam.len() {
5030 self.secondary_oam[idx] = 0xFF;
5031 }
5032 }
5033 }
5034 65..=256 => {
5035 if !self.sprite_eval_done {
5036 self.tick_sprite_eval_active_dot(next_line, sprite_height);
5037 }
5038
5039 if self.dot == 256 {
5040 // End-of-eval fixup: commit spr_count and the
5041 // eval-side sprite-0 latch onto the rendering-side
5042 // arrays. Pre-clear slots we did NOT fill so unused
5043 // ones produce no output even though
5044 // `fetch_sprite_tile` always runs all 8 slots.
5045 self.spr_count = self.sprite_eval_found;
5046 self.spr_zero_in_line = self.sprite_eval_zero_found;
5047 for i in (self.spr_count as usize)..8 {
5048 self.spr_shift_lo[i] = 0;
5049 self.spr_shift_hi[i] = 0;
5050 self.spr_attr[i] = 0;
5051 self.spr_x[i] = 0xFF;
5052 }
5053 }
5054 }
5055 _ => {
5056 // Dots 257..=340: eval is idle; sprite tile fetches happen
5057 // elsewhere (`fetch_sprite_tile`, scheduled at dots 260,
5058 // 268, ..., 316 from the tick() main path).
5059 }
5060 }
5061 }
5062
5063 /// Per-active-dot helper for the per-PPU-dot FSM. Drives the
5064 /// alternating read/write semantics of dots 65..=256 when eval has
5065 /// not yet exhausted primary OAM or set overflow.
5066 #[allow(clippy::too_many_lines)] // Phase 3a feature-gated branches expand the line count beyond the threshold; refactoring into sub-helpers would require sharing 5+ mutable fields by reference, hurting readability.
5067 fn tick_sprite_eval_active_dot(&mut self, next_line: i16, sprite_height: i16) {
5068 if (self.dot & 1) == 1 {
5069 // Odd dot: read.
5070 // Per nesdev wiki "PPU sprite evaluation": during dots 65-256,
5071 // the hardware updates OAMADDR to track the current eval read
5072 // position. A CPU $2004 read at this time sees the OAM byte
5073 // at that walking index. We surface the eval position into
5074 // `oam_addr` so that CPU reads of $2004 during sprite eval
5075 // observe the same behavior as real silicon. The dot-257-320
5076 // OAMADDR-reset added in `Ppu::tick` washes this back to 0
5077 // after eval, preserving the post-eval semantics that the
5078 // existing $4014 OAM DMA / blargg sprite_hit_tests rely on.
5079 // Phase 3a: under the eval-base-from-OAMADDR feature, the
5080 // y-test address ALWAYS uses `n*4 + m` so a misaligned
5081 // start (`oam_addr & 0x03 != 0` at dot 0) reads the
5082 // appropriate byte of the start sprite as the Y candidate
5083 // (Mesen2 `_spriteAddrL` model). Under the legacy path,
5084 // `m` is reset to 0 between sprites and the y-test always
5085 // reads byte 0; the legacy special-case is preserved for
5086 // bit-exact compatibility.
5087 let addr = ((self.sprite_eval_n as usize) * 4) + (self.sprite_eval_m as usize);
5088 // v2.1.4 F2.3 — OAM-decay read hook (no-op at the default): the legacy
5089 // per-dot sprite-eval read path also refreshes the row it reads, so both
5090 // eval models keep OAM alive identically during rendering.
5091 self.oam_decay_on_read((addr & 0xFF) as u8);
5092 self.sprite_eval_read_latch = self.oam[addr & 0xFF];
5093 // Expose the current eval index via the OAMADDR register
5094 // (truncated to u8 via the `& 0xFF` mask). This is the
5095 // documented hardware behavior — see AccuracyCoin
5096 // `TEST_ArbitrarySpriteZero` sub-test 2's lengthy comment
5097 // explaining the eval / OAMADDR interaction.
5098 self.oam_addr = (addr & 0xFF) as u8;
5099 } else {
5100 // Even dot: write/decide.
5101 let latch = self.sprite_eval_read_latch;
5102 if self.sprite_eval_overflow_search {
5103 // Treat the read byte as a y-coord candidate.
5104 let row = next_line - (latch as i16);
5105 if row >= 0 && row < sprite_height {
5106 self.status.insert(PpuStatus::SPRITE_OVERFLOW);
5107 self.sprite_eval_done = true;
5108 } else {
5109 // Buggy n+m increment: increment BOTH.
5110 self.sprite_eval_m = (self.sprite_eval_m + 1) & 0x03;
5111 if self.sprite_eval_n == 63 {
5112 self.sprite_eval_done = true;
5113 } else {
5114 self.sprite_eval_n += 1;
5115 }
5116 }
5117 } else if self.sprite_eval_copying {
5118 // Copy byte (m == 1, 2, 3) into secondary OAM.
5119 let sec_idx = self.sprite_eval_sec_idx as usize;
5120 if sec_idx < self.secondary_oam.len() {
5121 self.secondary_oam[sec_idx] = latch;
5122 }
5123 self.sprite_eval_sec_idx += 1;
5124 self.sprite_eval_m += 1;
5125 // Phase 3a: under the eval-base feature, continue
5126 // copying until the secondary OAM is aligned to a
5127 // sprite boundary (sec_idx % 4 == 0) — Mesen2's model
5128 // (`_secondaryOamAddr & 0x03 == 0` check at line
5129 // 1062). This handles misaligned start where 4
5130 // sequential reads from `(start_n*4+start_m)` span
5131 // sprite boundaries. Under the legacy path,
5132 // `m == 4` is identical to "sec_idx & 3 == 0" because
5133 // copying always starts at m=1 (after y-test at m=0),
5134 // so they're equivalent in the legacy case.
5135 let copy_done = self.sprite_eval_sec_idx.trailing_zeros() >= 2;
5136 if self.sprite_eval_m == 4 {
5137 self.sprite_eval_m = 0;
5138 self.sprite_eval_n = (self.sprite_eval_n + 1) & 0x3F;
5139 }
5140 if copy_done {
5141 // Finished this sprite. found was already
5142 // incremented when the y-byte landed.
5143 self.sprite_eval_copying = false;
5144 self.sprite_eval_m = 0;
5145 // Under feature: the m==4 wrap above already
5146 // advanced n once. Don't double-increment.
5147 // Under legacy: m never wrapped, so n advances
5148 // here for the first (and only) time.
5149 {
5150 // n was already advanced in the m==4 wrap
5151 // block above; just check terminal conditions.
5152 if self.sprite_eval_found == 8 {
5153 self.sprite_eval_overflow_search = true;
5154 }
5155 if self.sprite_eval_n == 0 {
5156 // n wrapped past 63 to 0 — done.
5157 self.sprite_eval_done = true;
5158 }
5159 }
5160 }
5161 } else {
5162 // Y-test for sprite n.
5163 let row = next_line - (latch as i16);
5164 let in_range = row >= 0 && row < sprite_height;
5165 if in_range && self.sprite_eval_found < 8 {
5166 // Write y into secondary OAM and start copying
5167 // bytes 1..=3 over the next 3 even-dot writes.
5168 let sec_idx = self.sprite_eval_sec_idx as usize;
5169 if sec_idx < self.secondary_oam.len() {
5170 self.secondary_oam[sec_idx] = latch;
5171 }
5172 self.sprite_eval_sec_idx += 1;
5173 // Sprite-zero-hit eligibility: per nesdev wiki +
5174 // Mesen2 (`NesPpu::ProcessSpriteEvaluation` line
5175 // 1040-1044, "If the first Y coordinate we load
5176 // is in range, set the sprite 0 flag — this
5177 // happens even if this isn't actually the first
5178 // sprite in OAM (i.e. because OAMADDR was not 0
5179 // when evaluation started)"), the sprite at the
5180 // eval-start position is sprite-zero IFF its Y
5181 // is in range — NOT "first in-range sprite found".
5182 // If the start sprite is out-of-range, no sprite
5183 // on this scanline is sprite-zero. Under Phase 3a,
5184 // gate on `sprite_eval_first_iter` (the first y-test
5185 // of the scanline); the legacy path keeps the
5186 // canonical `n == 0` check.
5187 let is_first_inrange = self.sprite_eval_first_iter;
5188 if is_first_inrange {
5189 self.sprite_eval_zero_flag_on();
5190 }
5191 self.sprite_eval_found += 1;
5192 self.sprite_eval_copying = true;
5193 // Phase 3a: increment from CURRENT m (handles
5194 // misaligned start where eval began at m != 0).
5195 // Legacy path resets to m=1 (canonical "skip Y,
5196 // copy bytes 1..=3" pattern).
5197 {
5198 self.sprite_eval_m += 1;
5199 if self.sprite_eval_m == 4 {
5200 // Wrapped past end of sprite — already
5201 // "copied" the whole sprite from its
5202 // misaligned start. Advance n, reset m.
5203 self.sprite_eval_copying = false;
5204 self.sprite_eval_m = 0;
5205 if self.sprite_eval_n == 63 {
5206 self.sprite_eval_done = true;
5207 } else {
5208 self.sprite_eval_n += 1;
5209 }
5210 }
5211 }
5212 } else if in_range && self.sprite_eval_found == 8 {
5213 // Defensive: 9th in-range sprite at the y-tested
5214 // cell. In practice the `found == 8` transition
5215 // happens at the end of copying sprite 7, which
5216 // flips into `overflow_search` mode, so this branch
5217 // is unreachable. Kept for safety.
5218 self.status.insert(PpuStatus::SPRITE_OVERFLOW);
5219 self.sprite_eval_done = true;
5220 } else {
5221 // Not in range: advance to next sprite.
5222 if self.sprite_eval_n == 63 {
5223 self.sprite_eval_done = true;
5224 } else {
5225 self.sprite_eval_n += 1;
5226 }
5227 }
5228 // Phase 3a: clear the "first-iteration" flag AFTER the
5229 // first y-test fires (regardless of in-range result).
5230 // Per Mesen2 `_cycle == 66` semantics — sprite-zero is
5231 // set only on the FIRST y-test that lands in range,
5232 // and only if it's the FIRST iteration overall.
5233 self.sprite_eval_first_iter = false;
5234 }
5235 }
5236 }
5237
5238 /// Helper: set the per-scanline sprite-zero-in-line flag from the
5239 /// FSM. Sets the EVAL-side latch (`sprite_eval_zero_found`); the
5240 /// rendering-side flag (`spr_zero_in_line`) is committed from this
5241 /// latch at dot 256.
5242 const fn sprite_eval_zero_flag_on(&mut self) {
5243 self.sprite_eval_zero_found = true;
5244 }
5245
5246 /// Arm the OAM-corruption disable edge on a `$2001` write — faithful
5247 /// port of `TriCNES`'s `$2001` write path (`Emulator.cs` lines
5248 /// 9684-9696 / 1740-1755). When rendering was ON before the write and
5249 /// the new mask turns BOTH BG + sprites OFF while on a render line (NOT
5250 /// in vblank), set the disable flags. `_instant` is the
5251 /// data-bus-immediate path (OAM eval observes the disable the same
5252 /// cycle); the non-instant flag is the regular 1-dot-delayed path. The
5253 /// disable edge is captured against the live eval pointer during the
5254 /// dots 1-64 window and committed on re-enable; the `!pending` guard
5255 /// stops a write from re-arming over an already-captured corruption.
5256 const fn arm_oam_corruption_disable(&mut self, was_rendering: bool) {
5257 if was_rendering
5258 && !self.mask.rendering_enabled()
5259 && !self.oam_corruption_pending
5260 && (self.scanline < self.region.vblank_start_line()
5261 || self.scanline == self.region.prerender_line())
5262 {
5263 self.oam_corruption_disabled = true;
5264 self.oam_corruption_disabled_instant = true;
5265 }
5266 }
5267
5268 /// OAM-corruption per-dot driver — faithful port of `TriCNES`'s
5269 /// `PPU_Render_SpriteEvaluation` corruption handling (`Emulator.cs`
5270 /// lines 2664-2762). Called every render-line dot (independent of the
5271 /// rendering gate, so the disable edge is observed even after the
5272 /// sprite-eval FSM stops). Three responsibilities, in `TriCNES` order:
5273 ///
5274 /// 1. **Commit on re-enable.** If rendering is currently enabled and a
5275 /// corruption is pending, apply it (`TriCNES` applies on the first
5276 /// rendered dot once `PPU_Mask_Show*_Instant` is set again). The
5277 /// pre-render-line dot-0 hook in `tick` handles the
5278 /// re-enable-during-vblank case.
5279 /// 2. **Maintain `OAM2Address` across the dots 1-64 clear window.**
5280 /// Reset at dot 1; incremented once per even clear dot, masked to
5281 /// 0x1F — exactly as `TriCNES` drives `OAM2Address` during dots 1-64.
5282 /// 3. **Capture the index at the disable edge.** When the disable
5283 /// flag (`oam_corruption_disabled` / `_instant`, armed by the
5284 /// `$2001` write) is set during the dots 1-64 window on a NON
5285 /// pre-render line, set `oam_corruption_pending` and capture
5286 /// `oam_corruption_index = oam2_addr` (the live secondary-OAM write
5287 /// pointer). The pre-render line is excluded from the capture (it is
5288 /// a read-only eval line for OAM-corruption purposes in `TriCNES`).
5289 fn tick_oam_corruption(&mut self, rendering: bool) {
5290 let pre_render = self.scanline == self.region.prerender_line();
5291
5292 // (1) Commit pending corruption once rendering is (re-)enabled
5293 // during the eval window. The pre-render dot-0 path in `tick`
5294 // covers the re-enable-in-VBlank case separately.
5295 if rendering && self.oam_corruption_pending && !pre_render && self.dot >= 1 {
5296 self.process_oam_corruption();
5297 }
5298
5299 // (2) + (3) only matter inside the dots 1-64 secondary-OAM clear
5300 // window. Outside it the disable flags simply persist until the
5301 // next eval window (or are committed above on re-enable).
5302 if (1..=64).contains(&self.dot) {
5303 if self.dot == 1 {
5304 // TriCNES resets OAM2Address at dot 1 of the eval window.
5305 self.oam2_addr = 0;
5306 }
5307
5308 // Capture the disable edge against the LIVE pointer, on a
5309 // non-pre-render line only (TriCNES: capture is skipped on the
5310 // read-only pre-render eval line).
5311 if (self.oam_corruption_disabled || self.oam_corruption_disabled_instant)
5312 && !pre_render
5313 && !self.oam_corruption_pending
5314 {
5315 self.oam_corruption_pending = true;
5316 self.oam_corruption_index = self.oam2_addr;
5317 }
5318 // The disable arming is single-shot: clear it once observed in
5319 // the eval window (TriCNES clears both flags when it fires).
5320 self.oam_corruption_disabled = false;
5321 self.oam_corruption_disabled_instant = false;
5322
5323 // Advance OAM2Address on even clear dots (mirrors TriCNES's
5324 // `OAM2[OAM2Address] = latch; OAM2Address = (OAM2Address+1) &
5325 // 0x1F` on even cycles of the dots 1-64 clear).
5326 if (self.dot & 1) == 0 {
5327 self.oam2_addr = (self.oam2_addr + 1) & 0x1F;
5328 }
5329 }
5330 }
5331
5332 /// Apply a pending OAM corruption — faithful port of `TriCNES`'s
5333 /// `CorruptOAM` (`Emulator.cs` lines 2635-2651): one OAM "row" of 8
5334 /// bytes is overwritten from row 0, plus the corresponding secondary-OAM
5335 /// byte. The index (`oam_corruption_index`) was captured at the disable
5336 /// edge from the live secondary-OAM eval pointer; index 0x20 wraps to 0.
5337 /// Clears the pending flag.
5338 fn process_oam_corruption(&mut self) {
5339 let mut index = self.oam_corruption_index as usize;
5340 if index == 0x20 {
5341 index = 0;
5342 }
5343 // OAM[index*8 + i] = OAM[i] for i in 0..8 (a no-op when index == 0,
5344 // matching TriCNES — it still runs, copying row 0 onto itself).
5345 let first_eight: [u8; 8] = [
5346 self.oam[0],
5347 self.oam[1],
5348 self.oam[2],
5349 self.oam[3],
5350 self.oam[4],
5351 self.oam[5],
5352 self.oam[6],
5353 self.oam[7],
5354 ];
5355 let dst = index * 8;
5356 if dst + 8 <= self.oam.len() {
5357 self.oam[dst..dst + 8].copy_from_slice(&first_eight);
5358 }
5359 // Also corrupt secondary OAM: OAM2[index] = OAM2[0].
5360 if index < self.secondary_oam.len() {
5361 self.secondary_oam[index] = self.secondary_oam[0];
5362 }
5363 self.oam_corruption_pending = false;
5364 }
5365
5366 /// Fetch one sprite slot's pattern bytes. Always called for all 8
5367 /// slots — for unused slots the secondary-OAM bytes are $FF, producing
5368 /// a dummy fetch that still toggles A12 to the sprite pattern table on
5369 /// real hardware. This is what generates the per-scanline A12 rising
5370 /// edge that MMC3's IRQ counter clocks on.
5371 #[allow(clippy::cast_sign_loss)]
5372 fn fetch_sprite_tile<B: PpuBus>(&mut self, bus: &mut B, slot: usize) {
5373 // Mirrors the y-test convention in `tick_sprite_eval_per_dot`:
5374 // `next_line` is the y-test reference = the CURRENT scanline
5375 // counter (or -1 for pre-render). The fetched row index is
5376 // `next_line - y`, which matches the row that will be
5377 // displayed on `next_line + 1` (the next scanline that
5378 // renders the eval result).
5379 //
5380 // v2.0 (ppu-sprite-shifter-counter): treat the pre-render line as
5381 // scanline `(prerender_line & 0xFF)` (NTSC 261 & 255 = 5) for the
5382 // sprite-tile in-range check, so a sprite whose pixel lands on row 5
5383 // loads into the shifters for scanline 0 (the stale secondary-OAM slots
5384 // filtered by the `load` gate below) — AccuracyCoin "Sprites On Scanline
5385 // 0". Default keeps the `-1` reference (scanline 0 sees no sprites).
5386 let next_line: i16 = if self.scanline == self.region.prerender_line() {
5387 self.region.prerender_line() & 0xFF
5388 } else {
5389 self.scanline
5390 };
5391 let sprite_height: i16 = if self.ctrl.contains(PpuCtrl::SPRITE_SIZE_16) {
5392 16
5393 } else {
5394 8
5395 };
5396 let base = slot * 4;
5397 let y = self.secondary_oam[base] as i16;
5398 let tile = self.secondary_oam[base + 1];
5399 let attr = self.secondary_oam[base + 2];
5400 let xpos = self.secondary_oam[base + 3];
5401 let in_use = slot < self.spr_count as usize;
5402 let flip_v = (attr & 0x80) != 0;
5403 let flip_h = (attr & 0x40) != 0;
5404
5405 // For unused slots, the row delta isn't meaningful (Y=$FF makes it
5406 // negative or huge) — pin to 0 so the address arithmetic is well
5407 // defined. The only thing that matters here is that the pattern
5408 // address lands in the sprite pattern table, which it does because
5409 // the sprite-table-select bit is set as PPUCTRL bit 3 (8x8 mode)
5410 // or tile bit 0 (8x16 mode); for the cleared $FF tile in 8x16 mode
5411 // bit 0 = 1 picks the $1000 table.
5412 let mut row: u16 = if in_use {
5413 (next_line.wrapping_sub(y)).clamp(0, sprite_height - 1) as u16
5414 } else {
5415 0
5416 };
5417
5418 let (table, tile_idx, in_tile_row) = if sprite_height == 16 {
5419 let table = u16::from(tile & 0x01) << 12;
5420 let mut tindex = tile & 0xFE;
5421 if flip_v && in_use {
5422 row = 15 - row;
5423 }
5424 if row >= 8 {
5425 tindex = tindex.wrapping_add(1);
5426 row -= 8;
5427 }
5428 (table, tindex, row)
5429 } else {
5430 let table = u16::from(self.ctrl.contains(PpuCtrl::SPRITE_PATTERN_HIGH)) << 12;
5431 let r = if flip_v && in_use { 7 - row } else { row };
5432 (table, tile, r)
5433 };
5434
5435 let addr_lo = table | (u16::from(tile_idx) << 4) | in_tile_row;
5436 let addr_hi = addr_lo | 0x08;
5437 self.observe_a12_addr(bus, addr_lo);
5438 // Sprite CHR fetch: route through `ppu_read_sprite` so MMC5
5439 // (and any other mapper with split sprite vs. BG CHR banking)
5440 // can use its sprite-specific bank registers.
5441 let mut lo = bus.ppu_read_sprite(addr_lo);
5442 self.observe_a12_addr(bus, addr_hi);
5443 let mut hi = bus.ppu_read_sprite(addr_hi);
5444 // W2 ($2007 Stress): stash the RAW (pre-h-flip) pattern bytes so the
5445 // per-dot sprite-fetch read cadence (`tick_sprite_fetch_read`) can
5446 // feed `render_data_bus` for the deferred `$2007` PPUDATA reload.
5447 {
5448 self.spr_fetch_lo_raw[slot] = lo;
5449 self.spr_fetch_hi_raw[slot] = hi;
5450 }
5451 // v2.0 (ppu-sprite-shifter-counter): gate the shifter load on the sprite
5452 // being in-range of `next_line`. On visible scanlines this is a no-op
5453 // (the eval already guarantees every `in_use` slot is in-range), but on
5454 // the pre-render line (feature on, `next_line = 5`) it filters the STALE
5455 // secondary-OAM slots so only sprites whose pixel lands on row 5 load for
5456 // scanline 0. Default (feature off): load every `in_use` slot.
5457 let load = in_use && {
5458 let r = next_line.wrapping_sub(y);
5459 r >= 0 && r < sprite_height
5460 };
5461 if load {
5462 if flip_h {
5463 lo = reverse_bits(lo);
5464 hi = reverse_bits(hi);
5465 }
5466 self.spr_shift_lo[slot] = lo;
5467 self.spr_shift_hi[slot] = hi;
5468 self.spr_attr[slot] = attr;
5469 self.spr_x[slot] = xpos;
5470 // v1.2.0 C3 (hd-pack): stash the 16-byte tile base (in-tile row
5471 // masked off) for this sprite slot so `emit_pixel` can name the
5472 // CHR tile. Telemetry only; no new VRAM read here.
5473 #[cfg(feature = "hd-pack")]
5474 {
5475 self.hd_spr_addr[slot] = addr_lo & 0x1FF0;
5476 self.hd_spr_x[slot] = xpos;
5477 // `in_tile_row` is the post-flip-V fetch row, i.e. exactly the
5478 // (unflipped) replacement texel row to sample.
5479 self.hd_spr_off_y[slot] = u8::try_from(in_tile_row & 0x07).unwrap_or(0);
5480 // CHR-ROM absolute tile index for the sprite, or the CHR-RAM
5481 // sentinel (the common mappers share BG/sprite CHR banking).
5482 self.hd_spr_idx[slot] = bus.chr_phys(addr_lo).map_or(HD_CHR_RAM, |o| o / 16);
5483 }
5484 // v2.3.2 "Lucid": the sprite's pattern ROW address (in-tile row
5485 // KEPT, unlike the `hd-pack` tile base above). Captured separately
5486 // so neither feature's telemetry depends on the other being on.
5487 #[cfg(feature = "debug-hooks")]
5488 {
5489 self.prov_spr_addr[slot] = addr_lo;
5490 }
5491 } else {
5492 #[cfg(feature = "hd-pack")]
5493 {
5494 self.hd_spr_addr[slot] = HD_TILE_NONE;
5495 }
5496 #[cfg(feature = "debug-hooks")]
5497 {
5498 self.prov_spr_addr[slot] = crate::provenance::PATTERN_ADDR_NONE;
5499 }
5500 }
5501 // Else: shift regs already cleared in tick_sprite_eval_per_dot.
5502 }
5503
5504 fn advance_dot(&mut self) {
5505 // Count every PPU master cycle (one per dot processed) for the NES_NTSC
5506 // colour phase. Output-only / cosmetic; never gates emulation.
5507 self.dot_counter = self.dot_counter.wrapping_add(1);
5508
5509 // Odd-frame skip: when the frame is odd and rendering is enabled,
5510 // the pre-render scanline 261 dot 339 transitions to (0, 0)
5511 // immediately, skipping dot 340.
5512 //
5513 // The rendering check reads `mask_for_skip_check` (two-stage
5514 // pipeline of `mask`, shifted at the bottom of this function), not
5515 // `mask` directly. The two-PPU-clock visibility delay between a
5516 // `$2001` write and this check is what makes blargg
5517 // `ppu_vbl_nmi/10-even_odd_timing` pass: lockstep applies the
5518 // PPUMASK write at the *start* of a CPU cycle, while real hardware
5519 // latches at φ2 (end of cycle). Without the delay the dot-339 skip
5520 // detector observes the write up to two PPU clocks earlier than
5521 // hardware does, mispredicting the skip when the write straddles
5522 // dot 339.
5523 if self.scanline == self.region.prerender_line()
5524 && self.dot == 339
5525 && (self.frame & 1) == 1
5526 && self.mask_for_skip_check.rendering_enabled()
5527 && self.region == PpuRegion::Ntsc
5528 {
5529 self.dot = 0;
5530 self.scanline = 0;
5531 self.frame = self.frame.wrapping_add(1);
5532 self.frame_complete = true;
5533 self.snapshot_ntsc_phase();
5534 self.mask_for_skip_check = self.mask_skip_pipe1;
5535 self.mask_skip_pipe1 = self.mask;
5536 return;
5537 }
5538
5539 self.dot += 1;
5540 if self.dot > 340 {
5541 self.dot = 0;
5542 // Advance scanline.
5543 if self.scanline == self.region.prerender_line() {
5544 self.scanline = 0;
5545 self.frame = self.frame.wrapping_add(1);
5546 self.frame_complete = true;
5547 self.snapshot_ntsc_phase();
5548 } else if self.extra_scanlines != 0 && self.scanline + 1 == self.region.prerender_line()
5549 {
5550 // v1.7.0 F3 — PPU extra-scanlines overclock. The line just
5551 // before pre-render is a pure idle vblank line (not visible,
5552 // not the VBL-set line, not pre-render): repeating it emits no
5553 // pixels, sets/clears no flags, and fires no VBL/NMI/A12 event
5554 // — it only adds CPU run-time (the surrounding scheduler still
5555 // clocks the CPU every third dot). When the counter is exhausted
5556 // we fall through to the pre-render line as usual. This whole
5557 // branch is unreachable while `extra_scanlines == 0`, so the
5558 // default build is byte-identical.
5559 if self.extra_lines_remaining == 0 {
5560 self.extra_lines_remaining = self.extra_scanlines;
5561 }
5562 self.extra_lines_remaining -= 1;
5563 if self.extra_lines_remaining == 0 {
5564 // Done inserting: advance to pre-render as normal.
5565 self.scanline += 1;
5566 }
5567 // else: hold on this idle line and run it again.
5568 } else {
5569 self.scanline += 1;
5570 }
5571 }
5572 self.mask_for_skip_check = self.mask_skip_pipe1;
5573 self.mask_skip_pipe1 = self.mask;
5574 }
5575
5576 const fn is_render_scanline(&self) -> bool {
5577 // Visible (0..=239) and pre-render line.
5578 self.scanline >= 0 && self.scanline <= self.region.last_visible_line()
5579 || self.scanline == self.region.prerender_line()
5580 }
5581}
5582
5583/// Resolve an address in `$3F00-$3FFF` to a palette RAM index, applying the
5584/// `$3F10/$14/$18/$1C → $3F00/$04/$08/$0C` mirror.
5585const fn palette_index(addr: u16) -> usize {
5586 let mut idx = (addr & 0x1F) as usize;
5587 if matches!(idx, 0x10 | 0x14 | 0x18 | 0x1C) {
5588 idx -= 0x10;
5589 }
5590 idx
5591}
5592
5593/// Reverse the bit order of a byte (used for horizontally-flipped sprites).
5594const fn reverse_bits(b: u8) -> u8 {
5595 b.reverse_bits()
5596}
5597
5598#[cfg(test)]
5599mod tests {
5600 use super::*;
5601
5602 // T-73-005 / T-73-006 (Phase 7): pin the per-region timing table so an
5603 // accidental edit to a region constant trips a test instead of silently
5604 // mis-timing PAL/Dendy. The runtime frame-structure consequences are
5605 // gated by the integration test in
5606 // `crates/rustynes-test-harness/tests/region_timing.rs`.
5607 #[test]
5608 fn ppu_region_constants_match_hardware() {
5609 // NTSC: 262 lines (pre-render 261), VBL@241, no odd-frame skip caveat.
5610 assert_eq!(PpuRegion::Ntsc.prerender_line(), 261);
5611 assert_eq!(PpuRegion::Ntsc.vblank_start_line(), 241);
5612 assert_eq!(PpuRegion::Ntsc.post_reset_mask_cycles(), 29_658);
5613 // PAL: 312 lines (pre-render 311), VBL@241, longer reset mask.
5614 assert_eq!(PpuRegion::Pal.prerender_line(), 311);
5615 assert_eq!(PpuRegion::Pal.vblank_start_line(), 241);
5616 assert_eq!(PpuRegion::Pal.post_reset_mask_cycles(), 33_132);
5617 // Dendy: 312 lines, but VBL starts at 291 (the distinguishing trait).
5618 assert_eq!(PpuRegion::Dendy.prerender_line(), 311);
5619 assert_eq!(PpuRegion::Dendy.vblank_start_line(), 291);
5620 assert_eq!(PpuRegion::Dendy.post_reset_mask_cycles(), 33_132);
5621 // Last visible line is 239 in every region.
5622 for r in [PpuRegion::Ntsc, PpuRegion::Pal, PpuRegion::Dendy] {
5623 assert_eq!(r.last_visible_line(), 239);
5624 }
5625 }
5626
5627 #[test]
5628 fn odd_frame_dot_skip_is_ntsc_only() {
5629 // The pre-render dot-339 odd-frame skip only fires on NTSC with
5630 // rendering enabled. Drive a rendering-enabled odd pre-render frame in
5631 // each region and confirm only NTSC collapses dot 340.
5632 fn skips(region: PpuRegion) -> bool {
5633 let mut ppu = Ppu::new(region);
5634 // Force an odd frame, rendering on, parked at pre-render dot 339.
5635 ppu.frame = 1;
5636 ppu.mask = PpuMask::SHOW_BG;
5637 ppu.mask_for_skip_check = PpuMask::SHOW_BG;
5638 ppu.scanline = region.prerender_line();
5639 ppu.dot = 339;
5640 ppu.advance_dot();
5641 // A skip lands us at (scanline 0, dot 0); no skip steps to dot 340.
5642 ppu.scanline == 0 && ppu.dot == 0
5643 }
5644 assert!(skips(PpuRegion::Ntsc), "NTSC odd frame skips dot 340");
5645 assert!(!skips(PpuRegion::Pal), "PAL never skips");
5646 assert!(!skips(PpuRegion::Dendy), "Dendy never skips");
5647 }
5648
5649 /// Test bus that owns 8 KiB of CHR-RAM with horizontal mirroring map.
5650 /// CIRAM lives in the PPU; this bus only services CHR + A12.
5651 struct TestBus {
5652 chr: [u8; 0x2000],
5653 a12_count: u32,
5654 last_a12: bool,
5655 }
5656
5657 impl TestBus {
5658 fn new() -> Self {
5659 Self {
5660 chr: [0u8; 0x2000],
5661 a12_count: 0,
5662 last_a12: false,
5663 }
5664 }
5665 }
5666
5667 impl PpuBus for TestBus {
5668 fn ppu_read(&mut self, addr: u16) -> u8 {
5669 if addr < 0x2000 {
5670 self.chr[addr as usize]
5671 } else {
5672 0
5673 }
5674 }
5675 fn ppu_write(&mut self, addr: u16, value: u8) {
5676 if addr < 0x2000 {
5677 self.chr[addr as usize] = value;
5678 }
5679 }
5680 fn notify_a12(&mut self, level: bool) {
5681 if level != self.last_a12 {
5682 self.a12_count += 1;
5683 self.last_a12 = level;
5684 }
5685 }
5686 fn nametable_address(&self, addr: u16) -> u16 {
5687 // Horizontal mirroring: tables 0/1 -> bank 0, 2/3 -> bank 1.
5688 let table = ((addr.wrapping_sub(0x2000)) / 0x0400) & 0x03;
5689 let local = addr & 0x03FF;
5690 let phys = u16::from(table >= 2);
5691 phys * 0x0400 + local
5692 }
5693 }
5694
5695 fn fresh_ppu() -> (Ppu, TestBus) {
5696 let mut ppu = Ppu::new(PpuRegion::Ntsc);
5697 // Drive past the post-reset masking window.
5698 ppu.post_reset_mask_remaining = 0;
5699 (ppu, TestBus::new())
5700 }
5701
5702 // F1.1 (Fathom accuracy remediation) — palette backdrop-override.
5703 // When rendering is disabled and the VRAM address `v` points into palette
5704 // space ($3F00-$3FFF), the palette's shared address input is driven by `v`,
5705 // so the PPU outputs the color at `v & 0x1F` INSTEAD of the universal
5706 // backdrop ($3F00). This is a display artifact only — palette RAM is never
5707 // mutated, and rendering-enabled output is unchanged. See `NESdev` "PPU
5708 // palettes"; mirrors Mesen2 `NesPpu.cpp` / ares output-stage behavior.
5709 #[test]
5710 fn palette_backdrop_override_when_rendering_disabled() {
5711 let (mut p, _b) = fresh_ppu();
5712 p.mask = PpuMask::empty(); // rendering disabled
5713 p.palette_ram[palette_index(0x3F00)] = 0x0F; // backdrop
5714 p.palette_ram[palette_index(0x3F05)] = 0x16; // override target (red)
5715 p.scanline = 10;
5716 p.dot = 20; // pixel_x = 19 (visible)
5717 let off = (10usize * 256 + 19) * 4;
5718 let red = crate::palette::nes_color_to_rgba(0x16);
5719 let backdrop = crate::palette::nes_color_to_rgba(0x0F);
5720
5721 // v in palette range, rendering off -> output palette[v & 0x1F].
5722 p.v = 0x3F05;
5723 p.emit_pixel();
5724 assert_eq!(&p.framebuffer[off..off + 4], &red, "override -> palette[5]");
5725
5726 // v NOT in palette range, rendering off -> universal backdrop.
5727 p.v = 0x2000;
5728 p.emit_pixel();
5729 assert_eq!(&p.framebuffer[off..off + 4], &backdrop, "non-palette v");
5730
5731 // Rendering ENABLED with transparent BG -> backdrop, never overridden
5732 // (the fetch pipeline owns `v` while rendering).
5733 p.mask = PpuMask::SHOW_BG | PpuMask::SHOW_BG_LEFT;
5734 p.v = 0x3F05;
5735 p.emit_pixel();
5736 assert_eq!(
5737 &p.framebuffer[off..off + 4],
5738 &backdrop,
5739 "enabled -> no override"
5740 );
5741
5742 // $3F10 mirrors to $3F00 (universal backdrop), not a distinct entry.
5743 p.mask = PpuMask::empty();
5744 p.v = 0x3F10;
5745 p.emit_pixel();
5746 assert_eq!(
5747 &p.framebuffer[off..off + 4],
5748 &backdrop,
5749 "$3F10 mirrors backdrop"
5750 );
5751
5752 // The override is display-only: palette RAM is unmodified.
5753 assert_eq!(p.palette_ram[palette_index(0x3F05)], 0x16);
5754 }
5755
5756 // F1.2 (Fathom) — OAM / $2004 quirks. Both behaviors below are already
5757 // implemented and covered by the AccuracyCoin `$2004`/`Sprite0Hit` ROMs;
5758 // this is a FAST regression guard so an edit trips a unit test instead of
5759 // only the ~57s ROM battery. (The `OAMADDR & 0xF8` render-start copy is NOT
5760 // modeled on the DEFAULT revision — Mesen2, ares, and TriCNES all omit it as
5761 // a revision-dependent, oracle-less corner. As of v2.1.7 P5 the related
5762 // OAMADDR `$2003` write-during-render corruption is available as an opt-in
5763 // `PpuRevision::Rp2c02G` model; see `docs/accuracy-ledger.md`.)
5764 #[test]
5765 fn oam_2004_attribute_mask_and_oamaddr_257_320_forcing() {
5766 // (1) $2004 read of a sprite ATTRIBUTE byte (OAM offset & 3 == 2) masks
5767 // bits 4-2 with $E3 (they don't exist in OAM); other bytes are unmasked.
5768 // Read outside the rendering windows so the plain OAM path is taken.
5769 let (mut p, mut b) = fresh_ppu();
5770 p.mask = PpuMask::empty(); // rendering disabled
5771 p.scanline = 250; // vblank -> not a render scanline
5772 p.oam[2] = 0xFF; // attribute byte
5773 p.oam[1] = 0xFF; // tile byte (no mask)
5774 p.oam_addr = 2;
5775 assert_eq!(p.cpu_read_register(4, &mut b), 0xE3, "attr byte $E3-masked");
5776 p.oam_addr = 1;
5777 assert_eq!(
5778 p.cpu_read_register(4, &mut b),
5779 0xFF,
5780 "non-attr byte unmasked"
5781 );
5782
5783 // (2) OAMADDR is forced to 0 across dots 257-320 of a rendered scanline
5784 // (the sprite-tile-load interval), washing away a perturbed value.
5785 let (mut p, mut b) = fresh_ppu();
5786 p.mask = PpuMask::SHOW_BG | PpuMask::SHOW_SPRITE;
5787 p.scanline = 10; // visible render line
5788 p.dot = 256;
5789 p.oam_addr = 0x40; // perturbed; nothing but the 257-320 wash zeroes it
5790 for _ in 0..70 {
5791 p.tick(&mut b); // dot 256 -> 326, through the whole window
5792 }
5793 assert_eq!(p.oam_addr, 0, "OAMADDR washed to 0 across dots 257-320");
5794 }
5795
5796 // v2.1.4 F2.3 — optional OAM decay (opt-in, default-OFF). Models Mesen2's
5797 // `ReadSpriteRam`: a row un-refreshed for > OAM_DECAY_CPU_CYCLES CPU cycles
5798 // decays to `((sprAddr & 3) == 2) ? (sprAddr & 0xE3) : sprAddr` on the next
5799 // read. The read path used here is the plain (non-rendering) `$2004` read —
5800 // `mask` empty + a vblank scanline keeps out of the rendering / dot-1-64 /
5801 // dot-257-320 forcing windows.
5802 #[test]
5803 fn oam_decay_disabled_by_default_leaves_oam_untouched() {
5804 let (mut p, mut b) = fresh_ppu();
5805 p.mask = PpuMask::empty();
5806 p.scanline = 250; // vblank — plain OAM read path
5807 // Seed a distinctive value in row 0 (bytes 0..8).
5808 for i in 0..8u8 {
5809 p.oam[i as usize] = 0xAA;
5810 }
5811 // Advance the clock WELL past the decay window with no OAM access.
5812 p.dot_counter = (OAM_DECAY_CPU_CYCLES + 10_000) * 3;
5813 // Default is disabled — a read must return the seeded byte, and OAM must
5814 // be byte-for-byte unchanged (no decay pattern written).
5815 p.oam_addr = 0;
5816 assert!(!p.oam_decay_enabled(), "decay off by default");
5817 assert_eq!(
5818 p.cpu_read_register(4, &mut b),
5819 0xAA,
5820 "no decay when disabled"
5821 );
5822 for i in 0..8usize {
5823 assert_eq!(p.oam[i], 0xAA, "OAM row untouched when decay disabled");
5824 }
5825 }
5826
5827 #[test]
5828 fn oam_decay_enabled_decays_stale_row_to_mesen_pattern() {
5829 let (mut p, mut b) = fresh_ppu();
5830 p.set_oam_decay(true);
5831 p.mask = PpuMask::empty();
5832 p.scanline = 250;
5833 // Seed row 3 (OAM $18..$20) with a value distinct from the decay pattern.
5834 for a in 0x18u8..0x20 {
5835 p.oam[a as usize] = 0x5A;
5836 }
5837 // `set_oam_decay(true)` re-based the timestamps to the then-current cycle
5838 // (0). Advance PAST the window so the row is stale on the next read.
5839 p.dot_counter = (OAM_DECAY_CPU_CYCLES + 1) * 3;
5840 // Read byte $1A (an attribute byte: $1A & 3 == 2) — the whole row decays
5841 // first, then the (now-decayed) byte is returned. Expected decay byte for
5842 // $1A = $1A & 0xE3 = $02; but `$2004` additionally $E3-masks an attr byte
5843 // on the way out ($02 & $E3 == $02), so the observed value is $02.
5844 p.oam_addr = 0x1A;
5845 let v = p.cpu_read_register(4, &mut b);
5846 assert_eq!(v, 0x1A & 0xE3, "attribute byte decays to sprAddr & 0xE3");
5847 // The full row now holds the canonical pattern.
5848 for a in 0x18u8..0x20 {
5849 let expect = if a & 0x03 == 0x02 { a & 0xE3 } else { a };
5850 assert_eq!(p.oam[a as usize], expect, "row byte ${a:02X} decayed");
5851 }
5852 }
5853
5854 #[test]
5855 fn oam_decay_access_within_window_refreshes_row() {
5856 let (mut p, mut b) = fresh_ppu();
5857 p.set_oam_decay(true);
5858 p.mask = PpuMask::empty();
5859 p.scanline = 250;
5860 for a in 0x18u8..0x20 {
5861 p.oam[a as usize] = 0x5A;
5862 }
5863 // Touch the row just before the window closes (elapsed == threshold →
5864 // still a refresh, not a decay), which re-stamps the timestamp.
5865 p.dot_counter = OAM_DECAY_CPU_CYCLES * 3;
5866 p.oam_addr = 0x18;
5867 assert_eq!(
5868 p.cpu_read_register(4, &mut b),
5869 0x5A,
5870 "in-window read: no decay"
5871 );
5872 // Advance another (threshold) cycles from the refresh point — still within
5873 // the window relative to the refreshed timestamp, so no decay.
5874 p.dot_counter += OAM_DECAY_CPU_CYCLES * 3;
5875 p.oam_addr = 0x19;
5876 assert_eq!(
5877 p.cpu_read_register(4, &mut b),
5878 0x5A,
5879 "refresh kept row alive"
5880 );
5881 for a in 0x18u8..0x20 {
5882 assert_eq!(p.oam[a as usize], 0x5A, "row still holds seeded data");
5883 }
5884 }
5885
5886 #[test]
5887 fn oam_decay_is_pal_disabled() {
5888 // PAL's frequent refresh cadence masks decay, so the model never acts
5889 // there even when enabled (matches Mesen2). Same stale-row setup as the
5890 // NTSC decay test, but on PAL the read must NOT decay.
5891 let mut p = Ppu::new(PpuRegion::Pal);
5892 p.post_reset_mask_remaining = 0;
5893 let mut b = TestBus::new();
5894 p.set_oam_decay(true);
5895 p.mask = PpuMask::empty();
5896 p.scanline = 250;
5897 for a in 0x18u8..0x20 {
5898 p.oam[a as usize] = 0x5A;
5899 }
5900 p.dot_counter = (OAM_DECAY_CPU_CYCLES + 1) * 3;
5901 p.oam_addr = 0x18;
5902 assert_eq!(p.cpu_read_register(4, &mut b), 0x5A, "PAL: decay disabled");
5903 for a in 0x18u8..0x20 {
5904 assert_eq!(p.oam[a as usize], 0x5A, "PAL row untouched");
5905 }
5906 }
5907
5908 #[test]
5909 fn oam_decay_write_refreshes_row() {
5910 let (mut p, mut b) = fresh_ppu();
5911 p.set_oam_decay(true);
5912 p.mask = PpuMask::empty();
5913 p.scanline = 250;
5914 for a in 0x18u8..0x20 {
5915 p.oam[a as usize] = 0x5A;
5916 }
5917 // A `$2004` write of row 3 well past the window still refreshes it, so a
5918 // subsequent in-window read of that row does not decay.
5919 p.dot_counter = (OAM_DECAY_CPU_CYCLES + 5_000) * 3;
5920 p.oam_addr = 0x18;
5921 p.cpu_write_register(4, 0x33, &mut b); // writes $18, refreshes row 3
5922 // Read $19 (same row) a short time later — within the window of the write.
5923 p.dot_counter += 10 * 3;
5924 p.oam_addr = 0x19;
5925 assert_eq!(p.cpu_read_register(4, &mut b), 0x5A, "write kept row alive");
5926 }
5927
5928 // v2.1.7 P5 — PPU revision + power-up palette (opt-in, default-off).
5929
5930 #[test]
5931 fn revision_defaults_to_rp2c02h_no_corruption() {
5932 let (p, _b) = fresh_ppu();
5933 assert_eq!(p.revision(), PpuRevision::Rp2c02H, "default revision");
5934 assert!(
5935 !p.revision().models_oamaddr_corruption(),
5936 "default revision models no OAMADDR corruption"
5937 );
5938 }
5939
5940 #[test]
5941 fn default_revision_2003_write_during_render_does_not_corrupt() {
5942 // On the default revision a $2003 write mid-render must NOT arm any OAM
5943 // corruption — the byte-identity guarantee.
5944 let (mut p, mut b) = fresh_ppu();
5945 p.mask = PpuMask::SHOW_BG | PpuMask::SHOW_SPRITE;
5946 p.scanline = 10; // visible render line
5947 // Distinct row-0 vs row-1 so a spurious copy would be observable.
5948 for i in 0..8u8 {
5949 p.oam[i as usize] = 0x11;
5950 p.oam[8 + i as usize] = 0x22;
5951 }
5952 p.cpu_write_register(3, 0x08, &mut b); // OAMADDR = row 1
5953 assert!(
5954 !p.oam_corruption_pending,
5955 "default revision: no corruption armed"
5956 );
5957 }
5958
5959 #[test]
5960 fn rp2c02g_2003_write_during_render_arms_and_corrupts_row() {
5961 // On the earlier `Rp2c02G` revision a $2003 write while rendering is
5962 // active arms the row-copy corruption; committing it copies row 0 over
5963 // the targeted row (index = value >> 3).
5964 let (mut p, mut b) = fresh_ppu();
5965 p.set_revision(PpuRevision::Rp2c02G);
5966 p.mask = PpuMask::SHOW_BG | PpuMask::SHOW_SPRITE;
5967 p.scanline = 10; // visible render line
5968 for i in 0..8u8 {
5969 p.oam[i as usize] = 0x11; // row 0
5970 p.oam[8 + i as usize] = 0x22; // row 1 (target)
5971 }
5972 p.cpu_write_register(3, 0x08, &mut b); // OAMADDR = 0x08 → row index 1
5973 assert!(p.oam_corruption_pending, "Rp2c02G: corruption armed");
5974 assert_eq!(p.oam_corruption_index, 1, "targets row 1");
5975 // Commit and verify row 1 now mirrors row 0.
5976 p.process_oam_corruption();
5977 for i in 0..8u8 {
5978 assert_eq!(
5979 p.oam[8 + i as usize],
5980 0x11,
5981 "row 1 byte {i} corrupted from row 0"
5982 );
5983 }
5984 }
5985
5986 #[test]
5987 fn rp2c02g_2003_write_outside_render_does_not_corrupt() {
5988 // Even on the corrupting revision, a $2003 write with rendering disabled
5989 // (or in vblank) must NOT arm corruption — the glitch is render-gated.
5990 let (mut p, mut b) = fresh_ppu();
5991 p.set_revision(PpuRevision::Rp2c02G);
5992 p.mask = PpuMask::empty(); // rendering disabled
5993 p.scanline = 250; // vblank
5994 p.cpu_write_register(3, 0x08, &mut b);
5995 assert!(
5996 !p.oam_corruption_pending,
5997 "Rp2c02G but no rendering: no corruption"
5998 );
5999 }
6000
6001 #[test]
6002 fn power_up_palette_defaults_zeroed() {
6003 let (p, _b) = fresh_ppu();
6004 assert_eq!(p.power_up_palette(), PaletteInit::Zeroed, "default palette");
6005 assert_eq!(
6006 p.palette_ram, [0u8; 32],
6007 "default power-up palette all-zero"
6008 );
6009 }
6010
6011 #[test]
6012 fn power_up_palette_blargg_applies_masked_pattern() {
6013 let (mut p, _b) = fresh_ppu();
6014 p.apply_power_up_palette(PaletteInit::Blargg);
6015 assert_eq!(p.power_up_palette(), PaletteInit::Blargg);
6016 // Byte 0 = 0x09, an attr-index that survives the 6-bit mask untouched.
6017 assert_eq!(p.palette_ram[0], 0x09, "Blargg byte 0");
6018 // Every cell must be 6-bit masked (matching a `$2007` write path).
6019 for (i, &b) in p.palette_ram.iter().enumerate() {
6020 assert_eq!(b, BLARGG_POWER_UP_PALETTE[i] & 0x3F, "cell {i} masked");
6021 }
6022 // Re-applying Zeroed restores the byte-identical default state.
6023 p.apply_power_up_palette(PaletteInit::Zeroed);
6024 assert_eq!(p.palette_ram, [0u8; 32], "re-zeroed");
6025 }
6026
6027 // F1.3 (Fathom) — PPU open-bus refresh map. The Blargg `ppu_open_bus` table
6028 // is: a read DRIVES (and refreshes) some bits and passes others through from
6029 // the decay latch. $2000-$2003/$2005/$2006 = all decay; $2004 + $2007
6030 // (non-palette) = all driven; $2002 = `---D DDDD` (bits 7-5 driven); $2007
6031 // palette = `DD-- ----` (bits 7-6 decay). The $2002 low-5 case is covered by
6032 // `ppustatus_*` above; this locks the $2007-palette and write-only cases.
6033 #[test]
6034 fn open_bus_refresh_map_2007_palette_and_write_only() {
6035 // Reading a WRITE-ONLY register drives no bits -> the full decay latch.
6036 let (mut p, mut b) = fresh_ppu();
6037 p.open_bus = 0xA5;
6038 assert_eq!(
6039 p.cpu_read_register(0, &mut b),
6040 0xA5,
6041 "$2000 read = pure open bus"
6042 );
6043
6044 // $2007 PALETTE read drives bits 5-0 (palette) and passes bits 7-6 from
6045 // open bus (Blargg map: palette = `DD-- ----`).
6046 let (mut p, mut b) = fresh_ppu();
6047 p.open_bus = 0xFF; // bits 7-6 set
6048 p.mask = PpuMask::empty(); // no render-window $FF path
6049 p.v = 0x3F00;
6050 p.palette_ram[palette_index(0x3F00)] = 0x15;
6051 assert_eq!(
6052 p.cpu_read_register(7, &mut b),
6053 0x15 | 0xC0,
6054 "$2007 palette: bits 5-0 palette, 7-6 open bus"
6055 );
6056 }
6057
6058 #[test]
6059 fn ppustatus_read_clears_vbl_and_w() {
6060 let (mut p, mut b) = fresh_ppu();
6061 p.status.insert(PpuStatus::VBLANK);
6062 p.w = true;
6063 let v = p.cpu_read_register(2, &mut b);
6064 assert!(v & 0x80 != 0, "VBL should have been set on read");
6065 assert!(!p.status.contains(PpuStatus::VBLANK));
6066 assert!(!p.w);
6067 }
6068
6069 #[test]
6070 fn default_ppu_uses_composite_palette_no_2c05() {
6071 let (p, _b) = fresh_ppu();
6072 assert_eq!(p.active_palette, crate::palette::PpuPalette::Composite2C02);
6073 assert!(!p.is_2c05);
6074 // map_register is the identity on a non-2C05 PPU.
6075 for r in 0u8..8 {
6076 assert_eq!(p.map_register(r), r);
6077 }
6078 }
6079
6080 #[test]
6081 fn c2c05_swaps_2000_and_2001() {
6082 let (mut p, mut b) = fresh_ppu();
6083 p.set_palette(crate::palette::PpuPalette::Rgb2C05, true, 0x3D);
6084 // A write to $2000 (reg 0) on a 2C05 sets MASK; a write to $2001 sets
6085 // CTRL. Use a distinct, register-valid value for each.
6086 // PPUMASK bit 3 = SHOW_BG. PPUCTRL bit 7 = NMI_ENABLE.
6087 p.cpu_write_register(0, 0b0000_1000, &mut b); // -> MASK SHOW_BG
6088 assert!(p.mask.contains(PpuMask::SHOW_BG), "$2000 write set MASK");
6089 assert!(p.ctrl.is_empty(), "$2000 write did NOT touch CTRL");
6090
6091 let (mut p2, mut b2) = fresh_ppu();
6092 p2.set_palette(crate::palette::PpuPalette::Rgb2C05, true, 0x3D);
6093 p2.cpu_write_register(1, 0b1000_0000, &mut b2); // -> CTRL NMI_ENABLE
6094 assert!(
6095 p2.ctrl.contains(PpuCtrl::NMI_ENABLE),
6096 "$2001 write set CTRL on a 2C05"
6097 );
6098 assert!(p2.mask.is_empty(), "$2001 write did NOT touch MASK");
6099 }
6100
6101 #[test]
6102 fn c2c05_2002_returns_identifier_in_low_bits() {
6103 let (mut p, mut b) = fresh_ppu();
6104 p.set_palette(crate::palette::PpuPalette::Rgb2C05, true, 0x3D);
6105 // Set the VBL flag so the high bits are deterministic.
6106 p.status.insert(PpuStatus::VBLANK);
6107 let v = p.cpu_read_register(2, &mut b);
6108 // 2C05-02 id = $3D; low 5 bits => $3D & $1F = $1D.
6109 assert_eq!(v & 0x1F, 0x3D & 0x1F);
6110 assert!(v & 0x80 != 0, "VBL still reported in bit 7");
6111 }
6112
6113 #[test]
6114 fn non_2c05_2002_keeps_open_bus_low_bits() {
6115 // Without is_2c05, the low 5 bits remain open-bus (byte-identical to
6116 // the legacy path).
6117 let (mut p, mut b) = fresh_ppu();
6118 p.cpu_write_register(3, 0x1F, &mut b); // load open bus with $1F
6119 let v = p.cpu_read_register(2, &mut b);
6120 assert_eq!(v & 0x1F, 0x1F);
6121 }
6122
6123 #[test]
6124 fn ppustatus_low_5_bits_are_open_bus() {
6125 let (mut p, mut b) = fresh_ppu();
6126 // Touch the open-bus latch via a $2003 write.
6127 p.cpu_write_register(3, 0xAB, &mut b);
6128 p.status.insert(PpuStatus::VBLANK);
6129 let v = p.cpu_read_register(2, &mut b);
6130 // Bits 7-5 from status (only VBL set), bits 4-0 from open-bus (0x0B).
6131 assert_eq!(v & 0xE0, 0x80);
6132 assert_eq!(v & 0x1F, 0xAB & 0x1F);
6133 }
6134
6135 #[test]
6136 fn ppustatus_read_preserves_low_5_bits_of_open_bus_latch() {
6137 // Reading $2002 only refreshes the upper 3 bits of the open-bus
6138 // latch (the bits sourced from PPUSTATUS); the lower 5 bits must
6139 // retain their previous value. Required by the `open_bus_read_test`
6140 // sub-routine of `cpu_dummy_writes_ppumem.nes` (Bisqwit), which
6141 // performs `lda $2002; eor $2000` and expects the result to be 0
6142 // after AND-masking with 0x1F.
6143 let (mut p, mut b) = fresh_ppu();
6144 // Seed the open-bus latch via a $2003 write; pick a value with low
6145 // bits set so the bug-fix is observable.
6146 p.cpu_write_register(3, 0xAB, &mut b);
6147 p.status.insert(PpuStatus::VBLANK);
6148 // Read $2002 — should expose status high bits + latch low 5 bits.
6149 let v = p.cpu_read_register(2, &mut b);
6150 assert_eq!(v, 0x80 | (0xAB & 0x1F));
6151 // Now read $2000 (write-only): should return the refreshed latch
6152 // = (status & 0xE0) | (old_latch & 0x1F) — i.e., the same value.
6153 let after = p.cpu_read_register(0, &mut b);
6154 assert_eq!(
6155 after, v,
6156 "$2002 read must refresh only the high 3 bits of open-bus; \
6157 the low 5 bits must survive into subsequent reads of \
6158 write-only ports"
6159 );
6160 }
6161
6162 #[test]
6163 fn ppudata_buffered_read_returns_previous_byte() {
6164 let (mut p, mut b) = fresh_ppu();
6165 // CIRAM lives in the PPU now.
6166 p.ciram[0] = 0xAB;
6167 p.ciram[1] = 0xCD;
6168 // Set v to $2000.
6169 p.cpu_write_register(6, 0x20, &mut b);
6170 p.cpu_write_register(6, 0x00, &mut b);
6171 // First read: returns buffer (0), refills from $2000.
6172 let r1 = p.cpu_read_register(7, &mut b);
6173 assert_eq!(r1, 0);
6174 // Second read: returns refill (0xAB), refills with next byte.
6175 let r2 = p.cpu_read_register(7, &mut b);
6176 assert_eq!(r2, 0xAB);
6177 let r3 = p.cpu_read_register(7, &mut b);
6178 assert_eq!(r3, 0xCD);
6179 }
6180
6181 #[test]
6182 fn ppudata_palette_read_bypasses_buffer() {
6183 let (mut p, mut b) = fresh_ppu();
6184 p.palette_ram[0] = 0x12;
6185 // Stash a different value in the underlying nametable mirror so we
6186 // see the buffer get the underlying value, not the palette byte.
6187 p.ciram[0] = 0xCC;
6188 // Set v to $3F00.
6189 p.cpu_write_register(6, 0x3F, &mut b);
6190 p.cpu_write_register(6, 0x00, &mut b);
6191 let r = p.cpu_read_register(7, &mut b);
6192 // High 2 bits open-bus. Low 6 bits: 0x12.
6193 assert_eq!(r & 0x3F, 0x12);
6194 // Buffer should now contain underlying nametable mirror at $2F00
6195 // (= $3F00 & $2FFF), via horizontal mirroring tables 2/3 -> bank 1.
6196 }
6197
6198 #[test]
6199 fn ppudata_increment_1_or_32() {
6200 let (mut p, mut b) = fresh_ppu();
6201 p.cpu_write_register(6, 0x21, &mut b);
6202 p.cpu_write_register(6, 0x00, &mut b);
6203 // Increment by 1 default.
6204 p.cpu_read_register(7, &mut b);
6205 assert_eq!(p.v & 0x7FFF, 0x2101);
6206 // Switch to increment 32.
6207 p.cpu_write_register(0, PpuCtrl::VRAM_INCREMENT_32.bits(), &mut b);
6208 p.cpu_read_register(7, &mut b);
6209 assert_eq!(p.v & 0x7FFF, 0x2121);
6210 }
6211
6212 #[test]
6213 fn ppuctrl_post_reset_mask_window_blocks_writes() {
6214 let mut p = Ppu::new(PpuRegion::Ntsc);
6215 // Don't override post_reset_mask_remaining — it's the documented
6216 // count.
6217 let mut b = TestBus::new();
6218 p.cpu_write_register(0, PpuCtrl::NMI_ENABLE.bits(), &mut b);
6219 assert!(
6220 !p.ctrl.contains(PpuCtrl::NMI_ENABLE),
6221 "PPUCTRL write must be ignored during post-reset window"
6222 );
6223 // Drive past the window.
6224 for _ in 0..30_000 {
6225 p.on_cpu_cycle();
6226 }
6227 p.cpu_write_register(0, PpuCtrl::NMI_ENABLE.bits(), &mut b);
6228 assert!(p.ctrl.contains(PpuCtrl::NMI_ENABLE));
6229 }
6230
6231 #[test]
6232 fn ppuctrl_nmi_enable_during_vbl_asserts_nmi_immediately() {
6233 let (mut p, mut b) = fresh_ppu();
6234 p.status.insert(PpuStatus::VBLANK);
6235 // NMI not yet enabled => line low.
6236 assert!(!p.nmi_line);
6237 p.cpu_write_register(0, PpuCtrl::NMI_ENABLE.bits(), &mut b);
6238 assert!(p.nmi_line);
6239 }
6240
6241 #[test]
6242 fn ppuscroll_two_writes_load_t_and_x() {
6243 let (mut p, mut b) = fresh_ppu();
6244 p.cpu_write_register(5, 0b1010_1011, &mut b); // X = 0xAB
6245 // t bits 4-0 = X[7:3] = 0b10101 = 0x15. x = X[2:0] = 0b011 = 0x03.
6246 assert_eq!(p.t & 0x001F, 0x15);
6247 assert_eq!(p.x, 0x03);
6248 assert!(p.w);
6249 p.cpu_write_register(5, 0b0101_1100, &mut b); // Y = 0x5C
6250 // t bits 14-12 = Y[2:0] = 0b100, t bits 9-5 = Y[7:3] = 0b01011.
6251 assert_eq!((p.t >> 12) & 0x07, 0x04);
6252 assert_eq!((p.t >> 5) & 0x1F, 0x0B);
6253 assert!(!p.w);
6254 }
6255
6256 #[test]
6257 fn ppuaddr_two_writes_copy_t_to_v() {
6258 let (mut p, mut b) = fresh_ppu();
6259 p.cpu_write_register(6, 0x3F, &mut b); // high
6260 // After first write t bits 13-8 = 0x3F & 0x3F; bit 14 cleared.
6261 assert_eq!((p.t >> 8) & 0x7F, 0x3F);
6262 assert!(p.w);
6263 p.cpu_write_register(6, 0x10, &mut b); // low; copy t to v
6264 assert_eq!(p.v, 0x3F10);
6265 assert!(!p.w);
6266 }
6267
6268 #[test]
6269 fn vbl_set_and_nmi_at_scanline_241_dot_1() {
6270 let (mut p, mut b) = fresh_ppu();
6271 p.cpu_write_register(0, PpuCtrl::NMI_ENABLE.bits(), &mut b);
6272 // Tick until scanline 241 dot 1.
6273 // Starting at pre-render dot 0 (after construction we set scanline
6274 // = prerender_line, dot = 0). Tick advances first. We need to
6275 // reach scanline 241 dot 1. Simplest: just tick enough.
6276 let mut saw_nmi = false;
6277 for _ in 0..(341 * 263) {
6278 p.tick(&mut b);
6279 if p.nmi_line {
6280 saw_nmi = true;
6281 break;
6282 }
6283 }
6284 assert!(saw_nmi, "NMI must assert during VBlank");
6285 assert!(p.status.contains(PpuStatus::VBLANK));
6286 }
6287
6288 #[test]
6289 fn frame_complete_latch_fires_once_per_frame() {
6290 let (mut p, mut b) = fresh_ppu();
6291 // Tick a full frame's worth.
6292 let mut frames_seen = 0;
6293 for _ in 0..(341 * 262 * 2) {
6294 p.tick(&mut b);
6295 if p.take_frame_complete() {
6296 frames_seen += 1;
6297 }
6298 }
6299 assert!(frames_seen >= 2);
6300 }
6301
6302 #[test]
6303 fn index_framebuffer_mirrors_rgba_output() {
6304 // T-110-A1: the parallel palette-index framebuffer must be a faithful
6305 // index-space mirror of the RGBA framebuffer — for every emitted pixel,
6306 // `rgba_lut[index] == framebuffer[pixel]`. This is the contract that
6307 // makes the index buffer a safe, determinism-neutral output: it carries
6308 // exactly the LUT index used to produce the displayed RGBA.
6309 let (mut p, mut b) = fresh_ppu();
6310 // Enable background rendering so the full visible area is emitted.
6311 p.cpu_write_register(1, 0x08, &mut b); // PPUMASK: show background
6312 // Run two full frames so every visible pixel has been written.
6313 for _ in 0..(341 * 262 * 2) {
6314 p.tick(&mut b);
6315 }
6316 let fb = p.framebuffer();
6317 let idx = p.index_framebuffer();
6318 assert_eq!(idx.len(), FRAMEBUFFER_PIXELS);
6319 for (i, &lut_idx) in idx.iter().enumerate() {
6320 assert!((lut_idx as usize) < 512, "index in range at pixel {i}");
6321 let expected = p.rgba_lut[lut_idx as usize];
6322 assert_eq!(
6323 &fb[i * 4..i * 4 + 4],
6324 &expected,
6325 "pixel {i}: index {lut_idx} must reproduce the RGBA output"
6326 );
6327 }
6328 }
6329
6330 #[test]
6331 fn ntsc_phase_in_range_and_crawls() {
6332 // The per-frame NTSC phase must stay in 0..=2 (NTSC) and visit more than
6333 // one value across frames (the dot-crawl the filter reproduces).
6334 let (mut p, mut b) = fresh_ppu();
6335 p.cpu_write_register(1, 0x08, &mut b); // rendering on (odd-frame skip active)
6336 let mut seen = [false; 3];
6337 for _ in 0..(341 * 262 * 8) {
6338 p.tick(&mut b);
6339 if p.take_frame_complete() {
6340 let ph = p.ntsc_phase();
6341 assert!(ph <= 2, "NTSC phase {ph} must be 0..=2");
6342 seen[ph as usize] = true;
6343 }
6344 }
6345 let distinct = seen.iter().filter(|&&s| s).count();
6346 assert!(
6347 distinct >= 2,
6348 "phase must crawl across frames (saw {distinct})"
6349 );
6350 }
6351
6352 #[test]
6353 fn palette_mirrors_3f10_alias_3f00() {
6354 let (mut p, mut b) = fresh_ppu();
6355 p.cpu_write_register(6, 0x3F, &mut b);
6356 p.cpu_write_register(6, 0x10, &mut b); // v = $3F10
6357 p.cpu_write_register(7, 0x21, &mut b); // write palette
6358 // The mirror should land at index 0 (= $3F00).
6359 assert_eq!(p.palette_ram[0], 0x21);
6360 assert_eq!(p.palette_ram[0x10], 0); // not actually written
6361 }
6362
6363 #[test]
6364 fn oamdata_write_increments_oamaddr() {
6365 let (mut p, mut b) = fresh_ppu();
6366 p.oam_addr = 0x40;
6367 p.cpu_write_register(4, 0xCC, &mut b);
6368 assert_eq!(p.oam[0x40], 0xCC);
6369 assert_eq!(p.oam_addr, 0x41);
6370 }
6371
6372 /// Diagnostic: with standard MMC3 layout (BG=$0000, sprites=$1000)
6373 /// and rendering enabled, the PPU should produce exactly 241 A12
6374 /// rising edges per NTSC frame (240 visible scanlines + 1 pre-render
6375 /// scanline). This is what MMC3's IRQ counter clocks on.
6376 #[test]
6377 fn a12_rising_edges_match_241_per_ntsc_frame_standard_layout() {
6378 struct CountingBus {
6379 chr: [u8; 0x2000],
6380 rises: u32,
6381 last_a12: bool,
6382 // diagnostic: count rises in each phase
6383 rises_visible: u32,
6384 rises_prerender: u32,
6385 cur_scanline_is_pre: bool,
6386 }
6387 impl PpuBus for CountingBus {
6388 fn ppu_read(&mut self, addr: u16) -> u8 {
6389 if addr < 0x2000 {
6390 self.chr[addr as usize]
6391 } else {
6392 0
6393 }
6394 }
6395 fn ppu_write(&mut self, addr: u16, value: u8) {
6396 if addr < 0x2000 {
6397 self.chr[addr as usize] = value;
6398 }
6399 }
6400 fn notify_a12(&mut self, level: bool) {
6401 if level != self.last_a12 {
6402 if level {
6403 self.rises += 1;
6404 if self.cur_scanline_is_pre {
6405 self.rises_prerender += 1;
6406 } else {
6407 self.rises_visible += 1;
6408 }
6409 }
6410 self.last_a12 = level;
6411 }
6412 }
6413 fn nametable_address(&self, addr: u16) -> u16 {
6414 let table = ((addr.wrapping_sub(0x2000)) / 0x0400) & 0x03;
6415 let local = addr & 0x03FF;
6416 let phys = u16::from(table >= 2);
6417 phys * 0x0400 + local
6418 }
6419 }
6420 let mut p = Ppu::new(PpuRegion::Ntsc);
6421 p.post_reset_mask_remaining = 0;
6422 let mut b = CountingBus {
6423 chr: [0u8; 0x2000],
6424 rises: 0,
6425 last_a12: false,
6426 rises_visible: 0,
6427 rises_prerender: 0,
6428 cur_scanline_is_pre: false,
6429 };
6430 // Standard layout: BG=$0000 (PPUCTRL bit 4 = 0),
6431 // sprites=$1000 (PPUCTRL bit 3 = 1).
6432 p.cpu_write_register(0, PpuCtrl::SPRITE_PATTERN_HIGH.bits(), &mut b);
6433 // Enable BG + sprite rendering (PPUMASK bits 3 + 4).
6434 p.cpu_write_register(1, (PpuMask::SHOW_BG | PpuMask::SHOW_SPRITE).bits(), &mut b);
6435
6436 // Advance past a complete frame. Reset rise counters at the start of
6437 // the frame and then tick exactly one NTSC frame (262 scanlines × 341
6438 // dots — odd-frame skip not triggered because frame counter is 0).
6439 // First, advance to scanline 0 dot 0.
6440 while !(p.scanline() == 0 && p.dot() == 0) {
6441 p.tick(&mut b);
6442 }
6443 b.rises = 0;
6444 b.rises_visible = 0;
6445 b.rises_prerender = 0;
6446 b.last_a12 = false;
6447 // Now run exactly one frame.
6448 let start_frame = p.frame();
6449 while p.frame() == start_frame {
6450 b.cur_scanline_is_pre = p.scanline() == PpuRegion::Ntsc.prerender_line();
6451 p.tick(&mut b);
6452 }
6453 assert_eq!(
6454 b.rises, 241,
6455 "expected 241 A12 rises per NTSC frame (240 visible + 1 pre-render), \
6456 got {} (visible={}, prerender={})",
6457 b.rises, b.rises_visible, b.rises_prerender
6458 );
6459 }
6460
6461 #[test]
6462 fn a12_transitions_notify_bus() {
6463 let (mut p, mut b) = fresh_ppu();
6464 // Set v to $1234 (A12 high), then $0234 (A12 low) — two transitions.
6465 p.cpu_write_register(6, 0x12, &mut b);
6466 p.cpu_write_register(6, 0x34, &mut b);
6467 assert_eq!(b.a12_count, 1);
6468 p.cpu_write_register(6, 0x02, &mut b);
6469 p.cpu_write_register(6, 0x34, &mut b);
6470 assert_eq!(b.a12_count, 2);
6471 }
6472
6473 // -------------------------------------------------------------------
6474 // T-23-002: sprite-evaluation FSM with buggy n+m overflow increment.
6475 // -------------------------------------------------------------------
6476
6477 /// Regression: 8 in-range sprites must populate secondary OAM and
6478 /// leave `spr_count == 8` without setting the `SPRITE_OVERFLOW` flag,
6479 /// PROVIDED the diagonal-read scan over the remaining 56 sprites
6480 /// never lands on an in-range byte. To pin that condition we fill
6481 /// the entire off-screen OAM region with 0xF0, so every byte the
6482 /// buggy `n+m` walk could land on reads as y=240 (out of range).
6483 #[test]
6484 fn sprite_eval_8_sprites_no_overflow() {
6485 let (mut p, _b) = fresh_ppu();
6486 p.scanline = 0;
6487 // 8 in-range sprites with non-zero, non-conflicting byte values
6488 // that don't read as "in-range y" if the diagonal walk hits them.
6489 for i in 0..8 {
6490 let base = i * 4;
6491 p.oam[base] = 0; // y = 0 (in range)
6492 p.oam[base + 1] = 0xF0; // tile (also out of range if read as y)
6493 p.oam[base + 2] = 0xF0;
6494 p.oam[base + 3] = 0xF0;
6495 }
6496 // Sprites 8..63: every byte = 0xF0 so diagonal read finds nothing.
6497 for i in 8..64 {
6498 for j in 0..4 {
6499 p.oam[i * 4 + j] = 0xF0;
6500 }
6501 }
6502 run_per_dot_fsm(&mut p);
6503 assert_eq!(p.spr_count, 8, "exactly 8 in-range sprites must fill");
6504 assert!(
6505 !p.status.contains(PpuStatus::SPRITE_OVERFLOW),
6506 "8 sprites + all-off-screen-remainder is not overflow"
6507 );
6508 }
6509
6510 /// The headline case: 9 in-range sprites must set `SPRITE_OVERFLOW`.
6511 /// On real hardware the buggy `n+m` increment reads the wrong byte
6512 /// of sprite #9, but here sprite #9 is in-range and its y-byte
6513 /// (which the diagonal walk reads first at n=9, m=0 if found==8)
6514 /// is in-range, so the flag fires.
6515 #[test]
6516 fn sprite_eval_9_sprites_sets_overflow() {
6517 let (mut p, _b) = fresh_ppu();
6518 p.scanline = 0;
6519 for i in 0..9 {
6520 let base = i * 4;
6521 p.oam[base] = 0; // y = 0 (in range)
6522 p.oam[base + 1] = 0xF0; // tile (out of range as y)
6523 p.oam[base + 2] = 0xF0;
6524 p.oam[base + 3] = 0xF0;
6525 }
6526 for i in 9..64 {
6527 for j in 0..4 {
6528 p.oam[i * 4 + j] = 0xF0;
6529 }
6530 }
6531 run_per_dot_fsm(&mut p);
6532 assert_eq!(p.spr_count, 8, "secondary OAM holds first 8 only");
6533 assert!(
6534 p.status.contains(PpuStatus::SPRITE_OVERFLOW),
6535 "9 in-range sprites must set overflow"
6536 );
6537 }
6538
6539 /// Empty OAM: no in-range sprites, no overflow.
6540 #[test]
6541 fn sprite_eval_empty_oam_no_overflow() {
6542 let (mut p, _b) = fresh_ppu();
6543 p.scanline = 0;
6544 // Every byte off-screen, so the eval pass never finds anything
6545 // and never enters overflow-detection mode.
6546 for byte in &mut p.oam {
6547 *byte = 0xF0;
6548 }
6549 run_per_dot_fsm(&mut p);
6550 assert_eq!(p.spr_count, 0);
6551 assert!(!p.status.contains(PpuStatus::SPRITE_OVERFLOW));
6552 }
6553
6554 /// The buggy `n+m` increment: when 8 sprites have been found, the
6555 /// overflow-detection FSM reads `OAM[n*4+m].y` and increments BOTH
6556 /// `n` and `m` together on each iteration. If sprite #9 is OUT of
6557 /// range but sprite #10's *non-y byte* (which the bug reads as a
6558 /// y-coordinate) happens to be in-range, the overflow flag will
6559 /// fire — that's the documented hardware quirk, not a bug in our
6560 /// FSM.
6561 ///
6562 /// Construct a case where:
6563 /// - Sprites 0..7 are in-range (fill secondary OAM, found = 8).
6564 /// - Sprite 8's y is far off-screen (y = 0xF0, normal y-read would
6565 /// say not-in-range).
6566 /// - Sprite 9's TILE byte (byte index 1, which the buggy m=1 read
6567 /// when n=9 lands on) is set to a value that, interpreted as y,
6568 /// would put the sprite on the next scanline.
6569 ///
6570 /// With the buggy FSM the overflow flag fires because the diagonal
6571 /// read finds sprite 9's tile byte (= 0) as a "y" that maps to a
6572 /// row in-range for an 8-tall sprite. A correct (non-buggy) FSM
6573 /// reading sprite #8's y first would NOT fire because sprite 8 is
6574 /// out of range.
6575 ///
6576 /// This test pins the buggy behavior; flipping it to non-buggy
6577 /// would change the assertion direction.
6578 #[test]
6579 fn sprite_eval_buggy_n_plus_m_finds_diagonal_overflow() {
6580 let (mut p, _b) = fresh_ppu();
6581 p.scanline = 0;
6582 // Start with the entire OAM off-screen.
6583 for byte in &mut p.oam {
6584 *byte = 0xF0;
6585 }
6586 // Sprites 0..7 in-range with all non-y bytes off-screen.
6587 for i in 0..8 {
6588 let base = i * 4;
6589 p.oam[base] = 0; // y = 0 (in range)
6590 // bytes 1,2,3 keep the 0xF0 fill so a stray read
6591 // doesn't mis-fire the diagonal test.
6592 }
6593 // Sprite 8 y is 0xF0 (from the bulk fill) — out of range.
6594 // Sprite 9 tile byte (OAM[9*4+1]) is the second diagonal read
6595 // target (after sprite 8's y). Setting it to 0 (= in-range y)
6596 // forces the buggy FSM to fire overflow on the SECOND iteration
6597 // of the inner loop.
6598 p.oam[9 * 4 + 1] = 0;
6599 run_per_dot_fsm(&mut p);
6600 assert_eq!(p.spr_count, 8);
6601 assert!(
6602 p.status.contains(PpuStatus::SPRITE_OVERFLOW),
6603 "buggy n+m increment must find the diagonal-read overflow at sprite 9 byte 1"
6604 );
6605 }
6606
6607 // -------------------------------------------------------------------
6608 // Sprite-eval FSM regression corpus. Originally introduced as the
6609 // parallel-implementation firewall gating the B8 swap from single-
6610 // shot to per-dot FSM. The single-shot collapse was removed in B8c;
6611 // these tests are now the regression net pinning the FSM's observable
6612 // output against a straight-line reference implementation
6613 // (`reference_eval`).
6614 //
6615 // The corpus targets:
6616 // - Empty OAM (no in-range)
6617 // - Exactly 8 in-range (no overflow)
6618 // - 9+ in-range (clean overflow)
6619 // - Diagonal-read scenarios (sprite N out-of-range, sprite (N+k)'s
6620 // non-y byte in-range)
6621 // - 8x8 + 8x16 sprite heights
6622 // - Boundary scanlines (0, 1, 239, prerender)
6623 //
6624 // Random fuzz + structured edge cases combined give 1013 cases.
6625 // -------------------------------------------------------------------
6626
6627 /// Tiny xorshift PRNG so the test is hermetic (no `rand` dep).
6628 struct XorShift(u64);
6629 impl XorShift {
6630 const fn new(seed: u64) -> Self {
6631 Self(if seed == 0 {
6632 0xDEAD_BEEF_CAFE_BABE
6633 } else {
6634 seed
6635 })
6636 }
6637 const fn next_u64(&mut self) -> u64 {
6638 let mut x = self.0;
6639 x ^= x << 13;
6640 x ^= x >> 7;
6641 x ^= x << 17;
6642 self.0 = x;
6643 x
6644 }
6645 fn next_u8(&mut self) -> u8 {
6646 (self.next_u64() & 0xFF) as u8
6647 }
6648 }
6649
6650 /// Snapshot of the observable post-dot-256 state for equivalence
6651 /// comparison.
6652 #[derive(Debug, Clone, PartialEq, Eq)]
6653 struct EvalObservable {
6654 secondary_oam: [u8; 32],
6655 spr_count: u8,
6656 spr_zero_in_line: bool,
6657 overflow: bool,
6658 }
6659
6660 fn observe(p: &Ppu) -> EvalObservable {
6661 EvalObservable {
6662 secondary_oam: p.secondary_oam,
6663 spr_count: p.spr_count,
6664 spr_zero_in_line: p.spr_zero_in_line,
6665 overflow: p.status.contains(PpuStatus::SPRITE_OVERFLOW),
6666 }
6667 }
6668
6669 /// Build a fresh PPU and seed `oam`, `scanline`, and `ctrl` from the
6670 /// given parameters.
6671 fn build_case(oam: &[u8; 256], scanline: i16, ctrl: PpuCtrl) -> Ppu {
6672 let mut p = Ppu::new(PpuRegion::Ntsc);
6673 p.post_reset_mask_remaining = 0;
6674 p.oam.copy_from_slice(oam);
6675 p.scanline = scanline;
6676 p.ctrl = ctrl;
6677 // Reset the overflow flag so we can observe per-case sets.
6678 p.status.remove(PpuStatus::SPRITE_OVERFLOW);
6679 // Pre-fill secondary OAM with a poison value so the per-dot FSM's
6680 // clear phase is observable (single-shot also starts by writing
6681 // $FF into all 32 bytes, so the final state must match).
6682 p.secondary_oam = [0xAA; 32];
6683 p.spr_count = 0;
6684 p.spr_zero_in_line = false;
6685 p
6686 }
6687
6688 /// Drive the per-dot FSM through dots 0..=256 on `p`.
6689 fn run_per_dot_fsm(p: &mut Ppu) {
6690 for dot in 0..=256u16 {
6691 p.dot = dot;
6692 p.tick_sprite_eval_per_dot();
6693 }
6694 }
6695
6696 /// Run one case through the FSM and assert observable matches the
6697 /// expected pinned state. The expected state is built by computing
6698 /// the result in a non-buggy reference implementation (the
6699 /// `reference_eval` below).
6700 fn assert_case_matches(label: &str, oam: &[u8; 256], scanline: i16, ctrl: PpuCtrl) {
6701 let expected = reference_eval(oam, scanline, ctrl);
6702
6703 let mut pf = build_case(oam, scanline, ctrl);
6704 run_per_dot_fsm(&mut pf);
6705 let actual = observe(&pf);
6706
6707 assert_eq!(
6708 expected,
6709 actual,
6710 "FSM mismatch for case `{label}` \
6711 (scanline={scanline}, 8x16={}, sprite_zero_y={:#04x})",
6712 ctrl.contains(PpuCtrl::SPRITE_SIZE_16),
6713 oam[0],
6714 );
6715 }
6716
6717 /// Reference implementation: a straight-line sprite-eval emulation
6718 /// matching the 2C02's behavior, used as the golden expected output
6719 /// for the FSM regression corpus. Originally the FSM was validated
6720 /// against the old single-shot collapse via the 1013-case equivalence
6721 /// harness (B8a); after B8c removed the single-shot, this stand-alone
6722 /// reference plays the same role.
6723 fn reference_eval(oam: &[u8; 256], scanline: i16, ctrl: PpuCtrl) -> EvalObservable {
6724 // Y-test convention: see `tick_sprite_eval_per_dot` docstring.
6725 // Pre-render uses -1 (always-fail), visible uses the current
6726 // scanline; sprite Y=N renders on scanlines N+1..=N+h.
6727 let next_line: i16 = if scanline == PpuRegion::Ntsc.prerender_line() {
6728 -1
6729 } else {
6730 scanline
6731 };
6732 let sprite_height: i16 = if ctrl.contains(PpuCtrl::SPRITE_SIZE_16) {
6733 16
6734 } else {
6735 8
6736 };
6737
6738 let mut secondary_oam = [0xFFu8; 32];
6739 let mut found = 0u8;
6740 let mut spr_zero_in_line = false;
6741 let mut overflow = false;
6742
6743 let mut n_idx = 0usize;
6744 while n_idx < 64 {
6745 let base = n_idx * 4;
6746 let y = oam[base] as i16;
6747 let row = next_line - y;
6748 if row >= 0 && row < sprite_height {
6749 let sec_base = (found as usize) * 4;
6750 secondary_oam[sec_base] = oam[base];
6751 secondary_oam[sec_base + 1] = oam[base + 1];
6752 secondary_oam[sec_base + 2] = oam[base + 2];
6753 secondary_oam[sec_base + 3] = oam[base + 3];
6754 if n_idx == 0 {
6755 spr_zero_in_line = true;
6756 }
6757 found += 1;
6758 if found == 8 {
6759 n_idx += 1;
6760 let mut m = 0u8;
6761 while n_idx < 64 {
6762 let nb = n_idx * 4 + (m as usize);
6763 let by = oam[nb] as i16;
6764 let brow = next_line - by;
6765 if brow >= 0 && brow < sprite_height {
6766 overflow = true;
6767 break;
6768 }
6769 m = (m + 1) & 0x03;
6770 n_idx += 1;
6771 }
6772 break;
6773 }
6774 }
6775 n_idx += 1;
6776 }
6777
6778 EvalObservable {
6779 secondary_oam,
6780 spr_count: found,
6781 spr_zero_in_line,
6782 overflow,
6783 }
6784 }
6785
6786 #[test]
6787 fn sprite_fsm_equivalence_edge_cases() {
6788 // 1: empty OAM (all 0xFF y) -> no found, no overflow.
6789 let mut oam = [0xFFu8; 256];
6790 assert_case_matches("empty_oam_y_ff", &oam, 0, PpuCtrl::empty());
6791
6792 // 2: every byte 0xF0 (out of range) -> no found, no overflow.
6793 oam = [0xF0u8; 256];
6794 assert_case_matches("empty_oam_y_f0", &oam, 0, PpuCtrl::empty());
6795
6796 // 3: 8 in-range sprites, all other bytes 0xF0 -> 8 found, no
6797 // overflow.
6798 oam = [0xF0u8; 256];
6799 for i in 0..8 {
6800 oam[i * 4] = 0;
6801 }
6802 assert_case_matches("8_in_range", &oam, 0, PpuCtrl::empty());
6803
6804 // 4: 9 in-range sprites -> overflow set.
6805 oam = [0xF0u8; 256];
6806 for i in 0..9 {
6807 oam[i * 4] = 0;
6808 }
6809 assert_case_matches("9_in_range", &oam, 0, PpuCtrl::empty());
6810
6811 // 5: 8 in-range + diagonal-read overflow (sprite 9 byte 1 = 0
6812 // forces buggy n+m to fire).
6813 oam = [0xF0u8; 256];
6814 for i in 0..8 {
6815 oam[i * 4] = 0;
6816 }
6817 oam[9 * 4 + 1] = 0;
6818 assert_case_matches("diagonal_overflow", &oam, 0, PpuCtrl::empty());
6819
6820 // 6: 8x16 sprite mode.
6821 oam = [0xF0u8; 256];
6822 for i in 0..3 {
6823 oam[i * 4] = 0;
6824 }
6825 assert_case_matches("8x16_mode", &oam, 0, PpuCtrl::SPRITE_SIZE_16);
6826
6827 // 7: pre-render line (evaluates for scanline 0).
6828 oam = [0xF0u8; 256];
6829 for i in 0..5 {
6830 oam[i * 4] = 0;
6831 }
6832 let prerender = PpuRegion::Ntsc.prerender_line();
6833 assert_case_matches("prerender_line", &oam, prerender, PpuCtrl::empty());
6834
6835 // 8: last visible scanline.
6836 oam = [0xF0u8; 256];
6837 for i in 0..2 {
6838 oam[i * 4] = 239;
6839 }
6840 assert_case_matches("scanline_239", &oam, 238, PpuCtrl::empty());
6841
6842 // 9: sprite zero NOT in range -> spr_zero_in_line must stay false.
6843 oam = [0xF0u8; 256];
6844 oam[0] = 0xF0; // sprite 0 out of range
6845 for i in 1..3 {
6846 oam[i * 4] = 0;
6847 }
6848 assert_case_matches("zero_out_of_range", &oam, 0, PpuCtrl::empty());
6849
6850 // 10: sprite zero in range but not first -> still must be true
6851 // because sprite 0 is at OAM index 0.
6852 oam = [0xF0u8; 256];
6853 oam[0] = 0; // sprite 0 in range
6854 for i in 5..10 {
6855 oam[i * 4] = 0;
6856 }
6857 assert_case_matches("zero_in_range_plus_others", &oam, 0, PpuCtrl::empty());
6858
6859 // 11: exactly 1 in-range at the last possible sprite (sprite 63).
6860 oam = [0xF0u8; 256];
6861 oam[63 * 4] = 0;
6862 assert_case_matches("only_sprite_63", &oam, 0, PpuCtrl::empty());
6863
6864 // 12: 8 in-range scattered among the 64 entries.
6865 oam = [0xF0u8; 256];
6866 for (slot, &n) in [0u8, 5, 11, 18, 27, 35, 44, 55].iter().enumerate() {
6867 let _ = slot;
6868 oam[(n as usize) * 4] = 0;
6869 }
6870 assert_case_matches("8_scattered", &oam, 0, PpuCtrl::empty());
6871
6872 // 13: all 64 sprites in range -> 8 found + overflow.
6873 oam = [0u8; 256];
6874 for i in 0..64 {
6875 oam[i * 4] = 0; // y = 0
6876 oam[i * 4 + 1] = 0xAB;
6877 oam[i * 4 + 2] = 0xCD;
6878 oam[i * 4 + 3] = 0xEF;
6879 }
6880 assert_case_matches("all_64_in_range", &oam, 0, PpuCtrl::empty());
6881 }
6882
6883 #[test]
6884 fn sprite_fsm_equivalence_randomized_corpus() {
6885 // 1000 fully-random cases + the 13 edge cases above = 1013 total
6886 // regression checks. Each invocation runs the FSM on a random
6887 // OAM/scanline/ctrl seed and asserts observable equality with
6888 // the straight-line reference implementation.
6889 const N: usize = 1000;
6890 let mut rng = XorShift::new(0x1234_5678_9ABC_DEF0);
6891
6892 for case in 0..N {
6893 let mut oam = [0u8; 256];
6894 for b in &mut oam {
6895 *b = rng.next_u8();
6896 }
6897 // Choose scanline from {0..=239, prerender=261}. Use a bias
6898 // toward 0..=239 since that's the realistic case.
6899 let r = rng.next_u64();
6900 let scanline: i16 = if r.trailing_zeros() >= 5 {
6901 PpuRegion::Ntsc.prerender_line()
6902 } else {
6903 ((r >> 8) & 0xFF) as i16 % 240
6904 };
6905 // 8x16 mode in 1/4 of cases.
6906 let ctrl = if rng.next_u64().trailing_zeros() >= 2 {
6907 PpuCtrl::SPRITE_SIZE_16
6908 } else {
6909 PpuCtrl::empty()
6910 };
6911
6912 let expected = reference_eval(&oam, scanline, ctrl);
6913
6914 let mut pf = build_case(&oam, scanline, ctrl);
6915 run_per_dot_fsm(&mut pf);
6916 let actual = observe(&pf);
6917
6918 assert_eq!(
6919 expected,
6920 actual,
6921 "FSM regressed against reference at case #{case} \
6922 (scanline={scanline}, 8x16={}, oam[0]={:#04x})",
6923 ctrl.contains(PpuCtrl::SPRITE_SIZE_16),
6924 oam[0],
6925 );
6926 }
6927 }
6928
6929 /// Cascade A reproducer V3: mimics `AccuracyCoin`'s
6930 /// `VerifySpriteZeroHits` step 2 (the version that EXPECTS a hit).
6931 /// Sprite 0 at Y=5 X=8 tile $C0. BG tile $C0 at nametable $2C21
6932 /// (NT 3 col 1 row 1). v = $2C00.
6933 ///
6934 /// Tile $C0 has a SINGLE opaque pixel at (col=0, row=0). With v=$2C00,
6935 /// BG tile at NT 3 position $21 displays at screen pixels (8, 8).
6936 /// Sprite at (Y=5, X=8) tile $C0 draws at scanline 6 (per nesdev:
6937 /// sprite occupies scanlines Y+1..Y+8). Sprite tile $C0's only opaque
6938 /// pixel is (col 0, row 0) → screen (8, 6).
6939 ///
6940 /// Sprite (8, 6) vs BG (8, 8) — NO geometric overlap. The test asserts
6941 /// a hit IS expected here, which is impossible without sprite Y
6942 /// semantics being different from what nesdev documents. This unit
6943 /// test makes the discrepancy concrete so it can be investigated
6944 /// against Mesen2 or other reference emulators.
6945 #[test]
6946 fn cascade_a_verify_sprite_zero_hits_step2() {
6947 let (mut p, mut b) = fresh_ppu();
6948 // Pin the PPU to (prerender, dot=0) so this diagnostic harness runs
6949 // through exactly one frame starting from the prerender boundary.
6950 // Required because Ppu::new() now starts at (prerender, dot=340)
6951 // per Session-13 Option B (close the +344-dot offset vs Mesen2);
6952 // without this reset the test's "advance one frame" loop would begin
6953 // mid-prerender and the sprite-zero-hit window would shift relative
6954 // to the BG-pipeline cycle-9 reload point this test was designed to
6955 // characterise (see docs/audit/cascade-a-investigation-2026-05-19.md
6956 // and docs/audit/session-13-cpu-boot-fix-2026-05-21.md).
6957 p.dot = 0;
6958 let tile_c0_base = 0xC0 * 16;
6959 // Tile $C0: only the (col 0, row 0) pixel is opaque (lo=$80 hi=$80).
6960 b.chr[tile_c0_base] = 0x80;
6961 b.chr[tile_c0_base + 8] = 0x80;
6962 // Tile $24: fully transparent (all-zero bytes already).
6963 // Fill NT 3 (bank 1 of CIRAM, horizontal mirroring) with $24, then
6964 // write $C0 at position $21.
6965 for i in 0..0x400 {
6966 p.ciram[0x400 + i] = 0x24;
6967 }
6968 p.ciram[0x400 + 0x021] = 0xC0;
6969 // OAM page mimics OAM DMA from a $FF-cleared page + sprite 0 init.
6970 for i in 0..256 {
6971 p.oam[i] = 0xFF;
6972 }
6973 p.oam[0] = 0x05; // Y = 5 (step 2)
6974 p.oam[1] = 0xC0; // CHR
6975 p.oam[2] = 0x03; // ATT
6976 p.oam[3] = 0x08; // X = 8
6977 // v = $2C00 (NT 3 top-left).
6978 p.v = 0x2C00;
6979 p.t = 0x2C00;
6980 // PPUCTRL = 0 (both pattern tables at $0000).
6981 p.ctrl = PpuCtrl::empty();
6982 // Enable rendering.
6983 let mask = PpuMask::SHOW_BG
6984 | PpuMask::SHOW_SPRITE
6985 | PpuMask::SHOW_BG_LEFT
6986 | PpuMask::SHOW_SPRITE_LEFT;
6987 p.mask = mask;
6988 p.mask_skip_pipe1 = mask;
6989 p.mask_for_skip_check = mask;
6990 p.status = PpuStatus::empty();
6991 // Advance ~1 full frame to allow sprite-zero hit to fire if it should.
6992 for _ in 0..(262 * 341) {
6993 p.tick(&mut b);
6994 }
6995 let hit = p.status.contains(PpuStatus::SPRITE_ZERO_HIT);
6996 // POST-FIX EXPECTATION: with the cycle-9 reload + post-emit shift
6997 // BG-pipeline correction landed (see
6998 // `docs/audit/cascade-a-investigation-2026-05-19.md`), tile $C0's
6999 // single opaque BG pixel lands at screen column 8 (PPU dot 9 of
7000 // scanline 6), exactly overlapping the sprite-zero opaque pixel
7001 // at (8, 6) → SPRITE-ZERO HIT must fire.
7002 //
7003 // The test ROM's geometry: sprite Y=5 X=8 tile $C0 has its only
7004 // opaque pixel at sprite-local (col 0, row 0) → screen (8, 6).
7005 // BG tile $C0 at NT 3 position $21 with v=$2C00 (fine Y=2,
7006 // coarse Y=0) renders at scanline 6, screen column 8, with its
7007 // only opaque pixel matching → overlap → hit.
7008 assert!(
7009 hit,
7010 "BG-pipeline fix regression: sprite-zero hit must fire for \
7011 VerifySpriteZeroHits step 2 (BG opaque at (8,6) overlaps \
7012 sprite-zero opaque at (8,6)) — see \
7013 docs/audit/cascade-a-investigation-2026-05-19.md."
7014 );
7015 }
7016
7017 /// Cascade A reproducer V2: start in VBL, enable rendering via the
7018 /// CPU-visible `$2001` write (with the 2-PPU-clock pipeline delay), do
7019 /// OAM DMA via the CPU-visible `$2003 + $2004` writes, then advance
7020 /// past pre-render → scanline 0 → scanline 1. More faithful to the
7021 /// real ROM execution path than the V1 reproducer.
7022 #[test]
7023 fn cascade_a_sprite_zero_hit_y0_x8_via_register_writes() {
7024 let (mut p, mut b) = fresh_ppu();
7025 // Load tile $FC into pattern table 0 fully-opaque.
7026 let tile_fc_base = 0xFC * 16;
7027 for row in 0..8 {
7028 b.chr[tile_fc_base + row] = 0xFF;
7029 b.chr[tile_fc_base + 8 + row] = 0x00;
7030 }
7031 // Write nametable $2001 = $FC via $2006 + $2007 (CPU-visible path).
7032 p.cpu_write_register(6, 0x20, &mut b); // hi
7033 p.cpu_write_register(6, 0x01, &mut b); // lo (v = $2001)
7034 p.cpu_write_register(7, 0xFC, &mut b);
7035 // Reset scroll: v = $2000 via $2006 + $2006.
7036 p.cpu_write_register(6, 0x20, &mut b);
7037 p.cpu_write_register(6, 0x00, &mut b);
7038 // Mimic the ROM's OAM page: ClearPage2 fills with $FF, then
7039 // InitializeSpriteZero writes sprite 0. So OAM[0..4] is the sprite,
7040 // OAM[4..256] is $FF (Y=$FF -> off-screen).
7041 for i in 0..256 {
7042 p.oam[i] = 0xFF;
7043 }
7044 // OAM DMA: write sprite 0 via $2003 (OAMADDR) + $2004 (OAMDATA).
7045 p.cpu_write_register(3, 0x00, &mut b); // OAMADDR = 0
7046 p.cpu_write_register(4, 0x00, &mut b); // sprite 0 Y = 0
7047 p.cpu_write_register(4, 0xFC, &mut b); // sprite 0 CHR = $FC
7048 p.cpu_write_register(4, 0x00, &mut b); // sprite 0 ATT = 0
7049 p.cpu_write_register(4, 0x08, &mut b); // sprite 0 X = 8
7050 // Advance to scanline 241 dot 1 (VBL start) — matches the ROM
7051 // post-WaitForVBlank position.
7052 while !(p.scanline == 241 && p.dot == 1) {
7053 p.tick(&mut b);
7054 }
7055 // Enable rendering via $2001 write (BG + SPR + show-left).
7056 let mask = (PpuMask::SHOW_BG
7057 | PpuMask::SHOW_SPRITE
7058 | PpuMask::SHOW_BG_LEFT
7059 | PpuMask::SHOW_SPRITE_LEFT)
7060 .bits();
7061 p.cpu_write_register(1, mask, &mut b);
7062 // PPUSTATUS may have VBL set; clear sprite-zero-hit start clean.
7063 p.status.remove(PpuStatus::SPRITE_ZERO_HIT);
7064 // Now advance through ~30 scanlines (rest of VBL + pre-render +
7065 // visible 0-9), matching what Clockslide_3000 covers in the ROM.
7066 for _ in 0..(30 * 341) {
7067 p.tick(&mut b);
7068 }
7069 assert!(
7070 p.status.contains(PpuStatus::SPRITE_ZERO_HIT),
7071 "Expected sprite-zero hit set after 30 scanlines past VBL. \
7072 Actual status=0x{:02X}, scanline={}, dot={}, \
7073 spr_count={}, spr_zero_in_line={}, \
7074 spr_x[0]={}, spr_shift_lo[0]=0x{:02X}, spr_shift_hi[0]=0x{:02X}, \
7075 mask=0x{:02X}, ctrl=0x{:02X}",
7076 p.status.bits(),
7077 p.scanline,
7078 p.dot,
7079 p.spr_count,
7080 p.spr_zero_in_line,
7081 p.spr_x[0],
7082 p.spr_shift_lo[0],
7083 p.spr_shift_hi[0],
7084 p.mask.bits(),
7085 p.ctrl.bits(),
7086 );
7087 }
7088
7089 /// Cascade A reproducer: the exact `AccuracyCoin TEST_Sprite0Hit_Behavior`
7090 /// sub-test 1 scenario, constructed directly without going through the
7091 /// CPU/test-ROM.
7092 ///
7093 /// Setup (matches `AccuracyCoin.asm:PREP_SpriteZeroHit` + the test's
7094 /// pre-state):
7095 ///
7096 /// - Sprite 0: `Y=$00, CHR=$FC, ATT=$00, X=$08`.
7097 /// - BG nametable: `vram[$2001] = $FC` (tile $FC at col=1, row=0).
7098 /// - CHR pattern table 0, tile $FC, all 8 rows: `lo=$FF / hi=$00`
7099 /// (fully opaque pixels of palette colour 1).
7100 /// - `PPUMASK = $1E` (BG + SPR + `BG_LEFT` + grayscale; the actual
7101 /// `PPUMASK_COPY` value the diagnostic probe in
7102 /// `crates/rustynes-test-harness/src/accuracy_coin.rs` captures at frame
7103 /// 3393 — see `docs/audit/accuracycoin-readme-analysis-2026-05-17.md`
7104 /// §"Addendum (2026-05-19, session 5)").
7105 /// - `PPUCTRL = $00` (BG and sprite pattern tables both at `$0000`).
7106 /// - `v = $2000` (top-left of nametable 0).
7107 ///
7108 /// **Expected**: sprite zero hit (PPUSTATUS bit 6) is set by the end
7109 /// of scanline 1 — sprite pixel (8..15, 1) overlaps BG pixel (8..15,
7110 /// 1) and both are opaque.
7111 ///
7112 /// **Current (2026-05-19, pre-fix)**: this test FAILS. The
7113 /// diagnostic probe shows PPUSTATUS bit 6 = 0 in the live battery
7114 /// (full ROM run). This unit test is the isolated reproducer.
7115 #[test]
7116 fn cascade_a_sprite_zero_hit_y0_x8_tile_fc_overlap() {
7117 let (mut p, mut b) = fresh_ppu();
7118 // 1. Load tile $FC into pattern table 0 with fully-opaque pixels.
7119 let tile_fc_base = 0xFC * 16;
7120 for row in 0..8 {
7121 b.chr[tile_fc_base + row] = 0xFF; // lo plane (palette bit 0)
7122 b.chr[tile_fc_base + 8 + row] = 0x00; // hi plane (palette bit 1)
7123 }
7124 // 2. Write tile $FC into nametable position $2001 (col=1, row=0).
7125 // CIRAM bank 0 directly (horizontal mirroring: $2000-$23FF -> ciram[0..0x400]).
7126 p.ciram[0x001] = 0xFC;
7127 // 3. Sprite 0: Y=$00, CHR=$FC, ATT=$00, X=$08.
7128 p.oam[0] = 0x00;
7129 p.oam[1] = 0xFC;
7130 p.oam[2] = 0x00;
7131 p.oam[3] = 0x08;
7132 // 4. PPUMASK = SHOW_BG | SHOW_SPRITE | SHOW_BG_LEFT | grayscale.
7133 let mask_bits = PpuMask::SHOW_BG
7134 | PpuMask::SHOW_SPRITE
7135 | PpuMask::SHOW_BG_LEFT
7136 | PpuMask::SHOW_SPRITE_LEFT;
7137 p.mask = mask_bits;
7138 // Pipeline the mask through the two skip-check stages so the
7139 // rendering-enabled signal is stable immediately.
7140 p.mask_skip_pipe1 = mask_bits;
7141 p.mask_for_skip_check = mask_bits;
7142 // 5. PPUCTRL = 0 (BG and sprite both at pattern table 0).
7143 p.ctrl = PpuCtrl::empty();
7144 // 6. v = $2000 (top-left of nametable 0).
7145 p.v = 0x2000;
7146 // Make sure sprite-zero-hit and VBL start clean.
7147 p.status = PpuStatus::empty();
7148 // Pre-render starts; advance ~3 full scanlines so we cross
7149 // pre-render → scanline 0 → scanline 1 → scanline 2. By the end
7150 // of scanline 1, the sprite-zero hit should be set.
7151 // Frame is 262*341 dots. We need at least scanlines 261..=2 = 4
7152 // scanlines = 4*341 = 1364 dots. Use 1500 for safety.
7153 for _ in 0..1500 {
7154 p.tick(&mut b);
7155 }
7156 assert!(
7157 p.status.contains(PpuStatus::SPRITE_ZERO_HIT),
7158 "Expected sprite-zero hit (PPUSTATUS bit 6) to be set after \
7159 scanline 1 with sprite 0 at (Y=0, X=8) tile $FC overlapping \
7160 BG nametable[$2001]=$FC (both fully opaque). \
7161 Actual status=0x{:02X}, scanline={}, dot={}, \
7162 spr_count={}, spr_zero_in_line={}, \
7163 spr_x[0]={}, spr_shift_lo[0]=0x{:02X}, spr_shift_hi[0]=0x{:02X}",
7164 p.status.bits(),
7165 p.scanline,
7166 p.dot,
7167 p.spr_count,
7168 p.spr_zero_in_line,
7169 p.spr_x[0],
7170 p.spr_shift_lo[0],
7171 p.spr_shift_hi[0],
7172 );
7173 }
7174
7175 // =========================================================
7176 // $2002 VBL race-window sweep — Mesen2-independent oracle
7177 // (Session-18 / C1 attempt 16, PPU axis).
7178 //
7179 // The nesdev wiki [`PPU registers`] page documents the race:
7180 //
7181 // "Reading the status register within two cycles of when VBL is
7182 // set will return 0 in bit 7 but clear the latch anyway, causing
7183 // the program to miss frames."
7184 //
7185 // "Reading PPUSTATUS at the exact start of vertical blank will
7186 // return 0 in bit 7 but clear the latch anyway, causing NMI to
7187 // not occur that frame."
7188 //
7189 // Three documented dot-cohorts straddling scanline 241 dot 1:
7190 //
7191 // * dot < the-VBL-set-dot (i.e. dot 0 of scanline 241, or
7192 // earlier): VBL bit is 0 in PPUSTATUS, latch was never set,
7193 // suppression DOES happen if read lands on dot 0 of scanline
7194 // 241 (the one-dot-before window).
7195 // * dot == the-VBL-set-dot (= dot 1 of scanline 241): the
7196 // "exact start of VBL" window — read returns 0, latch is
7197 // cleared, and the in-frame VBL set is suppressed.
7198 // * dot > the-VBL-set-dot (dot 2 or later of scanline 241):
7199 // read returns 1 (VBL was set), latch is cleared by the
7200 // read, no suppression of subsequent VBL/NMI within that
7201 // frame because the set already happened.
7202 //
7203 // This unit test sweeps the PPU position across that boundary
7204 // (scanline 240 dot 339 through scanline 241 dot 5) and tabulates
7205 // the four observables per scenario: (a) the read return value's
7206 // bit 7, (b) whether suppress_vbl_this_frame got set, (c) whether
7207 // PPUSTATUS.VBLANK is set inside the PPU after the read, (d) the
7208 // value the next read of $2002 returns once we tick past dot 1.
7209 //
7210 // The test asserts the expected race-window semantics for ALL
7211 // dot positions. If `RustyNES` honours the nesdev spec, every
7212 // assertion passes. If not, the failing rows expose the exact
7213 // boundary off-by-one.
7214 //
7215 // After the test the table itself is `println!`'d for human
7216 // inspection via `--nocapture`.
7217 /// Loop budget: one full NTSC frame's worth of dots plus a
7218 /// 1024-dot safety margin, ample to sweep into scanline 242.
7219 #[cfg(test)]
7220 const VBL_SWEEP_MAX_TICKS: u32 = 262 * 341 + 1024;
7221
7222 #[test]
7223 #[allow(clippy::too_many_lines)]
7224 #[allow(clippy::items_after_statements)]
7225 fn vbl_race_window_2002_read_sweep() {
7226 use alloc::format;
7227 use alloc::string::String;
7228 use alloc::vec::Vec;
7229 // `eprintln!` lives in std; tests run in a `std` cargo unit so
7230 // this is fine.
7231 extern crate std;
7232 use std::eprintln;
7233 // The window we sweep, in (scanline, dot) pairs, listed in
7234 // tick-order. We use NTSC (vblank_start_line = 241).
7235 //
7236 // Layout choice: scan two extra dots into scanline 240 (the
7237 // last visible line) so the "VBL never gets set this frame"
7238 // pre-window is observable; then sweep dots 0..=5 of scanline
7239 // 241; then sweep two dots into scanline 242 for the post-VBL
7240 // tail. Total = 2 + 6 + 2 = 10 sample points.
7241 //
7242 // We re-create a fresh PPU for each sample-point so the
7243 // suppression-latch carries no contamination from the prior
7244 // sample. The PPU's internal state between samples is the
7245 // confounding factor we MUST isolate.
7246
7247 #[derive(Debug, Clone, Copy)]
7248 struct ExpectedRow {
7249 scanline: i16,
7250 dot: u16,
7251 // Bit 7 of the value returned by the $2002 read.
7252 // None = no specific spec assertion (don't enforce).
7253 read_bit7: Option<u8>,
7254 // Whether `suppress_vbl_this_frame` should be set after
7255 // the read. None = don't enforce.
7256 suppress_set: Option<bool>,
7257 // Whether `status.VBLANK` is set after the read (the
7258 // read always clears it, so this should be `false` for
7259 // any cohort where the read happens AT or AFTER the set
7260 // dot; and `false` for cohorts where the set never
7261 // happened either).
7262 vblank_after_read: Option<bool>,
7263 }
7264
7265 // Per the wiki, the VBL flag is set at scanline 241 dot 1.
7266 // The "race window" is documented as:
7267 // - dot 0 of scanline 241: read returns 0, suppresses VBL set
7268 // - dot 1 of scanline 241: read returns 0, suppresses VBL set
7269 // - dot 2 of scanline 241: read returns 1, normal clear
7270 //
7271 // RustyNES's current impl reads back `dot <= 1` for the
7272 // suppression branch (see `cpu_read_register` case 2 above:
7273 // `if self.scanline == self.region.vblank_start_line() &&
7274 // self.dot <= 1 { self.suppress_vbl_this_frame = true; ... }`).
7275 //
7276 // The exact rendering of "read on the same dot as set"
7277 // depends on whether the set callback in tick() fires
7278 // BEFORE the read or AFTER. Since the test ticks the PPU
7279 // to a position FIRST then issues a synchronous read in the
7280 // same test step, the read sees the post-tick state — i.e.
7281 // the read on dot 1 of scanline 241 sees VBL set.
7282 let expected: [ExpectedRow; 10] = [
7283 ExpectedRow {
7284 scanline: 240,
7285 dot: 339,
7286 read_bit7: Some(0),
7287 suppress_set: Some(false),
7288 vblank_after_read: Some(false),
7289 },
7290 ExpectedRow {
7291 scanline: 240,
7292 dot: 340,
7293 read_bit7: Some(0),
7294 suppress_set: Some(false),
7295 vblank_after_read: Some(false),
7296 },
7297 ExpectedRow {
7298 scanline: 241,
7299 dot: 0,
7300 read_bit7: Some(0),
7301 suppress_set: Some(true),
7302 vblank_after_read: Some(false),
7303 },
7304 ExpectedRow {
7305 scanline: 241,
7306 dot: 1,
7307 // VBL is set on this tick BEFORE the read; the read
7308 // returns 1 AND `suppress_vbl_this_frame` is latched
7309 // (RustyNES's `dot <= 1` race window — 2 PPU dots wide).
7310 //
7311 // Session-18 / C1 attempt 16 (PPU-axis, rolled back):
7312 // tightening the predicate to `dot == 0` (matching
7313 // Mesen2 + nesdev wiki) did not flip the failing
7314 // `cpu_interrupts_v2/{2,3,5}` tests at the integration
7315 // layer — the load-bearing axis is the CPU-vs-PPU
7316 // intra-cycle access interleaving, not the suppression
7317 // predicate's literal dot range. Restored 2-dot window
7318 // as the cleaner regression invariant; the unit test
7319 // documents the actual behavior. See
7320 // `docs/audit/session-18-c1-attempt16-ppu-axis-rollback-2026-05-22.md`.
7321 read_bit7: Some(1),
7322 // R2 (mc-r1-substrate): the dot==0 race window (Mesen2
7323 // `UpdateStatusFlag:590`) makes the dot-1 read a normal
7324 // post-set read — no suppression. Default (2-dot window):
7325 // suppression latches at dot 1 too.
7326 suppress_set: Some(false),
7327 vblank_after_read: Some(false),
7328 },
7329 ExpectedRow {
7330 scanline: 241,
7331 dot: 2,
7332 read_bit7: Some(1),
7333 suppress_set: Some(false),
7334 vblank_after_read: Some(false),
7335 },
7336 ExpectedRow {
7337 scanline: 241,
7338 dot: 3,
7339 read_bit7: Some(1),
7340 suppress_set: Some(false),
7341 vblank_after_read: Some(false),
7342 },
7343 ExpectedRow {
7344 scanline: 241,
7345 dot: 4,
7346 read_bit7: Some(1),
7347 suppress_set: Some(false),
7348 vblank_after_read: Some(false),
7349 },
7350 ExpectedRow {
7351 scanline: 241,
7352 dot: 5,
7353 read_bit7: Some(1),
7354 suppress_set: Some(false),
7355 vblank_after_read: Some(false),
7356 },
7357 ExpectedRow {
7358 scanline: 242,
7359 dot: 0,
7360 read_bit7: Some(1),
7361 suppress_set: Some(false),
7362 vblank_after_read: Some(false),
7363 },
7364 ExpectedRow {
7365 scanline: 242,
7366 dot: 1,
7367 read_bit7: Some(1),
7368 suppress_set: Some(false),
7369 vblank_after_read: Some(false),
7370 },
7371 ];
7372
7373 // Per-row capture for human inspection.
7374 #[derive(Debug)]
7375 struct ObservedRow {
7376 scanline: i16,
7377 dot: u16,
7378 read_value: u8,
7379 read_bit7: u8,
7380 suppress_set_after: bool,
7381 status_vblank_after: bool,
7382 }
7383 let mut observed = Vec::<ObservedRow>::new();
7384
7385 for row in &expected {
7386 // Build a fresh PPU and tick it to the target (scanline, dot).
7387 // Strategy: tick UNTIL we land on the target. Each `tick`
7388 // call calls `advance_dot()` first, so the post-tick state
7389 // is (scanline + 1, dot=1 wraparound) etc. Hence we tick
7390 // until p.scanline()/p.dot() match.
7391 //
7392 // Disable rendering so we don't trigger A12 emissions,
7393 // sprite eval, etc. — keeps the test focused on VBL +
7394 // $2002.
7395 let (mut p, mut b) = fresh_ppu();
7396 // No PPUMASK render bits. No PPUCTRL bits (so NMI off).
7397 // post_reset_mask_remaining = 0 already (fresh_ppu sets it).
7398 // Tick to the target. Loop bound is one full NTSC frame plus
7399 // safety margin (see `VBL_SWEEP_MAX_TICKS` above).
7400 let mut ticks = 0u32;
7401 while !(p.scanline == row.scanline && p.dot == row.dot) {
7402 p.tick(&mut b);
7403 ticks += 1;
7404 assert!(
7405 ticks < VBL_SWEEP_MAX_TICKS,
7406 "could not reach (scanline={}, dot={}) within one frame; \
7407 loop bug or scheduler change",
7408 row.scanline,
7409 row.dot,
7410 );
7411 }
7412
7413 // Issue the $2002 read.
7414 let read_value = p.cpu_read_register(2, &mut b);
7415 let read_bit7 = (read_value >> 7) & 1;
7416 let suppress_set_after = p.suppress_vbl_this_frame;
7417 let status_vblank_after = p.status.contains(PpuStatus::VBLANK);
7418
7419 observed.push(ObservedRow {
7420 scanline: row.scanline,
7421 dot: row.dot,
7422 read_value,
7423 read_bit7,
7424 suppress_set_after,
7425 status_vblank_after,
7426 });
7427 }
7428
7429 // Print the table for human inspection (only visible with
7430 // --nocapture).
7431 eprintln!();
7432 eprintln!("=== $2002 VBL race-window sweep ===");
7433 eprintln!(
7434 "{:>3} {:>3} {:>8} {:>7} {:>11} {:>14}",
7435 "sl", "dot", "read", "bit7", "suppress?", "PPU.VBLANK?",
7436 );
7437 for o in &observed {
7438 eprintln!(
7439 "{:>3} {:>3} 0x{:02X} {:>5} {:>9} {:>10}",
7440 o.scanline,
7441 o.dot,
7442 o.read_value,
7443 o.read_bit7,
7444 o.suppress_set_after,
7445 o.status_vblank_after,
7446 );
7447 }
7448 eprintln!();
7449
7450 // Assert the spec — but only on rows where `expected` carries
7451 // a concrete claim. Rows with `None` are recording-only.
7452 let mut failures = Vec::<String>::new();
7453 for (i, row) in expected.iter().enumerate() {
7454 let obs = &observed[i];
7455 if let Some(want) = row.read_bit7
7456 && obs.read_bit7 != want
7457 {
7458 failures.push(format!(
7459 "(sl={}, dot={}): expected read bit7 = {}, got {}",
7460 row.scanline, row.dot, want, obs.read_bit7,
7461 ));
7462 }
7463 if let Some(want) = row.suppress_set
7464 && obs.suppress_set_after != want
7465 {
7466 failures.push(format!(
7467 "(sl={}, dot={}): expected suppress_vbl = {}, got {}",
7468 row.scanline, row.dot, want, obs.suppress_set_after,
7469 ));
7470 }
7471 if let Some(want) = row.vblank_after_read
7472 && obs.status_vblank_after != want
7473 {
7474 failures.push(format!(
7475 "(sl={}, dot={}): expected status.VBLANK after read = {}, got {}",
7476 row.scanline, row.dot, want, obs.status_vblank_after,
7477 ));
7478 }
7479 }
7480
7481 assert!(
7482 failures.is_empty(),
7483 "$2002 race-window sweep mismatches vs. nesdev wiki spec:\n {}",
7484 failures.join("\n "),
7485 );
7486 }
7487
7488 // -------------------------------------------------------------------
7489 // v1.3.x left-edge regression: BG attribute (palette) shift register
7490 // must stay in lockstep with the BG pattern shift registers through
7491 // the dots 321-336 pre-fetch boundary.
7492 //
7493 // 086ce4d moved the BG pattern pipeline to the Mesen2 cycle-9 reload +
7494 // post-emit shift model and added an explicit `<<= 8` at pre-fetch
7495 // dots 328/336 for the 16-bit pattern shifters, but left the 8-bit
7496 // `at_shift` + 1-bit `at_feed` attribute model untouched. The two
7497 // pipelines then advanced at different rates across the pre-fetch
7498 // region, so the palette (attribute) bits drifted one tile out of
7499 // phase with the pattern bits in the leftmost columns — the source of
7500 // the "green tint / garbage palette in the left 1-2 columns while
7501 // scrolling" regression. The fix makes the attribute shifters 16-bit
7502 // and shift them in lockstep with the pattern shifters.
7503 // -------------------------------------------------------------------
7504
7505 /// Render one visible scanline with a SOLID pattern everywhere
7506 /// (pattern value 1 in every tile) but a per-tile-group ATTRIBUTE
7507 /// boundary, then return the (pattern, palette) the PPU emitted at
7508 /// each of the first 24 columns. Because the pattern value is the
7509 /// same everywhere, any column-to-column change is purely an
7510 /// attribute (palette) change — which is exactly what the AT shift
7511 /// register controls. Misalignment between the pattern and attribute
7512 /// pipelines therefore shows up as the palette boundary landing on
7513 /// the wrong column.
7514 ///
7515 /// Returns a Vec of `(palette_value)` per column 0..24 of the target
7516 /// scanline (pattern value is always 1, verified internally).
7517 fn diag_attr_palette_per_column(fine_x: u8, coarse_x: u16) -> alloc::vec::Vec<u8> {
7518 use alloc::vec::Vec;
7519 let target_line: usize = 5;
7520 let (mut p, mut b) = fresh_ppu();
7521
7522 // Single solid tile: tile 1 = pattern value 1 on all rows.
7523 for row in 0..8u16 {
7524 b.chr[(0x0010 + row) as usize] = 0xFF; // lo plane all set
7525 b.chr[(0x0018 + row) as usize] = 0x00; // hi plane clear -> value 1
7526 }
7527
7528 // Nametable 0: every tile = tile 1 (solid). Attribute table sets
7529 // a palette boundary: tile-column groups 0-1 use palette 1,
7530 // everything else uses palette 0. Each attribute byte covers a
7531 // 4x4-tile (32x32px) region split into four 2x2-tile quadrants.
7532 // We set the top-left quadrant of attribute byte 0 to palette 1.
7533 for off in 0..0x03C0u16 {
7534 p.ciram[off as usize] = 0x01; // tile index 1 everywhere
7535 }
7536 // Attribute table starts at $23C0 -> CIRAM offset 0x03C0.
7537 // Byte 0 covers tile columns 0-3, rows 0-3. Bits 1-0 = top-left
7538 // quadrant (tile cols 0-1, rows 0-1); bits 3-2 = top-right
7539 // quadrant (tile cols 2-3, rows 0-1). Set TL=palette 1, TR=
7540 // palette 2, the rest palette 0. This puts an attribute boundary
7541 // at every 16px (tile-pair) step so coarse-X scroll moves the
7542 // boundary across the pre-fetch-fed leftmost tile — the exact
7543 // condition that exposed the 086ce4d AT lockstep regression.
7544 p.ciram[0x03C0] = 0b00_00_10_01; // TL=pal1, TR=pal2.
7545 // The target scanline is row 5 -> tile row 0 -> top quadrants.
7546
7547 // Palettes: pattern value 1...
7548 // palette 0 -> $3F01
7549 // palette 1 -> $3F05
7550 // palette 2 -> $3F09
7551 p.palette_ram[palette_index(0x3F00)] = 0x0F; // universal
7552 p.palette_ram[palette_index(0x3F01)] = 0x30; // pal0 value1 = white
7553 p.palette_ram[palette_index(0x3F05)] = 0x16; // pal1 value1 = red
7554 p.palette_ram[palette_index(0x3F09)] = 0x2A; // pal2 value1 = green
7555
7556 // No sprites.
7557 for i in 0..256 {
7558 p.oam[i] = 0xF0;
7559 }
7560
7561 p.ctrl = PpuCtrl::empty();
7562 p.mask = PpuMask::SHOW_BG | PpuMask::SHOW_BG_LEFT;
7563
7564 // Scroll: coarse-X into t bits 0-4, fine-x into p.x.
7565 p.t = coarse_x & 0x1F;
7566 p.v = 0;
7567 p.x = fine_x;
7568
7569 p.scanline = p.region.prerender_line();
7570 p.dot = 0;
7571 p.last_a12_level = false;
7572
7573 for _ in 0..(341 * (target_line + 2)) {
7574 p.tick(&mut b);
7575 }
7576
7577 let line = target_line;
7578 let pal0 = crate::palette::nes_color_to_rgba(0x30);
7579 let pal1 = crate::palette::nes_color_to_rgba(0x16);
7580 let pal2 = crate::palette::nes_color_to_rgba(0x2A);
7581 let universal = crate::palette::nes_color_to_rgba(0x0F);
7582 let mut out = Vec::with_capacity(24);
7583 for x in 0..24usize {
7584 let off = (line * 256 + x) * 4;
7585 let px = [
7586 p.framebuffer[off],
7587 p.framebuffer[off + 1],
7588 p.framebuffer[off + 2],
7589 p.framebuffer[off + 3],
7590 ];
7591 // Map color back to palette index: 0/1/2 = palette, 254 =
7592 // universal, 255 = unexpected.
7593 let v = if px == pal0 {
7594 0u8
7595 } else if px == pal1 {
7596 1u8
7597 } else if px == pal2 {
7598 2u8
7599 } else if px == universal {
7600 254u8
7601 } else {
7602 255u8
7603 };
7604 out.push(v);
7605 }
7606 out
7607 }
7608
7609 /// The expected palette index for screen column `x` given a scroll of
7610 /// `coarse_x` tiles + `fine_x` pixels. Tile column C maps to: 0-1 ->
7611 /// palette 1, 2-3 -> palette 2, 4+ -> palette 0. With a total left
7612 /// shift of `coarse_x*8 + fine_x` pixels, screen column `x`
7613 /// corresponds to source pixel `x + coarse_x*8 + fine_x`, whose tile
7614 /// column is that pixel / 8.
7615 fn expected_palette(x: usize, fine_x: u8, coarse_x: u16) -> u8 {
7616 let src_pixel = x + (coarse_x as usize) * 8 + fine_x as usize;
7617 let tile_col = src_pixel / 8;
7618 match tile_col {
7619 0 | 1 => 1,
7620 2 | 3 => 2,
7621 _ => 0,
7622 }
7623 }
7624
7625 /// With NO scroll the palette-1 region must cover exactly tile columns
7626 /// 0-1 (screen columns 0-15), palette-2 tile columns 2-3 (16-31), and
7627 /// palette-0 beyond. Visible-region pipeline only; must hold both
7628 /// before and after the fix.
7629 #[test]
7630 fn bg_attribute_boundary_no_scroll() {
7631 let cols = diag_attr_palette_per_column(0, 0);
7632 for (x, &v) in cols.iter().enumerate() {
7633 let want = expected_palette(x, 0, 0);
7634 assert_eq!(
7635 v, want,
7636 "col {x}: expected palette {want}, got {v} (full: {cols:?})"
7637 );
7638 }
7639 }
7640
7641 /// The palette boundary must stay glued to the pattern across BOTH
7642 /// fine-X and coarse-X scroll. This is the case the 086ce4d AT-register
7643 /// regression broke: the pre-fetch `<<= 8` (added for the 16-bit
7644 /// pattern shifters) was not applied to the 8-bit attribute model, so
7645 /// the palette drifted one tile relative to the pattern across the
7646 /// dots 321-336 pre-fetch boundary — wrong palette in the leftmost
7647 /// tile column (screen columns 0-7). With the 16-bit AT shifters the
7648 /// boundary tracks the pattern exactly at every scroll value.
7649 ///
7650 /// Empirically: on the pre-fix (HEAD) tree the `coarse_x` cases below
7651 /// mis-paint screen columns 0-7; on the fixed tree every column
7652 /// matches `expected_palette`.
7653 #[test]
7654 fn bg_attribute_boundary_tracks_pattern_under_scroll() {
7655 for coarse_x in 0..6u16 {
7656 for fine_x in 0..8u8 {
7657 let cols = diag_attr_palette_per_column(fine_x, coarse_x);
7658 for (x, &v) in cols.iter().enumerate() {
7659 let want = expected_palette(x, fine_x, coarse_x);
7660 assert_eq!(
7661 v, want,
7662 "coarse_x={coarse_x} fine_x={fine_x} col {x}: \
7663 expected palette {want}, got {v}\nfull: {cols:?}\n\
7664 (palette boundary must stay glued to the pattern; \
7665 a mismatch in columns 0-7 is the 086ce4d \
7666 AT-register lockstep regression)"
7667 );
7668 }
7669 }
7670 }
7671 }
7672
7673 /// Register-level invariant: the attribute shift registers must track
7674 /// the BG pattern shift registers bit-for-bit through the exact
7675 /// reload / shift / pre-fetch-`<<= 8` sequence that a real scanline
7676 /// boundary performs. This is the direct, hermetic guard against the
7677 /// 086ce4d regression where the attribute pipeline (then an 8-bit
7678 /// register + 1-bit feed) advanced at a different rate than the 16-bit
7679 /// pattern pipeline across the dots 321-336 pre-fetch boundary.
7680 ///
7681 /// The check exploits an exact structural equivalence: for any tile
7682 /// whose pattern low byte is `0xFF` (all 8 pixels opaque in plane 0),
7683 /// the pattern-low shift register's per-pixel bit equals 1 for that
7684 /// tile's 8 columns. An attribute bit that is set (`at_latch` bit
7685 /// set) expands to `0xFF` in `reload_bg_shift_regs`, so the AT-low
7686 /// register must hold the IDENTICAL 8-bit run as the pattern-low
7687 /// register for that tile. We reload two tiles with pattern-low
7688 /// `0xFF` + attribute bit set, run the pre-fetch `<<= 8` boundary,
7689 /// then assert the AT registers equal the pattern registers exactly.
7690 #[test]
7691 #[ignore = "permanent-by-design: pins the SUPERSEDED pre-master-clock BG shifter feed. The default master-clock core injects the BG serial-in '1' source the AccuracyCoin 'BG Serial In' test proves correct, so this unit assertion is kept as a historical pin and cannot be un-ignored. Visual coverage: visual_regression 7/7 on the default build."]
7692 fn bg_attribute_register_lockstep_through_prefetch() {
7693 let (mut p, _b) = fresh_ppu();
7694
7695 // Start clean.
7696 p.bg_shift_lo = 0;
7697 p.bg_shift_hi = 0;
7698 p.at_shift_lo = 0;
7699 p.at_shift_hi = 0;
7700
7701 // Tile A: pattern low = 0xFF, high = 0xFF; attribute = 0b11 (both
7702 // bits set -> both AT bytes expand to 0xFF). After reload the low
7703 // byte of every register is 0xFF.
7704 p.bg_lo_latch = 0xFF;
7705 p.bg_hi_latch = 0xFF;
7706 p.at_latch = 0b11;
7707 p.reload_bg_shift_regs();
7708 assert_eq!(p.bg_shift_lo & 0x00FF, 0x00FF);
7709 assert_eq!(p.at_shift_lo & 0x00FF, 0x00FF);
7710 assert_eq!(p.at_shift_hi & 0x00FF, 0x00FF);
7711
7712 // Pre-fetch `<<= 8` (dots 328 / 336): the pattern and attribute
7713 // registers MUST be shifted identically. Exercises the exact
7714 // production helper used inside `tick`.
7715 p.prefetch_shift_bg_regs();
7716
7717 // Tile B: same content reloaded into the low byte.
7718 p.bg_lo_latch = 0xFF;
7719 p.bg_hi_latch = 0xFF;
7720 p.at_latch = 0b11;
7721 p.reload_bg_shift_regs();
7722
7723 // After the boundary, both tiles' data is present and the
7724 // attribute registers must be bit-identical to the pattern
7725 // registers (because both tiles set every plane-0 / plane-1 bit
7726 // AND every attribute bit). Any drift between the two pipelines
7727 // (the regression) makes these diverge.
7728 assert_eq!(
7729 p.at_shift_lo, p.bg_shift_lo,
7730 "AT-low shifter must track pattern-low shifter bit-for-bit \
7731 through the pre-fetch boundary (086ce4d lockstep regression)"
7732 );
7733 assert_eq!(
7734 p.at_shift_hi, p.bg_shift_hi,
7735 "AT-high shifter must track pattern-high shifter bit-for-bit \
7736 through the pre-fetch boundary (086ce4d lockstep regression)"
7737 );
7738
7739 // Now shift one pixel (post-emit `shift_bg`) and re-check lockstep.
7740 p.shift_bg();
7741 assert_eq!(p.at_shift_lo, p.bg_shift_lo, "lockstep after shift_bg");
7742 assert_eq!(p.at_shift_hi, p.bg_shift_hi, "lockstep after shift_bg");
7743 }
7744}