-
-
Notifications
You must be signed in to change notification settings - Fork 0
PPU 2C02
References: Nesdev PPU, PPU power up state, PPU registers, PPU rendering, and PPU sprite evaluation.
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).
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.
- 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/$1Cmirror$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.
| 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 LockstepBus::cpu_read/cpu_write performs the bus access before the cycle's 3 PPU dots tick, 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 bus's edge detector samples the /NMI line between every PPU dot of tick_one_cpu_cycle so a glitched edge is still latched.
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. This compensates for LockstepBus's atomic write-before-tick ordering.
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.
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, not the VBL-set line, 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. Byte-identical at zero.
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.
- 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
vbits reload fromtat dot 257. The pattern-table fetches happen for all 8 slots regardless of how many real sprites are in range. -
Dots 280..=304 (pre-render only) — vertical
vbits reload fromtif 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).
Sprite evaluation runs through 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
$2004during this window would return$FFon 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+mincrement — the diagonal-read pattern thatsprite_overflow_tests/4-Obscureandsprite_overflow_tests/5-Emulatorexercise. -
Dot 256: commit
spr_countand pre-clear unused slot rendering-side arrays.
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.
-
PPUSCROLL write 1 →
tbits 4-0 = X[7:3];x= X[2:0]; clearw. -
PPUSCROLL write 2 →
tbits 14-12 = Y[2:0] (fine Y);tbits 9-5 = Y[7:3] (coarse Y); setw. -
PPUADDR write 1 →
tbits 13-8 = value & 0x3F;tbit 14 = 0; clearw. -
PPUADDR write 2 →
tbits 7-0 = value; copyttov; setw. -
PPUSTATUS read → clear
w. -
During rendering at dot 256 of every visible scanline,
vY increments (with the 29→0 wrap-and-flip-nametable-Y quirk). At dot 257, horizontal bits ofvreload fromt. 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).
-
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$2007read 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"). - 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 & 0xF8is copied to OAM[0..=7]. (2C02G bug.) -
$2004attribute-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. - Open-bus: PPUSTATUS bits 4-0 reflect last-written-or-read PPU bus value with per-bit-group decay.
- PPUCTRL NMI bit 0→1 while VBL set: triggers NMI immediately.
PPUMASK bit 0 (greyscale): output color ANDed with $30. Bits 7-5 (BGR emphasis): each modulates one color channel down. Both apply per-pixel during emission.
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.
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.
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. 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.
- 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.
-
PPUMASK rendering toggle mid-screen (OAM corruption). Disabling rendering mid-screen during sprite evaluation corrupts 1 OAM row. 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 eval-pointer index makes it deterministic. - Sprite zero at X=0 with left-column hidden. Common false-fire trap.
-
MMC3 IRQ depends on PPU A12 toggling. PPU calls
bus.notify_a12(level)on every transition. Don't filter; the mapper does. Only emit A12 from actual address-bus drivers (BG/sprite pattern fetches,$2007reads/writes,$2006low-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 againstv's fine-Y bit 0. Standard layout (BG=$0000, sprites=$1000) must produce exactly one A12 rise per rendered scanline at PPU dot 260. - Scanline-241 dot-0 race. Reading PPUSTATUS at scanline 241 dot 0 returns 0 and suppresses NMI for that frame.
- Palette read top 2 bits = open bus. Palette is only 6-bit; the high 2 bits come from the open-bus latch.
- 8×16 sprites use bit 0 of tile index for pattern table. PPUCTRL bit 3 is ignored for 8×16 mode.
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. Licensed under MIT OR Apache-2.0. | GitHub Repository | Web Demo