A cycle-accurate NES emulator in C11. The core has no external dependencies and emits a 256x240 framebuffer plus an audio stream; the frontend presents them through whatever graphics API the platform prefers.
make app # -> dist/PrismNES.app
open dist/PrismNES.appA real macOS bundle with a native menu bar (Cocoa NSMenu, NSOpenPanel,
NSSavePanel). The core stays platform neutral: src/macos_menu.m turns menu
items into SDL user events, so another platform only has to supply the six
functions in src/menu.h.
| Menu | Items |
|---|---|
| File | Open ROM, Import/Export Save RAM, Save/Load State, Save/Load State As |
| Emulation | Pause, Frame Advance, Reset, Power Cycle, Mute |
| Debug | CPU State, Console Help, Game Genie code, Run 6502 code |
| View | 1x / 2x / 3x / 4x |
Drag and drop a .nes file onto the window to load it. Battery saves are
written next to the ROM on exit and reloaded on open.
The terminal that launched the app is a live console; it never blocks the emulator:
r ADDR [LEN] read memory r 0300 16
w ADDR B [B..] write bytes w 0300 AA BB
x HEXBYTES inject and run code x A9 42 8D 00 03 60
gg CODE Game Genie code gg SXIOPO
cpu registers, cycle, PPU position
p / q pause / quit
x is arbitrary code execution inside the emulated machine: the bytes are
written to $0700, a return address is pushed, and the PC is pointed at them,
so an RTS hands control back to the running game. The example above is
LDA #$42 / STA $0300 / RTS and leaves $42 at $0300.
Save states are the whole machine with pointers rewritten as offsets; a
round-trip is bit-identical, verified by tools/statetest.c across NROM, MMC1
and MMC3.
make
./prism roms/AccuracyCoin.nesHeadless, for scripted verification (no graphics dependency at all):
./prism roms/AccuracyCoin.nes --headless 4000 --script "60:start" --text| Flag | Meaning |
|---|---|
--headless N |
run N frames, write a PNG, exit |
--script "60:start,120:right" |
press buttons at given frame numbers |
--hold N |
frames to hold each scripted press (default 6) |
--text |
decode the nametable to stdout (greppable test results) |
--blargg |
run a blargg-protocol test ROM and print its result string |
--ram HEX:LEN |
dump CPU RAM after the run |
--watch |
print every distinct screen the ROM draws |
--bench |
report frames/sec and realtime multiple |
--png PATH, --scale N, --no-vsync, --mute |
Controls: arrows, Z = A, X = B, Enter = Start, RShift = Select,
R = reset, Esc = quit. Battery-backed carts save to <rom>.sav on exit.
| # | Name | Notes |
|---|---|---|
| 0 | NROM | |
| 1 | MMC1 / SxROM | serial shifter, 512K PRG via CHR bit 4, CHR RAM |
| 2 | UxROM | |
| 3 | CNROM | |
| 4 | MMC3 / TxROM | full banking, mirroring, PRG-RAM protect, A12 scanline IRQ |
| 7 | AxROM | single-screen mirroring |
| 11 | Color Dreams | |
| 66 | GxROM | |
| 94, 180 | UxROM variants |
Banking resolves into pointer tables (four 8K PRG windows, eight 1K CHR windows), so a PRG or CHR access stays one indexed load regardless of mapper. iNES 1.0 and 2.0 headers are both parsed; unsupported mappers are rejected with a clear message rather than silently misbehaving.
AccuracyCoin: 126 / 141 (up from 109 when the core first booted it).
blargg's suites, run via --blargg:
| Suite | Result |
|---|---|
instr_test-v5/official_only |
16/16 passed |
instr_test-v5/all_instrs |
16/16 passed (all 256 opcodes incl. unofficial) |
ppu_vbl_nmi |
10/10 passed |
instr_timing |
2/2 passed |
instr_misc |
4/4 passed |
oam_read, oam_stress, cpu_dummy_reads |
passed |
mmc3_test_2 |
4/5 passed (4-scanline_timing #3) |
cpu_interrupts_v2 |
2/5 passed (cli_latency, nmi_and_brk) |
apu_test |
fails 4-jitter #2 |
Every CPU bus access consumes exactly one cycle, and the PPU and APU are advanced from inside that access:
read cycle : tick tick <bus read> tick
write cycle : tick tick tick <bus write>
Placing the read strobe two dots into the cycle and the write strobe at the end
is what makes the $2002 / NMI race windows land correctly.
Notable hardware behaviour that is modelled, each one verified by a test ROM that fails without it:
- Every dummy read and dummy write in every addressing mode.
/NMIas a level that the CPU edge-detects at the end of each cycle, so a$2002read in the same cycle the flag is set suppresses the interrupt.- The IRQ level detector sampling at φ2, before the access lands — a
$4015read that acknowledges an IRQ still leaves that cycle's sample asserted. - Branch interrupt polling: before cycle 2, and again before cycle 4 only when the branch crosses a page. That asymmetry is the "branch interrupt delay".
- Interrupt hijacking: an NMI asserted mid-sequence steals a
BRKorIRQ. - DMC and OAM DMA as real RDY stalls whose halt and alignment cycles re-read
whatever address the CPU left on the bus — which is why DMA is visible to
$2007,$4015and the controller ports. - A
$4015-triggered DMC fetch waiting two APU cycles before pulling RDY low. - The
SH*opcodes losing their& Hterm when a DMA steals the dummy read. $4015answering from inside the 2A03 without driving the external data bus.- The frame IRQ flag clearing on the get cycle after the
$4015read, so a double read can still observe it set. - The controller latch being clocked by the put half of a CPU cycle, so a one-cycle strobe landing on a get cycle never latches the buttons.
- Sprite fetches comparing against
scanline & 255, which is how leftover secondary OAM can put a sprite on scanline 0. - MMC3 A12 edge detection filtered by PPU dots, driven by pattern fetches and
by
$2006/$2007moving the VRAM address.
Fifteen AccuracyCoin tests, in three groups with known root causes:
Sub-dot strobe placement (4 tests). M2 falls 15/24 of the way through a CPU
cycle - 1.875 PPU dots - and this core advances the PPU in whole dots, so a
read strobe can only sit at dot 1 or dot 2. Measured directly: latching vblank
at M2 rise while the sprite flags stay at dot 2 fixes Hybrid Addresses and
advances $2002 Flag Timing and Stale Sprite Shift Regs, but costs the seven
vblank/NMI timing tests, because the true offset is fractional. The fix is a
master-clock core (CPU /12, PPU /4) rather than three ticks per cycle.
PPU external bus (5 tests). The PPU multiplexes A0-A7 with D0-D7 through an
external octal latch strobed by ALE. A $2007 access during rendering collides
with the fetch cadence, and when ALE and /RD assert together the latch feeds
back on itself. Blocks ALE + Read, Internal Data Bus, BG Serial In and
the $2004/$2007 stress tests. Needs PPU memory access restructured into a
per-dot bus state machine.
DMC request stages (3 tests) and APU detail (3 tests). The abort answer key
needs four distinguishable request stages; this core has two. Current output is
04 04 04 04 04 04 02 04 00 00 ... against an expected
04 04 04 04 04 04 03 04 01 01 ....
Older notes: Roughly two thirds need modelling of the PPU's
external pins and internal buses, which this core does not have: ALE + Read,
Hybrid Addresses, Internal Data Bus, BG Serial In, stale BG and sprite
shift registers, the $2004/$2007 stress tests, and OAM corruption from
$2003 writes during rendering.
The rest are narrower: three DMC DMA abort/bus-conflict cases (the abort
duration answer key needs a finer DMC request state machine than the four
stages implemented here), $2002 flag timing (needs vblank latched at M2 rise
and the sprite flags at M2 fall — a sub-cycle split that the current fixed
strobe placement cannot express without retuning every other PPU test), plus
Address $2004 behavior, Frame Counter IRQ check C, DMC check L, and
APU Register Activation check 4.
./check.sh reproduces the full breakdown; ./score.sh just prints the score.
./page.sh N lists menu page N; ./runtest.sh PAGE ROW runs a single test and
prints every screen it draws.
~1140 fps headless, about 19x realtime, single threaded, with the fully cycle-accurate CPU, PPU and APU running.
The GPU backend is irrelevant to that number: presenting a frame is one 256x240 texture upload and one quad. The work is the emulation loop:
- Unity build. The whole core compiles as one translation unit
(
src/prism.cincludes the others), sobus_read,ppu_tickandapu_tickinline into the CPU's instruction loop. This beats cross-file LTO. -O3 -march=native -fomit-frame-pointer, jump-table opcode dispatch, no function pointers in the hot path.- Mapper banking via pointer tables, so mapper support costs nothing per access.
The windowed frontend uses SDL_Renderer, which resolves to the platform's
native API: Metal on macOS, D3D11 on Windows, Vulkan/GL on Linux. The
binary prints which one it selected at startup (renderer: metal). The core
links no graphics library, so a hand-written Metal or Vulkan backend can be
dropped in later purely for presentation features (CRT shaders, lower-latency
present, HDR) without touching the emulator.
src/nes.h public API and all state
src/cpu.c 6502 core, DMA engine
src/ppu.c 2C02, dot accurate
src/apu.c 2A03 audio, DMC DMA requests
src/mapper.c cartridge mappers and bank tables
src/bus.c address decoding, cartridge loading, controllers
src/prism.c unity translation unit + public entry points
src/main.c SDL frontend, headless runner, PNG writer
tools/trace.c instruction tracer and PC histogram
check.sh score.sh page.sh runtest.sh test harnesses