retro-silicon

Components

video/ppu-2c02

The NES's 2C02 PPU (NTSC), dot-exact, with a save-state port

Portable Sim: passing (15 passed). Used by Nintendo Entertainment System. Source.

BoardStateRun
Sipeed Tang Nano 20Kverified2026-10-28, at ac84cec
Sipeed Tang Console 138Kstale2026-10-25, at f2536e6
TUL PYNQ-Z2verified2026-10-28, at ac84cec

Verified: the latest run on that board of a system using it passed every test, and the component hasn't changed since. Stale: it has changed since that run.

README

The NES’s 2C02 picture processing unit (NTSC), dot-exact: 341 dots by 262 lines, with background and sprite rendering, scrolling, the palette, VBL and NMI, sprite 0 hit, and sprite overflow with its hardware bug. Its timing follows the 2C02 as MesenCE models it, so its frames match MesenCE’s pixel for pixel; that is how it’s tested.

Module ppu_2c02 (the folder name can’t start a module name), and ppu_2c02_rgb, the 2C02’s 512 colours ({emphasis, colour}) as 24-bit RGB, registered: a ROM that tools/gen_rgb.py generates by simulating the 2C02’s composite signal (its measured voltage levels, 12 phases of the colour subcarrier, and the emphasis bits’ attenuation) and decoding it as an NTSC TV does. It’s the PPU’s colours, not any emulator’s palette; a test checks the RTL matches the script, and gen_rgb.py --png draws it.

Timing

Ports

PortDirWidthMeaning
clk, rstin1Clock; synchronous reset to the power-up state
cein1One dot
reg_ce, reg_we, reg_addr, reg_wdatain1, 1, 3, 8A register access ($2000 + reg_addr)
reg_rdataout8The last read’s data
nmi_nout1NMI, active low: VBL and PPUCTRL bit 7
vaddr, vwe, vwdataout14, 1, 8Video memory address ($0000-$3FFF) and write
vrdatain8Video memory read data
pix_valid, pix, pix_x, pix_yout1, 9, 8, 8A pixel
frame_endout1One-clock pulse at line 240, dot 0
dbg_line, dbg_dotout9The position: the dot last run
st_addr, st_rdata, st_we, st_wdata7, 32, 1, 32Save-state port (below)

The palette, OAM and secondary OAM are inside the PPU; the pattern tables and nametables are outside, on the video memory port.

Parameters

ParameterDefaultMeaning
OPEN_BUS_DECAY3Frames before an open-bus bit that nothing refreshes reads 0 (1-6). MesenCE uses 3; real 2C02s are said to decay faster

Save state

The port follows docs/conventions.md: 101 words, every register, pipeline stage and internal memory.

WordsHolds
0-63OAM, 4 bytes a word, byte 0 in bits 7-0
64-71Palette RAM, likewise
72-79Secondary OAM, likewise
80-87The 8 sprite units: pattern low, pattern high, X, then valid, priority and palette
88-100Registers and pipeline, packed as listed at the end of the RTL

OAM has no reset, as on the chip: a system clears it through this port at power-up if it wants it clear.

Status

Unit tests: frame length with and without rendering (the odd frame’s skipped dot), VBL set and clear times, NMI and its suppression, $2007’s buffer, palette mirrors and increment, a background tile with fine X scroll, sprite 0 hit’s first dot, and a save-state round trip mid-frame with rendering and register writes going on.

In systems/nes, 42 test ROMs pass and their frames match MesenCE’s (harness/golden/nes_frames_crc32.txt), in the sim and on a Tang Nano 20K: blargg’s ppu_vbl_nmi (all 10), ppu_open_bus, oam_read, the 2005 sprite hit (11), sprite overflow (5), VBL/NMI timing (7) and PPU (5) tests, nmi_sync’s demo and full_palette.

Planted bugs the ROMs catch: sprite 0 hit allowed at X = 255, VBL set a dot late, no odd-frame skipped dot, sprite overflow evaluation without its hardware bug, and palette writes to $3F10 that don’t reach $3F00.

Not modelled yet: OAM decay and the row-corruption glitches MesenCE has off by default; the 2C02E’s OAM corruption at the pre-render line when OAMADDR isn’t 0; sprites on line 0 after an odd frame’s skipped dot (MesenCE shifts them a pixel); and sprite units that pause when rendering is turned off mid-line (here they’re compared by X). PAL and the arcade PPUs aren’t parameters yet.

About 5,800 LUTs, 450 ALU cells and 1,170 flip-flops on a GW2A-18C by Yosys’s count, plus 337 flip-flops for the registered next dot; OAM in 66 LUT RAMs. Registering the next dot took the Tang Console 138K’s NES build from 49 MHz to 65-72 MHz, and the Tang Nano 20K’s from 45 to 70 MHz. Not optimised yet.

In systems/nes on a Tang Console 138K at 50 MHz, too, the same 42 test ROMs pass with MesenCE’s frames. Planted bug the ROMs catch: ce a clock after the memory read instead of two, so the next dot settles on the byte before.

References