Start, then arrows aim and Z fires
←↑↓→ d-pad Z A X B Enter start Shift select
Games are open-source homebrew (GPL-3.0); authors and source in ROMS.md. Mappers 0, 1, 2, 3, 4 and 7 are supported if you load your own.
Overview
The NES is a good first “real” machine to emulate: small enough to hold in your head, but with three chips (a Ricoh 2A03 CPU, a picture processing unit and an audio processing unit) that have to agree on time down to the cycle, or games break in visible ways.
This emulator implements all 256 opcodes of the 2A03’s 6502 core, official and unofficial, with their hardware side effects. The PPU renders dot by dot into a 256×240 frame using Loopy scroll registers. The APU has both pulse channels, triangle, noise and the DMC sample channel. Six cartridge mappers (NROM, MMC1, UxROM, CNROM, MMC3 and AxROM) cover most of the licensed library, from Super Mario Bros. to Super Mario Bros. 3 and Kirby’s Adventure.
The core has no platform dependencies, so the same crate drives two frontends. One is a desktop app on SDL2. The other is the WebAssembly build above: 73 KB, no wasm-bindgen, with a small JavaScript player that handles frame pacing, audio and input.
Architecture
The big design decision is ownership. A naive Rust port of a C-style emulator ends up wrapping every component in Rc<RefCell<…>> so the CPU can reach the PPU and the PPU can reach the cartridge. That compiles, but it moves the borrow checker’s job to runtime and hides the real data flow. Here the Emulator owns the CPU and a single Bus; the bus owns everything the CPU can address. The CPU takes &mut Bus for the duration of one instruction and gives it back. There are zero RefCells in the codebase, so aliasing mistakes are compile errors rather than runtime panics.
Accuracy
Playing games is a weak test: most bugs only show up in one title, three levels in. So the repository runs the community-standard test ROMs as cargo test. Each one exercises the hardware and reports its own verdict, either in memory at $6000 or, for nestest, as a trace diffed against a golden log.
| Test ROM | What it checks | Result |
|---|---|---|
| nestest | Every opcode’s registers and cycle count, diffed against an 8,991-line reference log | Matches every line |
| blargg instr_test (official + all) | Behaviour of all 256 opcodes | Pass |
| blargg instr_misc | Dummy reads on page crossing | Pass |
| blargg apu_test | Length counters, frame IRQ timing and jitter, DMC (8 subtests) | Pass |
| blargg ppu_open_bus, oam_read | PPU I/O latch and its decay; sprite memory | Pass |
| blargg mmc3_test 1, 2, 3, 5 | MMC3 IRQ counter, A12 clocking | Pass |
| blargg mmc3_test 4 | The exact dot an MMC3 IRQ fires | Partial (8 of 10 checks) |
| blargg ppu_vbl_nmi | VBL flag and NMI timing to the PPU dot | Partial (4 of 10) |
The two partial results have one cause, which I understand but haven’t fixed. The CPU advances the PPU three dots per memory access rather than interleaving at single-dot granularity, so NMI and MMC3 interrupts can land a dot or two early. Games can’t see that. These tests can, so they’re marked as known gaps in the suite rather than hidden.
Getting from the first run (2 of 9 suites) to here found real bugs a player would eventually have hit. Twelve unofficial opcodes fell through to a no-op. DMC sample playback never fetched from memory, so it was silent. Reading $4015 didn’t acknowledge the frame interrupt. And a 120 Hz display ran every game at double speed, because frames were paced by vsync.
Hard problems
Three chips, one clock
On NTSC hardware the PPU runs exactly three dots for every CPU cycle, and the APU ticks once per cycle. The first version made the CPU the master clock at instruction granularity: run a whole instruction, then advance everything else by however many cycles it took. That’s simple, and it’s wrong in a way games notice. An instruction that reads the PPU status register at $2002 sees the PPU as it was before the instruction started, several dots in the past.
Now the clock lives in the bus. Every CPU memory access first advances the rest of the machine by one CPU cycle, and cycles with no bus access are made up after the instruction.
fn tick_components(&mut self) {
self.nmi_polled = self.nmi_pending;
self.irq_polled = self.irq_line();
self.cycles += 1;
self.cartridge.cpu_cycle = self.cycles;
for _ in 0..3 {
if self.ppu.tick(&mut self.cartridge) {
self.frame_complete = true;
}
if self.ppu.nmi_occurred {
self.ppu.nmi_occurred = false;
self.nmi_pending = true;
}
}
self.apu.tick();
}
/// Level of the shared /IRQ line (APU frame counter, DMC, mapper).
pub fn irq_line(&self) -> bool {
self.apu.irq() || self.cartridge.irq_active()
}
/// CPU read: one bus cycle.
pub fn cpu_read(&mut self, addr: u16) -> u8 {
self.tick();
let val = self.read_no_tick(addr);
self.open_bus = val;
val
}That only works if the CPU makes the same memory accesses the real one does, including the useless ones. A 6502 indexing across a page boundary first reads the address with the high byte not yet carried. A read-modify-write instruction like INC writes the original value back before writing the result. Both matter. The dummy read of $2002 clears the vblank flag, and the MMC1 mapper ignores the second of two back-to-back writes, which is how games reset it with a single INC $8000.
fn rmw(&mut self, bus: &mut Bus, addr: u16, f: impl FnOnce(&mut Self, u8) -> u8) -> u8 {
let v = bus.cpu_read(addr);
bus.cpu_write(addr, v);
let r = f(self, v);
bus.cpu_write(addr, r);
r
}Interrupts follow the same idea. They’re sampled at the start of every bus cycle, and the CPU acts on the value from before its last cycle, like the real chip. /IRQ is a level-triggered line shared by the APU and the mapper, so it stays asserted until the game acknowledges it.
MMC3 counts address lines, not scanlines
MMC3 cartridges (Super Mario Bros. 3, Kirby, Mega Man 3–6) can raise an interrupt partway down the screen, which is how status bars stay still while the level scrolls. The cartridge can’t see scanlines, though. It watches the PPU’s address bus and counts rising edges on address line A12, which flips when the PPU moves from fetching background tiles to fetching sprite tiles.
My first attempt called a scanline() hook at a fixed dot, and all five blargg MMC3 tests failed. Games also change A12 by writing addresses through $2006 and $2007, and the real chip filters out edges that come too close together. The fix was to report every PPU bus address to the cartridge and detect edges the way the hardware does. The PPU also had to perform all eight sprite fetches per line, with dummy fetches for empty slots, because those dummy fetches are what make the counter tick.
pub fn ppu_bus_address(&mut self, addr: u16, ppu_dot: u64) {
const MIN_LOW_DOTS: u64 = 10; // ~3 CPU cycles
let high = addr & 0x1000 != 0;
if high && !self.a12_high && ppu_dot.saturating_sub(self.a12_low_since) >= MIN_LOW_DOTS {
self.mapper.scanline();
}
if !high && self.a12_high {
self.a12_low_since = ppu_dot;
}
self.a12_high = high;
}The NMI that only fired once
In the first version, the vertical-blank interrupt fired on the first frame and never again. NMI on the NES is edge-triggered: the CPU only sees it when the line goes from low to high. My edge detector remembered the previous level in nmi_prev and re-evaluated it when vblank was set, or when the game touched $2002 or $2000, but not when the pre-render scanline cleared vblank. A game that didn’t touch those registers left nmi_prev stuck high, so the next frame’s rising edge was never seen.
fn update_nmi(&mut self) {
let nmi = self.nmi_output && (self.status & 0x80 != 0);
if nmi && !self.nmi_prev {
self.nmi_occurred = true;
}
self.nmi_prev = nmi;
}The fix was one line in the right place: re-evaluate the edge when the pre-render scanline clears the flag, so the falling edge is recorded every frame no matter what the game does.
fn tick_prerender(&mut self, cart: &mut Cartridge) {
if self.dot == 1 {
self.status &= !0xE0; // clear vblank, sprite-zero-hit, overflow
self.update_nmi(); // falling edge: reset nmi_prev so next vblank re-triggers NMI
}Results
- Plays NROM, MMC1, UxROM, CNROM, MMC3 and AxROM cartridges. Battery-backed saves persist next to the ROM on desktop.
- 11 of 13 accuracy test ROMs pass in full, and the other two are partial with the cause documented.
- Runs in the browser from a 73 KB WebAssembly module with no wasm-bindgen: plain C-ABI exports, a canvas, an AudioWorklet for sound, and keyboard or touch input.
- Paced to the NES’s own 60.1 Hz against the wall clock in both frontends, so high-refresh displays don’t speed games up.
- A headless snapshot tool renders any frame of any ROM with scripted input, which is how the screenshots here were checked.
What I’d do next
- Single-dot CPU/PPU interleaving. It closes the last two test gaps (NMI and MMC3 IRQ timing). It means splitting each bus access into its two half-cycles.
- Save states. Serialize the whole
Busso the browser build can suspend and resume a game across visits. - More mappers. MMC2 (Punch-Out!!) and MMC5 (Castlevania III) are the next biggest gaps.