NES PPU VBlank and NMI Timing: Model the Event, Not Just the Frame
A timing-focused guide to NES VBlank status, PPUCTRL NMI gating, PPUSTATUS reads, regional timing, races, and emulator validation.
Many early NES programs wait for vertical blank before changing graphics memory. An emulator that treats VBlank as a boolean attached to “the next frame” may pass ordinary games and still fail a title that polls the status register, enables NMI late, or reads the register at a carefully chosen time. The hardware behavior is a sequence of PPU events observed by a CPU with a related but distinct clock. Correctness depends on the event ordering, not simply on the number of rendered frames.
The technical material below follows NESdev’s community-maintained, experimentally derived documentation. It is not a manufacturer’s programming manual. That distinction matters because several details were reconstructed from hardware tests, test ROMs, and software behavior. When a particular PPU revision or region matters, name it in the test record rather than treating one timing profile as universal.
Separate VBlank from the interrupt line
VBlank is a PPU status condition. NMI is a CPU interrupt signal. The PPU’s VBlank flag contributes to the signal, but PPUCTRL’s NMI-enable bit also gates whether the PPU asserts it. These concepts are often compressed into one phrase, “the VBlank interrupt,” which hides the behavior that breaks emulators.
For the commonly documented NTSC PPU timeline, the VBlank flag is set at scanline 241, dot 1. PPUCTRL register $2000, bit 7, enables VBlank NMI output. PPUSTATUS register $2002, bit 7, reports the flag; reading PPUSTATUS clears it and resets the shared write toggle used by scroll and address writes. At the pre-render line the flag is cleared. The exact interpretation must be tied to the PPU region and timing model being emulated; PAL has a different VBlank interval, and Dendy timing must not be inferred by copying NTSC constants.
The conceptual signal is:
NMI output = VBlank flag AND PPUCTRL.NMI-enable
That expression is useful but not a complete cycle-level implementation. A core must model when the inputs change, when the combined output edge becomes observable by the CPU, and how the CPU/PPU phase relationship affects a register access near the transition. An emulator that checks the expression once per video frame loses exactly the event ordering that software can observe.
PPUSTATUS reads have side effects
Reading $2002 is not a passive query. It clears the VBlank flag. If software reads just before the flag would be set, at the boundary, or shortly after it, the PPU’s response can differ. A read around the set event can suppress the upcoming VBlank/NMI behavior, and software can also deliberately clear the flag before enabling NMI. A PPUCTRL write that changes NMI from disabled to enabled while the VBlank flag is already set can produce an immediate NMI edge. Robust initialization often clears the flag before enabling NMI, but an emulator must reproduce the hardware rather than merely recommend a defensive programming pattern.
There is also a register-latch consequence: PPUSTATUS resets the write toggle used by PPUSCROLL and PPUADDR. Thus an apparently harmless status poll can affect the interpretation of a later write sequence. A CPU implementation must preserve the relative ordering between the memory-mapped read, the PPU event scheduler, the toggle reset, and any NMI request. This is one reason to centralize register access in a bus/PPU interface instead of letting game code or rendering code manipulate a flag directly.
Do not overgeneralize the race as a fixed number of CPU cycles without a specified PPU, region, and phase convention. CPU instructions perform bus cycles, the PPU advances at its own dot cadence, and emulator scheduling choices can place the read on either side of a transition. A useful implementation records the exact time of the PPU event and processes CPU bus accesses against that event sequence. It should not decide “VBlank happened sometime this frame” after the CPU has already executed several instructions.
Frame length is not always a constant integer
An NTSC NES PPU has 341 dots on each scanline. In the usual rendering-enabled odd-frame case, it skips one PPU clock, so the frame is one dot shorter than the preceding frame. The skip is conditional on rendering state; code that disables rendering around the relevant interval can observe different behavior. This is a PPU-dot fact, not permission to subtract one CPU cycle from every frame. The CPU and PPU clocks have a fixed relationship for a given timing profile, but they do not share the same unit.
PAL has a longer VBlank period and different PPU-to-CPU cadence; Dendy has another timing profile. The PPU register events and frame scheduler should therefore be parameterized by hardware region/revision rather than copied from an NTSC-only test. A cartridge or emulator may also select a region model explicitly. If a test passes only after changing a global “FPS” number, investigate the PPU event sequence and clock ratio before altering presentation timing.
Implement state transitions as scheduled events
A maintainable design keeps the VBlank status flag, PPUCTRL NMI enable, the resulting NMI output edge, and the CPU’s pending interrupt state distinct. The PPU scheduler owns dot/scanline progression. PPUSTATUS read logic performs documented register side effects at the timestamp of the CPU access. A transition in the combined NMI output is passed to the CPU through the emulator’s interrupt/event interface.
The following pseudocode shows ownership, not a drop-in dot-accurate core. The edge detector must run at the scheduled PPU/CPU synchronization points for the target system:
static bool nmi_output(const struct ppu *ppu)
{
return (ppu->status & 0x80) != 0 && (ppu->ctrl & 0x80) != 0;
}
static void update_nmi_line(struct machine *m)
{
bool next = nmi_output(&m->ppu);
if (!m->ppu.nmi_line && next)
cpu_request_nmi_edge(&m->cpu);
m->ppu.nmi_line = next;
}
static uint8_t ppu_read_status(struct machine *m)
{
uint8_t value = m->ppu.status;
m->ppu.status &= (uint8_t)~0x80;
m->ppu.write_toggle = 0;
update_nmi_line(m);
return value;
}
The code avoids a common mistake: cpu_request_nmi_edge() is not a level that remains asserted until software clears a CPU flag. The NMI line transition is an event; the CPU then handles it according to its own interrupt timing. The helper names are illustrative and deliberately do not prescribe a particular CPU core’s IRQ/NMI queue.
The PPU event path also needs reset behavior. At startup, status and NMI state have hardware-defined uncertainties and initialization behavior that can be more complicated than “all registers are zero.” If the project targets strict power-on behavior, use a dedicated source/test suite for that claim. Do not silently expand an article about VBlank into a claim that every reset-state edge case is covered.
A failure-oriented debugging matrix
When a game hangs waiting for VBlank, log the PPU scanline/dot, status bit 7, PPUCTRL bit 7, the last $2002 read, and the CPU interrupt state. If the game receives no NMI, check whether software has disabled NMI, whether a status read cleared VBlank, whether the timing profile is wrong, or whether the NMI output edge was lost between CPU and PPU schedulers.
If the game receives an unexpected second NMI, inspect writes to PPUCTRL while the status flag is still set, duplicate edge delivery, and frame-boundary ordering. If scroll or address writes shift after a status poll, check that reading $2002 resets the write toggle. If only one region fails, compare frame/scanline timing, CPU/PPU ratio, VBlank start/end, and odd-frame behavior; do not patch a single game with a hard-coded delay until the machine-wide event ordering is understood.
Acceptance tests and evidence
Use the emulator test ROMs indexed by NESdev, but treat each result as one test of one behavior, not proof of complete PPU accuracy. Record ROM name and checksum, emulator commit, selected region, CPU/PPU phase convention, render-enable state, and test output. Include controlled microtests for: VBlank set and clear; PPUSTATUS read clearing bit 7; PPUSTATUS resetting the write toggle; PPUCTRL NMI-enable changes before, during, and after VBlank; status reads straddling the transition; and odd-frame length with rendering on versus off.
Run the same tests through a software-only PPU trace and on physical hardware when a timing claim matters. A screenshot cannot prove interrupt ordering. Compare trace events or a diagnostic ROM’s visible marker and timing output. If behavior differs, preserve the test artifacts and state which hardware revision or clone was used; a clone may be useful evidence for compatibility, but it is not automatically evidence for the original silicon.
The production rule is to model VBlank as a timestamped PPU state change and NMI as a separate, edge-sensitive CPU event derived from that state and the enable bit. That separation prevents frame-level approximations from erasing the narrow behaviors software can observe, while explicit region and test metadata keep the emulator’s accuracy claim honest.
Related:
- Cycle-Accurate Emulation and Why It’s So Hard to Get Right
- Sprite Limits and Scanlines: Why Accurate Emulators Preserve Flicker and Dropout
Sources: