Skip to content

PPU — Ricoh 2C02

References: ref-docs/research-report.md §Technical deep-dive → PPU; ref-docs/nesdev-wiki-technical-report.md §PPU; Nesdev PPU, PPU power up state, PPU registers, PPU rendering, and PPU sprite evaluation.

Purpose

Implement the 2C02 PPU as a state machine that advances exactly one dot per tick(). The PPU maintains the framebuffer, generates NMI at the start of vertical blank, manages OAM and sprite evaluation, and exposes the eight CPU-facing registers $2000-$2007 plus $4014 (OAMDMA, handled by the bus but originating an OAM-DMA request).

Interfaces

pub trait PpuBus {
    fn ppu_read(&mut self, addr: u16) -> u8;        // $0000-$3FFF, mapper-mediated CHR + nametable
    fn ppu_write(&mut self, addr: u16, value: u8);
    fn notify_a12(&mut self, level: bool);           // for MMC3/MMC5 IRQ counters
}

pub struct Ppu {
    pub dot: u16,           // 0..=340
    pub scanline: i16,      // -1 (pre-render) ..= 260 (or 311 PAL)
    pub frame: u64,
    /* opaque internal state */
}

impl Ppu {
    pub fn new(region: Region) -> Self;
    pub fn reset(&mut self);
    pub fn tick<B: PpuBus>(&mut self, bus: &mut B);

    pub fn cpu_read_register(&mut self, reg: u8) -> u8;       // reg 0..=7
    pub fn cpu_write_register(&mut self, reg: u8, value: u8);

    pub fn nmi_pending(&self) -> bool;
    pub fn frame_complete(&self) -> bool;
    pub fn framebuffer(&self) -> &[u8; 256 * 240 * 4];        // RGBA8 sRGB
    pub fn index_framebuffer(&self) -> &[u16; 256 * 240];     // (emph<<6)|colour, 0..=511
    pub fn ntsc_phase(&self) -> u8;                           // videoPhase: 0..=2 NTSC, 0..=1 PAL/Dendy
}

The PPU owns its 2 KB internal VRAM but routes all $0000-$3FFF accesses through the PpuBus so the mapper can override CHR (banking) and nametable mirroring. Palette RAM ($3F00-$3FFF) is internal to the PPU.

State

  • Internal VRAM — 2 KB (the on-board nametable RAM the mapper mirrors).
  • OAM — 256 B (64 sprites × 4 bytes).
  • Secondary OAM — 32 B (8 sprites × 4 bytes; cleared each scanline).
  • Palette RAM — 32 B with mirrors ($3F10/$14/$18/$1C mirror $3F00/$04/$08/$0C).
  • Internal registers — v (15-bit current address), t (15-bit temp), x (3-bit fine X), w (1-bit write toggle).
  • Shift registers — 16-bit BG pattern low + high, 8-bit BG attribute low + high, 8-bit per-sprite pattern low + high (×8), 8-bit per-sprite X-counter (×8), per-sprite attribute byte (×8).
  • Status latches — VBL flag, sprite-0 hit, sprite overflow.
  • Open-bus latch — 8-bit dynamic latch + per-bit decay timer (for PPUSTATUS bits 4-0 read).
  • NMI line + edge tracker — observed by the CPU.
  • OAMADDR, read buffer for PPUDATA.

Save-state restore validation

A save state is untrusted input (v2.7.0, core audit IMP-01 and the fuzz findings recorded in docs/audits/core-disposition.md). Ppu::restore rejects a blob whose fields hold values the running PPU never produces, with a typed PpuSnapshotError, because each of them restored cleanly before and then panicked or never finished a frame on a later dot:

Field Legal range Error
spr_count 0..=8 InvalidSprCount
dot, scanline 0..=340; -1..=pre-render line (-1 is the power-on position) InvalidRasterPosition
x (fine X) 0..=7 FieldOutOfRange
sprite_eval_n / _m / _found / _sec_idx 63 / 3 / 8 / 32 FieldOutOfRange
oam_bus_addr_h / _addr_l / _secondary_addr / _overflow_counter 63 / 3 / 32 / 3 FieldOutOfRange
oam2_addr, oam2_fetch_addr 31 FieldOutOfRange, InvalidOam2FetchAddr
oam_corruption_index 0..=32 (32 wraps to row 0) FieldOutOfRange

extra_lines_remaining is clamped, not rejected, to the extra_scanlines knob in force: the knob is a frontend setting that is not serialized, so a save written under a larger knob is genuine. Each bound is the range the PPU keeps the field in, so a state the emulator wrote always restores.

Behavior

Frame structure

Per ref-docs/research-report.md §Frame and scanline structure:

Scanline Dots Purpose
0..=239 0..=340 Visible (1..=256 emit pixels)
240 0..=340 Post-render idle
241..=260 (NTSC) / 241..=310 (PAL) 0..=340 Vertical blank (VBL flag set at scanline 241 dot 1; /NMI asserted at dot 3 — see below)
261 (NTSC) / 311 (PAL) 0..=340 Pre-render

VBL flag vs /NMI assertion timing. The VBL bit (PPUSTATUS bit 7) is set at scanline 241 dot 1 per nesdev. Real hardware pulls /NMI low one PPU clock later (dot 2). In our scheduler, where SystemBus::cpu_read/cpu_write performs the bus access before the cycle's 3 PPU dots tick (vs. real hardware's mid-cycle bus access), we delay /NMI assertion by 2 additional PPU dots (assert at scanline 241 dot 3) so that blargg's ppu_vbl_nmi/05-nmi_timing and 08-nmi_off_timing sample the rising edge on the same CPU cycle a real 6502 would. The CPU samples the /NMI level (Bus::nmi_level, the PPU's nmi_line) once per CPU cycle and edge-detects it itself (mc_prev_nmi_line in rustynes-cpu); ppu_vbl_nmi/07-nmi_on_timing passes on that path. Before v2.0.0 a bus-side detector sampled the line between every PPU dot of tick_one_cpu_cycle, to latch a glitched edge inside one CPU cycle. It had no caller after v2.0.0's scheduler and was removed at v2.9.8 with the rest of that path (ADR 0042).

