A Nintendo Entertainment System emulator written in C++20, with an SDL2 + Dear ImGui frontend that shows the picture and the machine drawing it side by side.
The CPU and the PPU render backgrounds and sprites, the frontend puts the picture on screen, and the controllers work. A mapper 0, 2, 3 or 4 game should draw and respond to input - Super Mario Bros plays, and the 240p Test Suite runs. MMC3 brings the scanline IRQ counter that raster splits need. There is still no audio.
| Area | State |
|---|---|
| 6502 CPU | Cycle-accurate, all 256 opcodes, verified per-cycle |
| PPU frame timing | Dot-accurate: vblank, NMI, suppression, odd-frame skip |
| PPU address space | Pattern tables, nametable and palette mirroring, $2007 buffer, OAM, open-bus decay |
| PPU background | Loopy v/t/x/w, dot-exact tile pipeline, framebuffer of palette indices |
| Sprites | Secondary OAM, per-dot evaluation, 8-per-line, the overflow search bug, priority, 8x16, flip. Passes blargg's 5 sprite_overflow and 11 sprite_hit ROMs |
| Cartridge | iNES, NROM (0), UNROM (2), CNROM (3) and MMC3 (4), CHR-ROM and CHR-RAM |
| MMC3 IRQ | A12-filtered scanline counter driving /IRQ. Passes 4 of blargg's 6 mmc3_test_2 ROMs; see below |
| APU | Frame counter and /IRQ. No audio. |
| Display | SDL2 + Dear ImGui: the screen and the debugger in one window |
| Controllers | Both ports at $4016/$4017, keyboard-driven. Passes blargg's read_joy3 test_buttons |
Next: APU audio, the largest subsystem still entirely absent. SDL is already a dependency and its audio callback is the natural clock for it.
Most oracles here are test ROMs - written to isolate one behaviour and report
a verdict - plus two freely-licensed homebrew programs. A retail game is a
different kind of load, so tests/local_rom_tests.cpp runs one end to end when
it is available: it boots to the title screen, presses Start, holds Right, and
checks that the playfield scrolls and the sprite-0 status bar split fires.
Measured on Super Mario Bros: coarse X takes 26 of a possible 32 values over 300
frames, and the split fires on 249 of them.
Those tests skip unless you supply the ROM yourself. Nothing here fetches a
commercial game - they are copyrighted, unlike the redistributable test dumps
the fetch scripts pull. Drop a dump of a cartridge you own at
tests/test_files/local/smb.nes and they switch from SKIPPED to executed; see
that directory's README.
Two things about test counts that are easy to misread:
ctestcounts a skipped test as a pass.GTEST_SKIPexits 0. The honest figure for any run is executed, not passed.- CI executes far fewer tests than it reports. The 512 per-opcode
SingleStepTests need 1.1 GB of vectors that CI does not fetch, so they skip
there and run only locally. The workflow prints the breakdown on every run.
blargg's
instr_test-v5singles cover all 256 opcodes in a few hundred KB and DO run in CI, so instruction coverage there is no longer just nestest and Klaus.cpu_exec_spaceruns the CPU through I/O space, which is what pins CPU open bus.
Two deliberate divergences, both asserted rather than hidden.
03-immediate reports
AB ATX #n. Opcode $AB computes A = X = (A | magic) & immediate, where
magic is an analogue property of the physical chip. Measured, no value
satisfies both oracles - $FF passes blargg and fails 3 SingleStepTests cases,
$EE does the reverse. $EE is kept, and tests/instr_test_roms.cpp asserts
that ROM fails on exactly ATX, so any other regression in it still shows up.
6-MMC3_alt fails because it tests a different chip. Sharp and NEC MMC3 parts
disagree about what happens when the IRQ latch is 0 - one reloads and fires on
every clock, the other stops - and the two ROMs assert opposite things, so they
cannot both pass. This implements Sharp, because SMB3 and Mega Man 3 are
Sharp boards and that is what real games depend on.
tests/mmc3_rom_tests.cpp pins 6-MMC3_alt's exact status and message, so a
change in how it fails still surfaces.
4-scanline_timing is a real gap rather than a divergence: it clears its first
two subtests and fails the third on fine A12 timing. The suspected cause is
specific - the two garbage nametable reads in each sprite pattern fetch are not
driven onto the PPU address bus, and the filter measures its low period from
exactly that point. It is pinned the same way as the divergence above, so the
day it starts passing, the test says so.
Still unexercised by anything here: sustained play over minutes rather than a few thousand frames, and every mapper beyond 0, 2, 3 and 4.
- CPU (Ricoh 2A03 / 6502):
- All 256 opcodes, official and undocumented, including the unstable
SHA/SHX/SHY/TAS/LXA/XAAfamily. - Cycle-stepped: exactly one bus access per cycle, at the cycle hardware performs it. That includes the accesses that are easy to miss because they change no register - the dummy read at the un-carried address when an indexed access crosses a page, and the dummy write of the old value that every read-modify-write performs. Those are observable on the bus, and getting them wrong breaks PPU timing.
- Verified against the SingleStepTests 65x02 vectors: 256/256 opcodes on the per-cycle bus trace and 256/256 on final CPU/memory state, 10,000 randomized cases each.
- Matches the canonical
nestestlog for all 8991 instructions, both on flat memory and through the real bus. - Passes the Klaus2m5 functional and interrupt test suites.
- All 256 opcodes, official and undocumented, including the unstable
- PPU (Picture Processing Unit):
- Frame timing is dot-accurate: vblank set/clear, NMI enable and
suppression, and the odd-frame clock skip. Passes all ten of Blargg's
ppu_vbl_nmiROMs. /NMIis modelled as a level the CPU samples, not an edge it is handed, which is what makes NMI suppression expressible.- OAM DMA takes the 513 or 514 CPU cycles it should, stealing them from a halted CPU.
- The address space is real: CHR-ROM or CHR-RAM behind the pattern
tables, nametable mirroring driven by the iNES flag, palette mirroring
with the
$3F10/$14/$18/$1Caliases, the$2007read buffer, and an open bus whose bits decay independently. Passesoam_read,oam_stressandppu_open_bus. - The background renders: the loopy
v/t/x/wscroll registers, the 8-cycle nametable/attribute/pattern fetch, shift registers with fine-X selection, and a 256x240 framebuffer of 6-bit palette indices. The dot each increment andt->vcopy happens on is pinned by tests, not just the bits they move. - Sprite 0 hit is evaluated in its real window, starting from
OAMADDR, withOAMADDRheld at 0 across the sprite-fetch dots as hardware does. Passes all eleven of blargg'ssprite_hitROMs, which measure the BACKGROUND pipeline as much as the sprite: pixel-exact alignment, the left-8 clip, and dot-exact flag timing. - Passes blargg's
ppu_read_bufferin full - the broadest single check here, covering CIRAM through$2007with both increment modes, PPU I/O mirroring, CHR-ROM reads, CNROM banking, sprite 0 hit, and OAM loaded from RAM, from ROM and from the PPU register file. - Sprites are complete: secondary OAM, per-dot evaluation, the
eight-per-line limit, the hardware overflow search bug, priority, 8x16
and both flips. Passes blargg's 11
sprite_hitand 5sprite_overflowROMs. - Eight sprite pattern fetches happen on every rendering line, however
few sprites it has, because the empty ones still drive A12 and that is
what clocks an MMC3 counter. Proven by mutation: removing them fails
2-details. - Still absent: PPUMASK colour emphasis, and the "forced backdrop"
case where rendering is disabled with
vpointing into palette space. Both are documented ininclude/ppu.h.
- Frame timing is dot-accurate: vblank set/clear, NMI enable and
suppression, and the odd-frame clock skip. Passes all ten of Blargg's
- Cartridge:
- iNES parsing with NROM (0), UNROM (2), CNROM (3) and MMC3 (4), trainer
support, and
$6000-$7FFFPRG-RAM. CNROM's switchable CHR window is what makesppu_read_bufferreachable; UNROM's PRG window is what runs the 240p Test Suite. - MMC3 adds the register file, both PRG modes, CHR A12 inversion, runtime mirroring and the scanline IRQ counter. Bus conflicts are deliberately not modelled on any of these boards - cartridges store the bank number at the address they write to, so the AND real hardware performs is a no-op for correct software.
$A001is decoded but not obeyed. The PRG-RAM enable and write-protect bits are stored, and nothing consults them:Bus::decoderoutes$6000-$7FFFstraight to the RAM, so it is always readable and writable. The mapper owns the bits butPrgRAMis a separate Bus device, and connecting the two is the piece that was left undone. No ROM here covers it and no game is known to depend on it, which is why it survived unnoticed - it is recorded here rather than in nobody's head.
- iNES parsing with NROM (0), UNROM (2), CNROM (3) and MMC3 (4), trainer
support, and
- Bus:
- A single address decode shared by reads and writes, so the two cannot
drift apart. It takes the direction as a parameter for exactly one
address:
$4017is the APU frame counter on a write and controller port 2 on a read, which on hardware really are two different devices.$4016is one device in both directions, because the write is the strobe for both ports and one strobe line owns them.
- A single address decode shared by reads and writes, so the two cannot
drift apart. It takes the direction as a parameter for exactly one
address:
- APU (Audio Processing Unit):
- The frame counter is implemented, including the 4- and 5-step
sequences and the frame interrupt - it is the machine's only maskable
interrupt source, so the CPU's
/IRQpath is untestable without it. Passes all fivecpu_interrupts_v2ROMs. - No audio. No channels, no mixer, no output.
- The frame counter is implemented, including the 4- and 5-step
sequences and the frame interrupt - it is the machine's only maskable
interrupt source, so the CPU's
An SDL2 window hosting Dear ImGui panels:
- The 256x240 screen, scaled by an integer factor with nearest-neighbour filtering, updated every frame.
- CPU registers, the decoded opcode at
PC, and writable status flags. - PPU state: scanline and dot, loopy
v/t/x/wraw and decoded, and the three status flags. - A clipped hex view of the 2KB internal RAM, with
PC, the stack pointer and the stack base highlighted. - Palette RAM as colour swatches, with the
$3F10/$3F14/$3F18/$3F1Caliases resolved so what is shown is what the PPU renders. - Run, pause, single-step, step-one-frame, reset, and a CPU trace to stdout.
No panel reads emulator state through Bus::read or PPU::read. Those are
hardware ports where a read clears the vblank flag, advances the VRAM address or
acknowledges an IRQ - a debugger that displayed them by reading them would
change the run it is supposed to be observing.
It replaced a Qt/QHexView debugger. Immediate mode suits a machine being single-stepped far better than a widget tree does, ImGui is vendored source rather than a system package, and SDL is what the APU's audio callback will need when there is audio to clock.
- A C++20 compatible compiler (e.g., GCC, Clang)
- CMake (version 3.15 or later)
- Ninja (optional, for faster builds)
- SDL2 - optional, only for the
nes_frontendtarget. Configure with-DNES_BUILD_FRONTEND=OFFto skip it; CMake also skips it automatically if SDL2 is not installed. The emulator libraries and the test suite build without SDL, and nothing undertests/links the frontend.
Google Test and Dear ImGui are fetched automatically by CMake, each pinned to a release tag.
# Clone the repository
git clone https://github.com/JuantAldea/NES.git
cd NES
# Configure the build using CMake
mkdir build && cd build
cmake ..
# Or, if you want to use Ninja
cmake .. -G Ninja
# Build the project
cmake --build .The build process generates two executables in the build/ directory.
Not production ready.
./build/nes_frontend path/to/rom.nes # loads and starts running
./build/nes_frontend # then type a path, or drag a .nes inPanel positions are saved to imgui.ini in the working directory.
The project uses Google Test. Tests build automatically with the project.
Most of the suite runs against external test ROMs and hardware-derived vectors. These are not committed - they are unlicensed ROM dumps and, in one case, 1.1 GB of generated JSON. Fetch them with:
tests/test_files/fetch_nestest.sh # ~900 KB nestest ROM + log
tests/test_files/fetch_instr_test.sh # ~708 KB instr_test-v5, all 256 opcodes
tests/test_files/fetch_cpu_interrupts.sh # ~200 KB cpu_interrupts_v2 ROMs
tests/test_files/fetch_cpu_behaviour.sh # ~224 KB reset and dummy-access ROMs
tests/test_files/fetch_cpu_exec_space.sh # ~92 KB CPU executing through I/O space
tests/test_files/fetch_blargg_ppu.sh # ~400 KB ppu_vbl_nmi ROMs
tests/test_files/fetch_blargg_ppu_2005.sh # ~64 KB palette/VRAM/OAM ROMs
tests/test_files/fetch_ppu_address_space.sh # ~100 KB OAM and open-bus ROMs
tests/test_files/fetch_ppu_read_buffer.sh # ~40 KB $2007 read buffer pack
tests/test_files/fetch_sprite_hit.sh # ~224 KB sprite 0 hit ROMs
tests/test_files/fetch_sprite_overflow.sh # ~104 KB sprite overflow ROMs
tests/test_files/fetch_mmc3.sh # ~268 KB MMC3 scanline IRQ ROMs
tests/test_files/fetch_read_joy3.sh # ~48 KB controller ROM
tests/test_files/fetch_visual_roms.sh # ~100 KB homebrew visual checks
tests/test_files/fetch_single_step_tests.sh # 1.1 GB SingleStepTests vectorsEach verifies a pinned SHA256 (the vectors are validated structurally instead, since upstream regenerates them wholesale) and skips anything already present, so re-running is cheap.
That is all fifteen, and the list has to stay complete to be useful: everything except the last one hard-fails when its ROMs are absent, so a partial list reads like a working setup and then fails in several places at once.
A green run does not mean the suite verified everything, and the headline
count actively hides this. The two per-opcode suites call GTEST_SKIP when the
1.1 GB of vectors is absent, and a skipped test exits 0, so ctest counts it as
a pass. With no vectors fetched the suite still reports "100% tests passed out
of 872" while having executed 356 of them.
The ROM suites behave the other way round: a missing ROM is a loud failure naming the fetch script to run, not a skip. So the failure modes are:
| Fixture | Missing behaviour |
|---|---|
| SingleStepTests vectors | 512 tests skip, counted as passing |
| Everything else | hard failure naming the fetch script |
CI deliberately does not fetch the vectors and prints the executed count on every run for this reason.
ninja -C build check # or: make -C build checkcheck runs the suite across every core. The tests are independent - each one
that writes a fixture writes a uniquely named one - and the suite is dominated
by 512 per-opcode cases that parallelise perfectly.
On 32 cores the full 872-test suite takes about 4 seconds. Two things got it there, and the second mattered more than the first:
- Parallelism took it from 129s to 16s. Past that point the total was simply the length of the single slowest test, so more cores stopped helping.
- The default build type is
Checked(-O3 -g, asserts left on), which took 16s to 3s. The suite spends nearly all its time emulating - the test ROMs run hundreds of millions of bus cycles each - so an unoptimised build costs about 5x.-O3measured ~10% faster than-O2;-march=nativewas slower than plain-O3and not portable.
Debug, Release and the other stock types are untouched and still available
via -DCMAKE_BUILD_TYPE=.
Plain ctest still works and is still serial:
ctest --test-dir build --output-on-failure
ctest --test-dir build -j8 --output-on-failure # or pick your own levelThe 872 tests are dominated by the two per-opcode suites - 256 opcodes checked for their bus trace and 256 for their final state, 10,000 cases apiece.
NES_SANITIZE builds everything with the given -fsanitize= list. It is
off by default, and orthogonal to the build type - the sanitizer build is the
normal Checked build (-O3 -g, asserts live) plus instrumentation:
cmake -S . -B build-asan -G Ninja -DNES_SANITIZE=address,undefined
cmake --build build-asan
ASAN_OPTIONS=detect_leaks=1:detect_stack_use_after_return=1 \
UBSAN_OPTIONS=print_stacktrace=1 ctest --test-dir build-asan -j8CI runs exactly this on every push, as the Test under ASan + UBSan job. The suite currently reports no ASan errors, no UBSan diagnostics and no leaks.
Two details worth knowing if you change this:
- The build adds
-fno-sanitize-recover=all. UBSan's default is to print a diagnostic and continue, which exits 0 - so without this the job would go green while stepping on undefined behaviour on every run. - Leak detection is deliberately left on. The argument for disabling it is that a short-lived test binary leaking at exit is harmless noise, but that argument assumes a dirty baseline; this suite has none, so detection is free and the next leak becomes a build failure rather than something nobody sees.
Instrumentation costs about 5x: the 356 tests CI executes take 3.7s normally and 19.6s under ASan+UBSan on 32 cores. That is why it is a separate CI job - the fast suite keeps reporting in seconds.
This project is licensed under the GNU General Public License v2.0. See the LICENSE file for details.