Skip to main content

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}