Odd-frame skip on NTSC: the pre-render scanline of odd frames omits the final dot, jumping (339, 261) → (0, 0). The decision is taken on the transition out of dot 339 and gated on the rendering-enabled flag. The flag read by the dot-skip detector lags mask by two PPU clocks via a two-stage shift pipeline (mask_for_skip_check / mask_skip_pipe1), so a $2001 write whose CPU cycle straddles dot 339 doesn't move the threshold by a full CPU cycle. This compensates for SystemBus's atomic write-before-tick ordering (CPU PPUMASK write lands ~3 PPU dots earlier than real hardware's φ2 latch); two dots of delay align the visible threshold with blargg's ppu_vbl_nmi/10-even_odd_timing expectations. The pipeline is fed only from mask and does not affect background, sprite, or palette-greyscale paths, all of which keep using mask directly.

The skip moves scanline 0's first sprite pixel to X=0 (v2.9.5). The sprite shifters are told to start counting on dot 339 and see that signal a dot late, at dot 340 (forums.nesdev.org/viewtopic.php?t=26291, from Visual2C02 analysis). When the skip removes dot 340, every loaded shifter starts scanline 0 still in the drawing state. It outputs its first pixel at X=0, shifts, and then counts one dot late, so pixels 1-7 land where they normally do. In the model, the skip puts the loaded slots back to spr_halted, and emit_pixel releases them after pixel 0 (spr_rearm_deferred, serialized in the PPU_SNAPSHOT_VERSION 11 tail because it lives across the frame boundary). AccuracyCoin's Sprites On Scanline 0 reads this as a composite 2C02, code 1; without it the two frames agree and the ROM reports an RGB PPU, code 2. An X=0 sprite is unaffected.

Extra-scanlines overclock (v1.7.0 F3)

The PPU can insert N extra blank scanlines into the vblank period each frame (Mesen2 UpdateTimings), at the existing dot resolution. Set via Nes::set_extra_scanlines(n) (forwarded to Ppu::set_extra_scanlines); read back via Nes::extra_scanlines(). Off by default (0), and a frontend config knob — it is not part of the save-state (the frontend re-applies it on restore, like the region / palette).

How it works: when extra_scanlines != 0, Ppu::advance_dot holds the PPU on the idle vblank line immediately before the pre-render line (NTSC line 260) and re-runs it n times before advancing to pre-render. That line is not visible (scanline > 239), not the VBL-set line (241), and not pre-render — so the extra lines emit no pixels, set/clear no PPUSTATUS flag, and fire no VBL / NMI / A12 event. They are pure additional CPU run-time (the scheduler still clocks the CPU every third dot), giving timing-sensitive games more compute headroom without altering the visible image.

Byte-identical at zero. The entire insertion branch is guarded by extra_scanlines != 0, so at the default 0 not a single code path differs and the frame is byte-for-byte identical to stock NES timing — AccuracyCoin, the commercial oracle, and nestest (which never set it) are unaffected. This is proved by crates/rustynes-test-harness/tests/extra_scanlines.rs (extra_scanlines_zero_is_byte_identical_to_stock, plus an image-invariance proof on the first frame and a CPU-cycle-growth check).

This is distinct from the CPU-multiplier overclock (Nes::set_cpu_overclock, v3.1.0), which shortens the CPU cycle on the master clock rather than adding lines, and lives in the bus (docs/scheduler.md, "CPU-multiplier overclock"). Until v3.1.0 this paragraph called it a v2.0 timebase item; the one-clock timebase that made it possible shipped in v2.0.0.

Disable sprite limit (v3.1.0, T-SPRITE-LIMIT, optional, default-OFF)

Nes::set_sprite_limit_disabled(true) draws the sprites a scanline drops past the eighth. It is render-only: evaluation, secondary OAM, the overflow flag, sprite-0 hit and all eight real sprite fetches, with their A12 edges, are exactly stock, so the game cannot tell. Pinned by crates/rustynes-test-harness/tests/sprite_limit.rs: with the option on, every CPU cycle, work-RAM byte and audio sample matches stock frame by frame, and blargg's five sprite-overflow ROMs pass.

  • Which sprites. After the eighth real fetch (slot 7, dot 316) of a visible line, fetch_extra_sprites walks primary OAM from entry 0, aligned, skips the first eight in range for the next line (the ones the hardware draws when evaluation starts at OAMADDR 0) and fetches up to 56 more (MAX_EXTRA_SPRITES). Only when evaluation found eight; never on the pre-render line. A line whose evaluation starts misaligned can draw a slightly different set: it is a display enhancement, not hardware.
  • How they are read. Through PpuBus::ppu_read_sprite, with no observe_a12_addr, and only when PpuBus::chr_reads_are_pure (the mapper's answer, docs/mappers.md): on MMC2, MMC4, the J.Y. ASIC, Bandai 96 and Nanjing 163 a CHR read changes the board, so the option draws eight there.
  • How they draw. In emit_pixel, only where none of the eight hardware sprites is opaque (a higher OAM index is a lower priority), with their own palette and priority bit, never as sprite 0.
  • State. The pending extras for the next line are PPU snapshot v13 (spr_extra_*), because a snapshot can fall between the fetch and the line. The switch is configuration: carried across a power cycle, and in movies and the netplay config_digest through HardwareOptions (the picture differs, so frame hashes do).

Power-up and reset state

The PPU has a reset mask distinct from the CPU reset sequence:

State Power Reset
PPUCTRL / PPUMASK $00 $00
PPUSTATUS Partly unspecified VBL unchanged; other bits partly unspecified
OAMADDR $00 on documented cold power Unchanged, but rendering normally disturbs it
PPUSCROLL / latch $0000, write toggle clear $0000, write toggle clear
PPUADDR $0000 Unchanged
PPUDATA read buffer $00 $00
OAM, palette, nametable RAM, CHR RAM Unspecified Unchanged or unspecified depending on storage

Writes to $2000, $2001, $2005, and $2006 are ignored for roughly 29,658 NTSC CPU clocks after reset; PAL is roughly 33,132 CPU clocks. The write-pair latch also does not toggle during this mask. $2002, $2003, $2004, $2007, and $4014 work immediately.

Cold power shortly after a previous power-off can look reset-like rather than fresh. Tests and user-facing deterministic mode should therefore avoid assuming stable OAM, palette, nametable, CHR RAM, or PPUSTATUS low-bit state. The unspecified power-up palette RAM and CPU work RAM are modeled by the opt-in, default-off knobs described below (see Power-up palette RAM and Power-on work-RAM model); the default keeps both deterministic (all-zero).

The table and window above describe the front-loading NES (NES-001), where the CPU and PPU share one reset line. That is the default model, and the one every release before v2.9.8 emulated. See the next section for the Famicom.

Famicom console model (optional, opt-in, default-OFF) (v2.9.8)

NESdev's "PPU power up state" page (§Famicom, and its closing note on front- and top-loaders) documents a different reset wiring on the Famicom:

  • The PPU's /RESET is tied to 5 V; only the CPU's rides a 0.47 µF capacitor. At power-on the PPU therefore starts initialising "approximately one frame before the CPU reset" (the page notes the exact timing has not been measured and may vary). One NTSC frame is about 29,781 CPU cycles, longer than the 29,658-cycle window, so the window is already over when the CPU runs its first instruction.
  • The Reset button resets only the CPU. The PPU keeps PPUCTRL, PPUMASK, its latches, its read buffer and its frame position, and no window is re-armed.

The page's examples are Magic John (waits 9,217 cycles before enabling NMI; boots on a Famicom, not on an NES) and The Lord of King. The 999-in-1 multicart (mapper 212) clears its nametable through $2006/$2007 at about cycle 27,400 and shows a screen of "0" tiles under the NES model.

rustynes_core::ConsoleModel selects the wiring; Nes::set_console_model sets it, and the bus consults it in two places:

Event ConsoleModel::Nes (default) ConsoleModel::Famicom
Power-on / power-cycle Window armed (29,658 / 33,132 cycles) Window closed before the first instruction (Ppu::end_warmup)
Warm reset Ppu::reset (table above) PPU untouched; APU, DMA and cartridge reset as on the NES
Selecting the model Stored; applies from the next reset Stored, and any window in progress ends now

The last row is what gives a host the Famicom power-on: the frontend applies its configuration straight after building or power-cycling the machine.

Not modelled: the PPU's frame position at power-on is left where the NES model puts it rather than advanced by a guessed "approximately one frame", and the NES-101 top-loader (which shares the Famicom's reset behaviour, per the same page) is not offered separately because its power-on lead is not documented.

The selection is a host/config knob, never derived from the ROM (NES 2.0 has no console type that tells a Famicom from an NES) and never from a per-game list. It is not part of the save-state; the warm-up counter it acts on already is (post_reset_mask_remaining, PPU snapshot section), so no snapshot version changes. A power-on movie or a netplay session records no console model, the same as the other hardware knobs in this file (OAM decay, die revision, power-on RAM): replaying one under a different setting can diverge from its first frames. With the model at its default, every output is byte-identical.

Tests: ppu::tests::end_warmup_lets_masked_registers_write_immediately (rustynes-ppu), nes::tests::famicom_console_model_ppu_leaves_reset_before_the_cpu (rustynes-core), and the commercial-ROM check crates/rustynes-test-harness/tests/famicom_console.rs (999-in-1, local dump).

Per-dot fetch sequencing (visible + pre-render scanlines)

  • Dot 0 — idle.
  • Dots 1..=256 — background tile fetch (8-dot windows of NT, AT, PT-low, PT-high, 2 dots each). Shift registers reload on dot 9, 17, 25, ..., 257. Pixels emit dots 1..=256.
  • Dots 257..=320 — sprite tile fetch for next scanline. OAMADDR is forced to 0. 8 sprites × 4 fetches (garbage NT, garbage NT, PT-low, PT-high). Sprite X positions and attributes load during the second garbage fetch. Horizontal v bits reload from t at dot 257. The pattern-table fetches happen for all 8 slots regardless of how many real sprites are in range — empty slots use the cleared secondary-OAM tile $FF, producing a dummy fetch that still drives A12 to the sprite pattern table. Without dummy fetches, scanlines with zero visible sprites would emit no rise. With standard layout (BG=$0000, sprites=$1000) each slot's two garbage nametable reads pull A12 low and its pattern reads raise it, so A12 rises eight times per rendered line: 8 × (240 visible + 1 pre-render) = 1928 rises per NTSC frame (v2.9.7; pinned by a12_reports_the_hardware_stream_and_an_mmc3_filter_still_sees_241). The MMC3 counts one scanline per line because its filter ignores a rise unless A12 was low for about three CPU cycles, and only the first slot's rise follows a long low. Filtered that way, the stream still gives the 241 clocks mmc3_test_2/2-details validates. Until v2.9.7 the PPU reported only pattern-fetch addresses, so it emitted MMC3's filtered view as if it were the stream. Boards that count raw edges were starved eightfold: Acclaim's MC-ACC, mapper 91 submapper 0 (INES_Mapper_091.md: "64 unfiltered rises") and the J.Y. ASIC's A12 mode (J_Y__Company_ASIC.md: "unfiltered, eight per scanline").
  • Dots 280..=304 (pre-render only) — vertical v bits reload from t if rendering enabled.
  • Dots 321..=336 — first two BG tiles of next scanline.
  • Dots 337..=340 — two extra NT fetches (purpose debated; preserve them so MMC5's frame-end detection works).

Fast dot path vs exact dot path (v2.1.8 A1; default since v2.2.3)

Ppu::tick is the emulator's single hottest function (~46% of frame self-time; docs/performance.md). Behind a runtime knob (Nes::set_fast_dotloop, default ON since v2.2.3; default-OFF for v2.1.8 .. v2.2.2) the per-dot FSM splits into two paths:

  • Exact path (the always-correct fallback, and the shipped default through v2.2.2): the fully-general per-dot body described throughout this document — every event and quirk checked on every dot. Still taken for every dot the guard below does not admit, which is ~31% of them.
  • Fast path (Ppu::tick_visible_render_fast): a specialized straight-line handler for the common clean dot — a visible scanline, dots 1..=256, rendering stably enabled, and no sub-dot disturbance (no $2006 copy-V or PPUMASK write-delay pending, no PPUDATA state machine in flight, no armed/pending OAM-corruption, warm scanline-classification cache). It runs the identical helper sequence (OAM-corruption pointer bookkeeping → sprite-eval FSM → OAM data-bus → BG reload/ALE/fetch/inc_hori_v → inc_vert_v at dot 256 → emit_pixel → shift_bg) with the branches the guard proves un-taken elided, so it is byte-identical to the exact path.

The dispatch guard is conservative: any disturbance — a mid-scanline $2000/$2001/$2005/$2006/$2007 write, a $2007 read during render (the PPUDATA state machine), a rendering-enable toggle, an armed OAM-corruption edge, the sprite-tile-fetch / OAMADDR-reset window (dots 257..=320), the pre-render line, scanline 241, dot 0 — fails the guard and takes the exact path. Correctness dominates: when in doubt, the exact path runs. A whole-scanline batch is not possible here — the lockstep every-cycle-bus-access scheduler advances the PPU ≤3 dots per CPU cycle (docs/scheduler.md, docs/performance.md §A1), so this is a per-dot specialization only. Byte-identity across the full corpus (framebuffer + index buffer + audio + cycles + snapshot) is pinned by crates/rustynes-test-harness/tests/fast_dotloop_diff.rs.

Idle-line specialization (v2.2.3 P2, ppu-idle-line-fast, default OFF). A second handler, Ppu::tick_idle_line_fast, covers idle lines — the post-render line plus every vblank line except the VBL-set line — where the per-dot body provably reduces to three rendering-flag assignments. It is byte-identical (same differential suite, plus a vblank-I/O torture ROM that forces the guard's fall-through arms), but measures below the >3% adoption bar, so it is compiled out by default and the general path continues to serve those dots. See docs/performance.md §P2.

The "warm scanline-classification cache" in that guard (cached_visible / cached_pre_render / cached_render_line, keyed by flags_cached_scanline) is a pure function of scanline + region, so it is recomputed rather than serialized: Ppu::restore resets the key to the Ppu::new sentinel at every schema version, forcing the next tick to refill it from the restored scanline. Carrying a warm key across a restore would let a cache filled under one timeline satisfy the guard against a value computed under another.

Sprite evaluation (cycles 1..=256)

Per ref-docs/research-report.md §Sprite evaluation:

  • Cycles 1..=64 — clear secondary OAM to $FF (forced reads).
  • Cycles 65..=256 — alternate odd (read primary OAM) / even (write secondary OAM).
  • Read Y from OAM[n][m] — m is normally 0, but OAMADDR seeds n and m at dot 65 (n = (OAMADDR >> 2) & $3F, m = OAMADDR & 3; nesdev "PPU registers" -> OAMADDR: "the value of OAMADDR at this tick determines the starting address"), so a misaligned OAMADDR starts the walk on a tile / attribute / X byte and the y-test reads that byte. A $2003 write during the dots 1-64 clear therefore still moves the start. (Until v3.1.0 the FSM seeded at dot 0 only; AccuracyCoin f5f41dc2 writes $2003 at dots 28-29 of scanline 0 and exposed it.)
  • If in range for the next scanline, copy four bytes from the walk position, OAMADDR stepping by one each, whatever the alignment: a start at m = 3 copies the last byte of slot n and the first three of slot n + 1 (until v3.1.0 the FSM stopped after one byte there).
  • The fourth byte copied is evaluated AS the X position, and range-tested like Y. In an aligned walk it is the sprite's X; from m = 3 it is slot n + 1's attribute byte, read in the X position. X in range: OAMADDR += 1 only, so a misaligned walk stays misaligned. X out of range: OAMADDR += 1, then AND with $FC, realigning. Aligned, both give the next multiple of four, so only misaligned OAM sees the difference (AccuracyCoin Misaligned OAM behavior tests 4-7; until v3.1.0 the FSM always realigned, and the pre-f5f41dc2 ROM recorded the resulting failures as a pass because its fail path did not pop its return address).
  • Not in range, secondary OAM not full: OAMADDR += 4, then AND with $FC — so n advances and m is cleared, realigning the walk after the first out-of-range sprite. When n overflows to 0, evaluation completes. (Invisible whenever OAMADDR is a multiple of four, since m is already 0 at every y-test; measured at 114 occurrences across a full AccuracyCoin battery, and pinned by misaligned_oam_out_of_range_advance_follows_both_rules.)
  • When 8 sprites found, disable secondary OAM writes. Then the buggy overflow check: OAMADDR += 5, i.e. increments both n and m (without carry) and does not realign. This mis-reads tile/attr/X bytes as Y bytes, producing the documented hardware overflow misbehavior. Reproduce exactly.

Implementation state (T-23-002 / T-23-003 / B8 follow-up)

Sprite evaluation runs through tick_sprite_eval_per_dot in crates/rustynes-ppu/src/ppu.rs — a per-PPU-dot FSM matching real-hardware behavior across dots 0..=256 of every visible / pre-render scanline:

  • Dot 0: reset FSM working state.
  • Dots 1..=64: secondary-OAM clear (1 byte every 2 dots; reads of $2004 during this window would return $FF on real hardware).
  • Dots 65..=256: alternating odd/even read/write of primary OAM. Odd dots latch a byte; even dots commit the latch into secondary OAM (when copying is enabled). Eight in-range sprites fill secondary OAM and then transition into overflow-search mode, which walks the remaining 56 entries with the documented buggy n+m increment — the diagonal-read pattern that sprite_overflow_tests/4-Obscure and sprite_overflow_tests/5-Emulator exercise.
  • Dot 256: commit spr_count and pre-clear unused slot rendering-side arrays.

Sprite-tile pattern fetches still dispatch at dots 260, 268, ..., 316 via fetch_sprite_tile, collapsed into one call per slot. Since v2.9.7 the slot's first garbage nametable read (local dot 1) reports the A12 fall and the pattern fetch the rise. The second garbage read (local dot 3) shares dot 260 with the collapsed pattern fetch and runs after it here, so it reads through read_vram_unobserved and reports nothing. Reporting it would lower A12 after the rise, and the resulting eight-dot low lets an MMC3 filter count a second rise per slot: mmc3_test_2 "clocked 241 times" fails, which is how this was found. The edges delivered are the hardware's: one fall and one rise per slot. mmc3_test_2/2-details and 3-A12_clocking pass on that stream.

The FSM regression corpus lives in crates/rustynes-ppu/src/ppu.rs::tests::sprite_fsm_equivalence_*. It runs 13 deterministic edge cases + 1000 randomized OAM / scanline / sprite-size combinations through the FSM and asserts bit-identical secondary_oam, spr_count, spr_zero_in_line, and SPRITE_OVERFLOW flag at end of dot 256 against a straight-line reference implementation (reference_eval). Originally introduced as the parallel-implementation firewall gating the B8 swap from single-shot to per-dot FSM; after B8c removed the single-shot impl the corpus is the regression net pinning the FSM output.

The OAM2 fetch-address counter (OAM2Address)

Distinct from oam2_addr, the dots-1..=64 clear-window write pointer above. oam2_fetch_addr is the 5-bit counter the sprite-fetch stage reads secondary OAM through, and it is a live counter carried across scanlines rather than an index derived from the dot. Three rules, all stated by AccuracyCoin's Frozen OAM2 Increment and by neither nesdev page:

  • Reset at dots 63, 255 and 339, which also clears the "OAM2 Overflowed" freeze flag. The reset is gated on rendering, so an interval with rendering disabled across those dots steals it — which is the whole subject of the test.
  • Increment on even dots of the fetch window, wrapping the 5-bit counter. The wrap raises oam2_overflowed, and that flag then suppresses further increments, so a completed fetch leaves the counter resting at 0. The ROM describes the overflow as happening "on dot 321", which is where a completed wrap is observed by the $2004 copy-buffer load rather than a further step.
  • oam2_overflowed and oam2_fetch_frozen are two states, not one. The wrap raises oam2_overflowed at any time; dot 257 latches its value into oam2_fetch_frozen, and it is the latched copy that sprite fetch consumes. So a wrap occurring after dot 257 changes nothing about the fetch already in progress — it will be seen by the next scanline's latch instead. Both are cleared by the dots 63/255/339 reset, and both are therefore observable only when rendering is disabled across one of those dots.
  • While the latched flag is raised, every slot of sprite fetch reads OAM2[0] — the freeze the entry is named for. With OAM2 full and rendering re-enabled on or after dot 256, all eight sprites therefore load byte 0 four times over.

The counter carries NO gate of its own (v2.6.18). Its call site already sits inside the shared render_line && rendering_gate block, so the one-dot-delayed gate is applied once. It previously conjoined the live mask as well, and an AND of two delayed views takes its DISABLE edge from the shallower of the two — so a $2001 write whose effect landed during dot 339 skipped that dot's reset, left the freeze flag raised, and produced a sprite-zero hit in the very sub-test written to prove no hit occurs.

Save-state coverage (PPU_SNAPSHOT_VERSION v9/v10). oam2_fetch_addr, oam2_overflowed and oam2_fetch_frozen are serialized (v9), and so is rendering_enabled_delayed2 (v10). All four look derivable and are not: the first three re-derive at dots 63/255/339 only when rendering is enabled there, which is exactly the case the behaviour is about, and the fourth differs from rendering_enabled_delayed for precisely the one dot after a $2001 rendering edge that decides the dot-256 increment.

Save-state coverage (PPU_SNAPSHOT_VERSION v8). The FSM's working registers are part of the save-state, not derived: sprite_eval_read_latch, sprite_eval_n / _m / _found / _sec_idx, and the _copying / _done / _overflow_search / _zero_found / _first_iter phase flags, alongside the parallel OAM-data-bus model (oam_bus_*) and the dots-1..=64 clear-window write pointer oam2_addr. secondary_oam — the buffer they fill — was serialized from v1, but the pointers and phase driving it were not until v8, so a checkpoint taken mid-eval restored a full buffer next to a power-on-default walker.

This is invisible to a straight run_frame loop and shows up only where a save-state round trip lands mid-frame: the frontend's run-ahead ([input] run_ahead, default 1) does exactly that every visible frame, as do netplay rollback and TAS seeking. Before the v8 tail the AccuracyCoin battery measured 141/141 headless but 138/141 through the desktop frontend, failing Sprite Evaluation :: Arbitrary Sprite zero (error 2), Sprite Evaluation :: Misaligned OAM behavior (error 1), and PPU Behavior :: Rendering Flag Behavior (error 2). The regression net is crates/rustynes-test-harness/tests/accuracycoin_runahead.rs, which reruns the whole battery through the run-ahead cycle at depths 1 and 2 and asserts no test is lost. Mesen2 serializes the equivalent set (NesPpu<T>::Serialize: _spriteIndex, _sprite0Added, _sprite0Visible, _oamCopybuffer, _secondaryOamAddr, _spriteInRange, _oamCopyDone, _overflowBugCounter).

Compatibility: v8 is a rejection epoch, not an upconversion. PPU_SNAPSHOT_VERSION 8 is not a strictly-additive tail that older readers can truncate past, because the state it adds (the sprite-evaluation FSM + OAM data-bus model) has no correct default for a mid-frame restore — inventing one is exactly the broken-FSM restore the tail exists to prevent. So the reader accepted 1..=PPU_SNAPSHOT_VERSION and returned PpuSnapshotError::UnsupportedVersion for anything else; a .rns slot written by v2.2.2 or earlier fails to load with a clear version error rather than being upconverted or silently misread. Per ADR 0028 / ADR 0034 this is the deliberate choice, and it is confined to .rns files on disk: .rnm movies replay inputs from a power-on and carry no PPU snapshot, and netplay-rollback / TAS-seek snapshots are in-memory and always written by the running build. Since v2.9.8 (ADR 0042) the reader accepts PPU_SNAPSHOT_VERSION only: every per-version upconversion is gone, and a .rns from v2.9.7 or earlier is refused at the container header before the PPU section is read.

Future work (post-flip):

  • Reading $2004 during cycles 1-64 should return $FF (idle clear phase). The per-dot FSM has the state for this but the $2004 read path does not yet observe the FSM phase. Add when a test ROM distinguishes the behavior.
  • OAMADDR-during-rendering OAMADDR=0 forcing across dots 257-320 is modeled (the sprite-tile-load wash). The write-triggered OAMADDR ($2003) corruption glitch is now available as an opt-in revision-gated model — see OAMADDR ($2003) write corruption below.

Sprite-0 hit

Set when a non-transparent pixel of sprite 0 overlaps a non-transparent pixel of background. Constraints:

  • Cannot set on dot 255.
  • Cannot set if either left-column-show flag is off and X is 0..=7.
  • Cannot set on the pre-render scanline.
  • Cleared at scanline 261 (or 311 PAL) dot 1.

Determined during sprite rendering, not evaluation.

OAM decay (optional, opt-in, default-OFF) (v2.1.4, F2.3)

Real 2C02 OAM is dynamic RAM. Sprite evaluation reads every sprite's Y byte each rendered scanline, which implicitly refreshes the cell charge; but with rendering disabled long enough the un-refreshed rows lose charge and decay to a fixed garbage pattern. RustyNES models this exactly like Mesen2 (NesPpu::ReadSpriteRam / WriteSpriteRam, OamDecayCycleCount = 3000), gated behind a default-OFF toggle. This model is derived from Mesen2's code, GPL-3.0-or-later, and is listed in ppu.rs's provenance header, NOTICE and docs/originality-and-provenance.md Section 1. That record was missing from v2.1.4 until v2.9.4.

  • Granularity — one CPU-cycle timestamp per 8-byte row (32 rows over the 256-byte OAM), oam_decay_cycles[addr >> 3]. The CPU cycle is derived from the PPU's monotonic dot counter (dot_counter / 3) — deterministic, no wall-clock.
  • On every OAM read (the $2004 read and the primary-OAM reads done during sprite evaluation): if cpu_cycle − oam_decay_cycles[addr>>3] ≤ 3000, refresh the timestamp; otherwise the row has decayed — rewrite all 8 of its bytes to ((sprAddr & 3) == 2) ? (sprAddr & 0xE3) : sprAddr (attribute bytes keep only their implemented bits; the rest read back their own low address). The refresh-on-sprite-eval is what keeps rows alive during normal rendering.
  • On every OAM write ($2004 write and OAM DMA): write the byte, then refresh the row's timestamp.
  • Region — NTSC/Dendy only. On PAL the far more frequent refresh cadence masks decay entirely, so the model never acts there (matching Mesen2).
  • Default-OFF byte-identity — with the toggle off (the default) no OAM access consults the decay state, so the framebuffer/audio/replay output and the visual/external_real_games/AccuracyCoin suites are byte-identical to a build without the field. Enable via Nes::set_oam_decay(true) / [emulation] oam_decay / Settings → Emulation → OAM decay (accuracy).
  • Save-state — the per-row timestamps are serialized in the PPU_SNAPSHOT_VERSION v7 tail as a relative age (now − timestamp), reconstructed against the live counter on load, so a run-ahead / netplay snapshot→restore stays byte-identical even though the free-running dot_counter itself is not serialized. The enable flag is a frontend/config knob re-applied on load (not serialized), like region.

PPU die revision (optional, opt-in, default-OFF) (v2.1.7, P5)

Real RP2C02 dies shipped across several letter revisions. RustyNES exposes a selectable PpuRevision (rustynes_core::PpuRevision) that gates the one revision-dependent behavior it models, the OAMADDR $2003 write corruption:

  • Rp2c02H (default) — the later "H"-class die RustyNES has always modeled. No $2003 write corruption. Byte-identical to a build without the field — AccuracyCoin, the commercial oracle, and the visual/audio suites are unaffected at the default.
  • Rp2c02G — the earlier die ("rev E+" in the nesdev notes). Additionally models the $2003 write corruption (below). Opt-in.

The selection is config, re-applied on load like region / the active palette — it is not serialized in the snapshot. (The corruption state it can arm — oam_corruption_pending / oam_corruption_index — is already in the PPU_SNAPSHOT_VERSION v6 tail, so an armed corruption still round-trips.) Set via Nes::set_ppu_revision / [emulation] ppu_oamaddr_corruption.

OAMADDR ($2003) write corruption (optional, opt-in, default-OFF)

On the earlier Rp2c02G revision, writing OAMADDR ($2003) while rendering is enabled on a visible or pre-render scanline corrupts one 8-byte OAM "row". A few titles — notably Huge Insect — trip it. RustyNES models it by reusing the same CorruptOAM row-copy the rendering-disable corruption uses: on such a write it arms oam_corruption_pending with oam_corruption_index = (value >> 3) & 0x1F (the row the write's high bits select), and the existing per-dot commit path applies it on the next rendered dot — copying OAM row 0 over the targeted row (plus the matching secondary-OAM byte). The !oam_corruption_pending guard defers to an already-armed corruption so the two sources never race.

  • Render-gated — only a $2003 write with rendering enabled on a visible / pre-render scanline arms it; writes outside rendering (or in vblank) never corrupt, on any revision.
  • Default-OFF byte-identity — the default Rp2c02H revision never arms it, so the corruption path is inert and the default build is byte-identical.
  • Honesty — the precise 2C02 letter-revision taxonomy of this glitch, and its exact per-title byte output, are not independently oracle-verified in this cut; RustyNES offers the model as an opt-in approximation keyed to a single "earlier revision" selection rather than claiming exact silicon fidelity. See docs/accuracy-ledger.md.

Power-up palette RAM (optional, opt-in, default-OFF) (v2.1.7, P5)

The 2C02's palette RAM is not cleared at power-on; different consoles come up with different garbage, and a few titles sample it before writing. RustyNES exposes a PaletteInit (rustynes_core::PaletteInit):

  • Zeroed (default) — all 32 palette-RAM bytes power up to $00, the established deterministic state. Byte-identical.
  • Blargg — the canonical "blargg" 32-byte power-up dump (mirrors TriCNES's BlarggPalette), each cell 6-bit masked like a $2007 write. Opt-in.

It writes only palette_ram, which the snapshot already serializes, so no snapshot-format change is required. Best applied at power-on (palette RAM is preserved across a warm reset, like real hardware); the selection is stored so a power-cycle re-applies it. Set via Nes::set_power_up_palette / [emulation] blargg_power_up_palette.

Power-on work-RAM model (optional, opt-in, default-OFF) (v2.1.7, P5)

Real hardware powers up with unreliable 2 KiB CPU work RAM (nesdev "CPU power up state"); a few titles read it before writing (Final Fantasy's RNG seed, River City Ransom, Cybernoid). rustynes_core::PowerOnConfig selects the fill via PowerOnRam:

  • Zeroed (default) — all-zero work RAM + open bus (byte-identical; what CI, the oracle, and save-state tests use).
  • Seeded(u64) — deterministic xorshift64 randomization keyed on the seed (the existing developer mode; same seed ⇒ identical RAM).
  • Filled(u8) — every work-RAM byte set to a uniform documented pattern.

All fills are deterministic (no wall-clock / OS RNG), so the same config + ROM + input ⇒ bit-identical contract holds. The fill is stored on the bus so a power-cycle re-applies it (power_cycle == fresh boot). Build via Nes::from_rom_with_power_on_config / set via Nes::set_power_on_ram, or the [emulation] randomize_power_on_ram + power_on_ram_seed config keys. (This strictly generalizes Nes::from_rom_with_power_on_seed, which now routes through PowerOnRam::Seeded.)

Host settings across a power cycle (v2.9.8)

A power cycle rebuilds the PPU from Ppu::new, and Ppu::adopt_settings_from carries the host's settings onto the new one: the custom or generated palette (the RGBA lookup is rebuilt to honour it), the extra-scanlines overclock, the fast dot path selector and the OAM-decay switch (through set_oam_decay, so the row ages start from the rebuilt PPU's cycle 0, as on a fresh console that enables it). The die revision and the two power-on fills above are stored on the bus, which re-applies them; the active palette and the 2C05 identity are board identity, re-derived from the cartridge. The state and fetch traces are capture buffers and are not carried. The provenance stores (debug-hooks) stay armed and are emptied. Until v2.9.8 the four carried settings reverted to their defaults in the cycle, and every host had to push them again. Pinned by a_power_cycle_keeps_every_ppu_and_apu_setting, a_power_cycled_console_with_settings_runs_as_a_fresh_one and, for any future configuration field, every_config_field_survives_a_power_cycle.

Loopy v / t / x / w

Per ref-docs/research-report.md §Internal scroll registers:

  • PPUSCROLL write 1 → t bits 4-0 = X[7:3]; x = X[2:0]; clear w.
  • PPUSCROLL write 2 → t bits 14-12 = Y[2:0] (fine Y); t bits 9-5 = Y[7:3] (coarse Y); set w.
  • PPUADDR write 1 → t bits 13-8 = value & 0x3F; t bit 14 = 0; clear w.
  • PPUADDR write 2 → t bits 7-0 = value; copy t to v; set w.
  • PPUSTATUS read → clear w.
  • During rendering at dot 256 of every visible scanline, v Y increments (with the 29→0 wrap-and-flip-nametable-Y quirk). "During rendering" here means rendering as of TWO dots ago, not the one-dot-delayed gate the rest of the pipeline uses (v2.6.18, rendering_enabled_delayed2): AccuracyCoin's Frozen OAM2 Increment test 2 states the rule outright — "Rendering is enabled on dot 256, but the PPU's vertical scroll is NOT incremented" — and that sentence only parses if the enable IS on dot 256. A $2001 write whose effect lands during dot 255 is in force from the start of 256, so a one-dot gate fires the increment and hardware does not. The check is deliberately not conjoined with the shared gate: an AND of two delayed views takes its DISABLE edge from the shallower one, and that edge is what test 4 measures. At dot 257, horizontal bits of v reload from t. At dots 280..=304 of pre-render, vertical bits reload.
  • Coarse X increment at every 8th dot of fetch windows (dots 8, 16, ..., 256, 328, 336).

Register quirks (must reproduce)

  • PPUSTATUS read clears VBL flag and w.
  • PPUDATA read buffering: returns previous buffered value, fills buffer with current v's data; palette reads ($3F00-$3FFF) bypass buffer but still update it with the underlying nametable mirror.
  • PPUDATA ($2007) read during active rendering: a $2007 read while rendering is enabled does NOT do a clean buffered VRAM fetch. Instead it returns the value the rendering fetch cadence most recently drove on the VRAM data bus (the "render buffer"), and the PPUDATA state machine reloads data_buffer a few PPU dots after the read ends rather than immediately — modelled as a short PPU-dot countdown (ppudata_sm_countdown) with the v-increment glitch deferred to the same TStep dot (ppudata_v_inc_pending). This is the AccuracyCoin $2007 Stress per-dot-read bracket; verified complete (no v1.6.0 Workstream D change needed). The diag knobs RUSTYNES_2007_DELAY / RUSTYNES_2007_VINC are read-only investigation tools, not shipped behavior.
  • PPUDATA increment: 1 or 32 per PPUCTRL bit 2.
  • OAMADDR write during rendering: glitches OAMADDR's high 6 bits; OAM data is not modified during rendering writes.
  • OAMADDR ≥ 8 at rendering start: row at OAMADDR & 0xF8 is copied to OAM[0..=7]. (2C02G bug.)
  • $2004 attribute-byte read mask: every 4th OAM byte (at offset 2 within each 4-byte sprite group) is the attribute byte and has bits 2-4 unimplemented; reads return 0 in those bits regardless of what was stored. Writes still store the full byte. Required by ppu_open_bus.nes.
  • Open-bus: PPUSTATUS bits 4-0 reflect last-written-or-read PPU bus value with per-bit-group decay (3-30 ms hardware; we use a coarser 558.7 ms approximation for emulation — one million CPU cycles at NTSC, the same number the MiSTer DUT carries as 3,000,000 dots). Three independent decay groups: bits 0-4, bit 5, bits 6-7. Writes refresh all three groups; reads of $2002 refresh only bits 5-7 (the lower 5 bits' value AND timer carry over); palette $2007 reads refresh only bits 0-5 (the high 2 bits' value AND timer carry over). Required by cpu_dummy_writes_ppumem and ppu_open_bus tests 7 and 9.
  • PPUCTRL NMI bit 0→1 while VBL set: triggers NMI immediately.
  • Post-reset window: writes to $2000/$2001/$2005/$2006 ignored for ~29,658 NTSC CPU cycles after reset (33,132 PAL).

Picture and border output

The stock picture region is 256x240 pixels. The PPU also generates a border region around it on the analog signal. RustyNES exposes only the 256x240 picture framebuffer to the frontend today; any future overscan/border mode should be modeled as an output option, not as additional framebuffer pixels that change core rendering tests.

Greyscale + emphasis

PPUMASK bit 0 (greyscale): output color ANDed with $30. Bits 7-5 (BGR emphasis) are applied through the 512-entry rgba_lut, per pixel during emission.

PAL and Dendy swap bits 5 and 6 (v3.1.0, T-PAL-EMPHASIS). NESdev "Colour emphasis": bit 5 emphasises red on the NTSC 2C02 and green on the PAL 2C07 and the Dendy, bit 6 the reverse, and bit 7 is blue on all three. emit_pixel therefore forms the emphasis index as the PHYSICAL tint (bit 0 red, bit 1 green, bit 2 blue), exchanging bits 5 and 6 off NTSC, and both framebuffers carry it, so the composite filters see the right tint as well. Until v3.1.0 every PAL or Dendy game that set emphasis showed the wrong colour. Pinned by pal_and_dendy_swap_the_red_and_green_emphasis_bits. The MiSTer core has no PAL mode, so there is no RTL counterpart.

Emphasis model (v2.9.8, T-EMPHASIS-MODEL). The hardware has one attenuator shared by the three bits, armed during the phases of colours $C, $4 and $8 for bits 5, 6 and 7, so it runs 6, 10 or 12 of the 12 colour phases for one, two or three bits, and it never touches columns $E/$F (nesdev_wiki/NTSC_video.xhtml, "Color Tint Bits"). emphasis.rs models exactly that from the page's measured plain and attenuated levels, decodes it (twelve samples, burst on -U, the page's YUV-to-RGB matrix), and keeps only the CHANGE: EMPHASIS_DELTA[(emphasis << 6) | colour] is the model's emphasised RGB minus its plain RGB, added to the FBX base (or a loaded .pal) and clamped. So an un-emphasised frame is FBX exactly; one bit tints toward the complement of its phases; all three bits darken without tinting. The table is data because build_rgba_lut is a const fn; the test the_committed_table_is_the_documented_model recomputes it. Until v2.9.8 each set bit dimmed the other two channels to 13/16, compounding, which three bits turned into an even 0.66 dim of everything, where the hardware attenuates the whole signal once. The MiSTer core carries the same 512 colours, checked entry by entry by its palette-gate.

The presentation-only raw_signal model below is separate, and still attenuates by 0.746.

Index framebuffer + NTSC phase (composite-filter outputs)

Alongside the RGBA framebuffer, the emit path writes a parallel palette-index framebuffer (index_framebuffer()): one u16 per pixel holding the same 9-bit (emphasis << 6) | colour_index value (0..=511) used to look up the RGBA in the 512-entry rgba_lut. It is therefore an exact index-space mirror of the displayed picture — the invariant rgba_lut[index[p]] == framebuffer[p] holds for every emitted pixel (unit test index_framebuffer_mirrors_rgba_output). The true composite NES_NTSC filter (T-110-A1) uploads this as an R16Uint texture and reconstructs the analog signal in a shader.

ntsc_phase() exposes the per-frame videoPhase (0..=2 on NTSC; frame parity on PAL/Dendy), snapshotted at each frame boundary from a free-running master-cycle counter. This is the source of the NTSC dot-crawl; the filter derives the per-scanline (videoPhase*4 + y*341*8) and per-pixel (x*8) phase from it.

Both are output-only / cosmetic: they carry no logical state and feed no emulation path, so the determinism and AccuracyCoin contracts are unaffected and the no_std chip stack is untouched. Unlike the RGBA framebuffer (which IS serialized in the PPU snapshot), the index framebuffer + phase are NOT saved — they regenerate on the next emitted frame, so a state loaded while paused shows correct NTSC from the first frame after resume.

Raw composite-signal model (raw_signal, v2.1.9 P4)

rustynes_ppu::raw_signal is a new, parallel, presentation-only output that keeps the 2C02 composite waveform un-decoded. Where the generated palette (palette_gen, F1.4) pre-integrates each of the 64 base colours to a single RGB triple (an ideal TV over one pixel), raw_signal emits, for every (index 0..=63, emphasis 0..=7) pair, the twelve per-subcarrier-phase composite voltages the chip actually generates within a pixel. A shader (or any host NTSC decoder — see the frontend signal_decode.wgsl pass) demodulates the real signal across neighbouring pixels, reproducing signal-domain artifacts a per-colour RGB palette structurally cannot: composite colour bleed, dot crawl, and the waterfall/dither transparency tricks (Kirby's Adventure waterfalls, the 240p test suite colour-bleed screens) that depend on adjacent-pixel chroma mixing.

The model is the canonical Bisqwit nes_ntsc / Mesen2 "raw palette" generator (nesdev "NTSC video"): a two-level chroma square wave over 12 subcarrier phases, with InColorPhase((color + phase) % 12 < 6) positioning the hue, the luma nibble selecting the two LEVELS voltages, and each of the three emphasis bits attenuating by 0.746 during the phases overlapping its primary's hue region. generate_raw_signal_lut() yields the full 512-row (index-major) × 12-phase normalized table a host uploads as a signal texture (heap-boxed; built once at shader-setup time, never on a hot path).

Determinism: the waveform is level-lookup + one multiply + one affine normalize with no transcendental, so the f32 output is bit-identical across x86 / aarch64 / wasm / thumbv7em under IEEE-754 without libm. An in-crate GOLDEN_SIGNAL snapshot (row $00, all 8 emphasis) locks that cross-target contract via a const-eval sibling of the runtime path. It is additive + default-OFF: nothing here feeds the deterministic core or the default presentation, so the default framebuffer golden vectors and AccuracyCoin stay byte-identical (141/141). It is consumed only when the frontend explicitly selects the signal-decode presentation shader.

HD-pack tile-source export (hd-pack feature; v1.2.0 C3)

Behind the default-OFF hd-pack cargo feature, the emit path writes a third parallel buffer — hd_tile_source(): one HdTileSource per visible pixel, in lockstep with the index framebuffer. Each record names the CHR tile that produced the pixel: the 16-byte pattern-table tile base address ($0000..=$1FF0, fine-Y / in-tile-row masked off), the final 2-bit palette group, the sprite flip flags, and whether the source was a sprite or the background (HD_TILE_NONE marks a transparent / universal-background pixel). The BG tile address rides a small two-stage queue (hd_bg_addr_cur / hd_bg_addr_next) latched at fetch_bg_lo and promoted in reload_bg_shift_regs / prefetch_shift_bg_regs, so it tracks the BG pattern shift registers tile-for-tile; sprite tile bases are stashed per slot in fetch_sprite_tile.

It exists purely so the frontend's Mesen-style HD-pack loader can group pixels by 8×8 cell, hash the referenced CHR bytes (Mesen CRC32), and substitute hi-res replacement tiles at blit time. Like the index framebuffer it is output-only: it reads no new VRAM, issues no new A12 / mapper events, mutates no emulation state, and is not part of the save-state — so the framebuffer is byte-identical with the feature on or off (proven against the full ROM corpus: AccuracyCoin 139/141 — the two newest upstream PPU tests are known gaps — nestest 0-diff, blargg / kevtris green, identically with hd-pack on and off). The whole export is #[cfg(feature = "hd-pack")]-gated, so the default and no_std builds carry no memory or codegen cost.

Fetch-address trace (ppu-fetch-trace, v2.5.4, default-off)

crates/rustynes-ppu/src/fetch_trace.rs records the address of every PPU VRAM read, tagged with the frame, scanline and dot it was issued on — 12 bytes per record, magic RNESFTCH, schema 1. It exists so an independent implementation can be compared against the address bus, which a 2C02 drives on real pins: two correct implementations cannot disagree about it, unlike the latches behind it, which are this project's decomposition of what was fetched.

The hook sits at the single choke point, Ppu::read_vram, rather than at each call site — a per-site hook is how a fetch path added later escapes the trace silently. It is output-only: no extra VRAM reads, no A12 or mapper events, no emulation state mutated, and deliberately not in the save state (so snapshot_schema_audit carries it as an explicit exclusion with its reason). The whole module is #[cfg(feature = "ppu-fetch-trace")]-gated, so the default and no_std builds carry no memory or codegen cost, and AccuracyCoin (RAM) 141/141 plus nestest 0-diff were re-verified with it present.

The trace is a gate; the latches are not. See RustyNES_MiSTer/docs/rung3-ppu.md for the full partition and for what a fetch-address comparison cannot adjudicate.

Edge cases and gotchas

  1. Mid-scanline scroll write. Writing PPUSCROLL or PPUADDR mid-scanline shifts the BG tile fetch immediately. Common technique for status bars on top + scrolling playfield below; Battletoads, Megaman III, Felix the Cat all exercise this.
  2. PPUMASK rendering toggle mid-screen (OAM corruption). Disabling rendering mid-screen during sprite evaluation corrupts 1 OAM row. The model is a faithful port of TriCNES's PPU_Render_SpriteEvaluation (NOT Mesen2's _corruptOamRow, which Mesen ships off-by-default and documents as unfinished): the corrupted row index is the live secondary-OAM write pointer (OAM2Address) captured at the instant rendering is disabled — NOT the raw dot (dot >> 1). The disable edge is armed by the $2001 write (when rendering was on and the new mask turns both BG + sprites off on a render line), the index is captured during the dots 1-64 secondary-OAM-clear window (the pre-render line is excluded — it is read-only for corruption), and the corruption is DEFERRED until rendering re-enables (or the next pre-render line). On commit: OAM[index*8 + i] = OAM[i] for i in 0..8 (index 0x20 wraps to 0), and secondary_oam[index] = secondary_oam[0]. Using the raw dot was the SMB3 (MMC3) bug — NMI/DMA jitter shifted the HUD-split disable dot so the flagged row intermittently landed on Mario's OAM row, wiping his sprite (80/240 idle frames dropped him). The eval-pointer index makes it deterministic. (Separately: turning rendering off mid-screen with v in $3C00-$3FFF corrupts palette RAM as mapper hardware races with the address bus — not yet modeled.)
  3. Sprite zero at X=0 with left-column hidden. Common false-fire trap.
  4. MMC3 IRQ depends on PPU A12 toggling. PPU calls bus.notify_a12(level) on every transition. Don't filter; the mapper does. Emit A12 from every actual address-bus access: every read_vram (pattern, nametable, attribute and sprite fetches, since v2.9.7), $2007 reads/writes and $2006 low-byte writes. Internal loopy increments (inc_hori_v, inc_vert_v) must NOT emit, because they don't drive the address bus and emitting from them spuriously toggles A12 against v's fine-Y bit 0. Standard layout (BG=$0000, sprites=$1000) produces eight A12 rises per rendered scanline (one per sprite slot); an MMC3-style filter (low for three CPU cycles) keeps the first, at dot 260. Where each fetch reports its level matters to the MMC3, not only that it does. The NESdev MMC3 page puts the counter's clock at dot 260 with the background at $0000 and sprites at $1000, and at dot 324 of the previous scanline with the background at $1000 and sprites at $0000. Both are one dot before the pattern-low fetch's ALE dot (261 and 325). The sprite path reports at that convention (its rises land at 260, 268, ... 316). Since T-MMC3-BG-A12 the background does too: observe_bg_a12_lead reports the pattern fetches' high level at group phase 3 (dot 324 for the prefetch, dot 4 for a line's own first group) and the return to the nametable fetch's low level at phase 7. Before it, the background reported at its READ dots, two dots later, and a rise that landed on the first dot of a CPU cycle's catch-up reached the MMC3 a cycle late: blargg 4-scanline_timing failed at sub-test 9 ("Scanline 0 IRQ should occur sooner when $2000=$10"). With the background at $0000 both reports are low and nothing changes. Pinned by background_a12_rises_at_the_mmc3_pages_dot_324. Dot 0 drives the background CHR address too. The NESdev PPU rendering page: "The value on the PPU address bus during this cycle appears to be the same CHR address that is later used to fetch the low background tile byte starting at dot 5 (possibly calculated during the two unused NT fetches at the end of the previous scanline)." So on a visible line A12 takes the background table's level at dot 0, and with the background at $1000 the low of the two dummy nametable fetches (dots 337-340) is too short for an MMC3 to count the line's first pattern rise again. Two exceptions, both from the same page. The pre-render line's dot 0 does not do it: the line before it made no fetches, and blargg 4-scanline_timing sub-test 8 fails if it does. And on an odd frame the skip jumps "from (339,261) to (0,0), replacing the idle tick at the beginning of the first visible scanline with the last tick of the last dummy nametable fetch", so scanline 0's dot 0 stays low (dot0_replaced, set at the skip, serialized in PPU_SNAPSHOT_VERSION 12 because it lives across the frame boundary). That exception is the MMC3 page's "the pre-render scanline will decrement the counter twice every other vertical redraw", and blargg's sub-test 12 runs on an even frame (sync_vbl_even), where it must not. With both, 4-scanline_timing passes all 13 sub-tests. Pinned by scanline_0_dot_0_drives_bg_chr_only_when_the_skip_did_not_replace_it (red with the skip exception removed). Mappers built on an MMC3 core but keeping their own counter must apply that filter (a12_filter.rs, v2.9.7); a bare rising-edge test counts eight per line.
  5. Scanline-241 dot-0 race. Reading PPUSTATUS at scanline 241 dot 0 returns 0 and suppresses NMI for that frame. Test ROM coverage required.
  6. Palette read top 2 bits = open bus. Palette is only 6-bit; the high 2 bits come from the open-bus latch.
  7. An OAM DMA byte fills the I/O latch (v2.9.5). Each DMA transfer is a $2004 write, and "writing any value to any PPU port ... will fill this latch" (nesdev_wiki/PPU_registers). So after a DMA, $2002's low five bits read the last byte transferred. The MiSTer core modelled this first; its ledger entry 3.1c had held it open because AccuracyCoin's Open Bus seemed to disagree. On the oracle, all 144 AccuracyCoin entries report the same result with and without the fill.
  8. 8×16 sprites use bit 0 of tile index for pattern table. PPUCTRL bit 3 is ignored for 8×16 mode.
  9. Palette backdrop-override (rendering disabled, v in $3F00-$3FFF). When rendering is disabled and the VRAM address v points into palette space ($3F00-$3FFF), palette RAM is addressed by v, so the PPU outputs the color at v & 0x1F — with the $10/$14/$18/$1C → $00 universal-backdrop mirroring — instead of the universal backdrop ($3F00). This is a display artifact only: palette RAM is not mutated, and it cannot occur while rendering is enabled (the fetch pipeline owns v). Implemented in Ppu::emit_pixel as !rendering_enabled() && (v & 0x3F00) == 0x3F00 → read_palette(0x3F00 | (v & 0x1F)), byte-exact with TriCNES (Emulator.cs, the rendering-disabled PaletteRAMAddress = PPU_v & 0x1F path). This is what makes the full_palette / flowing_palette demos display all 64 colors at once (physically impossible through normal rendering). Distinct from the palette-RAM corruption in item 2 above (that mutates RAM and is not yet modeled; this is a read-through display effect).

Test plan

  • ppu_vbl_nmi (10 sub-ROMs): VBL flag timing to one PPU clock.
  • ppu_open_bus: open-bus reads.
  • sprite_overflow_tests (5 sub-ROMs): the buggy n+m increment.
  • sprite_hit_tests_2005.10.05 (10 sub-ROMs): sprite-0 hit edge cases.
  • oam_read, oam_stress: OAM access behavior.
  • ppu_sprite_hit: timing of sprite-0 hit set.
  • Visual diff against Mesen2 reference framebuffers for a curated set of demo ROMs (no copyrighted content).

Open questions

  • Open-bus decay model. Per-bit-group with 558.7 ms decay (one million CPU cycles at NTSC; the "~600 ms" this line and Ppu's comment carried was wrong by 7%), three timers — bits 0-4 / bit 5 / bits 6-7 — independently refreshed by writes and the subset of reads that drive each bit group. See "Open-bus" entry above and Ppu::refresh_open_bus. The deadline is a deliberate, measured deviation from the documentation, not an approximation nobody checked. PPU_registers.xhtml says "at least one bit ... decays after 3ms to 30ms, faster when the PPU is warm"; this model is ~19x slower. Swept at v2.6.3: ppu_open_bus.nes constrains only < 1000 ms (its decay checks are 100 x delay_msec 10 loops asserting the value has reached zero) and passes at 3, 10, 20, 30 and 100 ms — with AccuracyCoin still 141/141, nestest matching its golden log and all eight nes_blargg tests green at 30 ms. A documentation-derived value is therefore available, and is not taken: moving it changes shipped behaviour for every game that reads open bus after a long gap, and nothing here can adjudicate which value a 2C02 actually has. Recorded so the next person does not re-derive the sweep. The MiSTer DUT carries the identical value as 3,000,000 dots and is not free to move it — its goldens come from this model, and its corpus forces >= 523.4 ms; see RustyNES_MiSTer/docs/oracle-vs-documentation.md §1.1.
  • 2C02 vs 2C07 differences. PAL-only behaviors (no odd-frame skip; different post-reset masking window). Implement region as a parameter; don't fork the PPU code.
  • Vs. System PPU variants (2C03, 2C04, 2C05). Out of v1.0 scope; design Ppu so a future Ppu2C03 could share most code.