README
¶
tanuki
A sub-cycle-accurate NES emulator in pure Go: dot-precise PPU timing, a zero-allocation hot loop, and every cartridge expansion synthesizer rebuilt from scratch.
go install codeberg.org/JohnAnthony/tanuki/cmd/tanuki@latest
tanuki game.nes
tanuki is a Nintendo Entertainment System emulator built for accuracy and engineered to stay out of the garbage collector's way. The CPU, PPU, and APU run on one shared clock, with every CPU bus access placed at its sub-cycle (phi1/phi2) phase and PPU timing correct to the individual dot; the entire per-frame emulation loop allocates zero heap memory (proven both at runtime and by static escape analysis) and runs roughly 10x faster than real hardware on one modern core. It ships as a single self-contained binary, with no runtime dependencies, for Linux, macOS, and Windows.
Features
- Sub-cycle accuracy. The 2A03 CPU, PPU, and APU advance together one clock at
a time, each CPU bus access landing at its correct phi1/phi2 sub-cycle phase and
PPU timing exact to the dot. Verified against the nestest cycle log and blargg's
timing suites:
ppu_vbl_nmi,sprite_hit,sprite_overflow,cpu_interrupts, instruction timing, andapu_test. - A zero-allocation hot loop, proven two ways. The per-frame path makes no
heap allocations: enforced at runtime by an
AllocsPerRunguard and a0 allocs/opbenchmark, and statically by an escape-analysis gate that fails CI on any new hot-path allocation. - Every synthesis sound chip, from scratch. The 2A03 APU plus every cartridge expansion synthesizer (VRC6, Sunsoft 5B, Namco 163 wavetable, and the VRC7's Yamaha OPLL FM) reimplemented in Go and summed through a modeled non-linear DAC, a band-limited (BLEP) resampler, and analog RC output filters. (The lone holdout is the Jaleco uPD7756 ADPCM voice chip, whose samples standard iNES dumps omit.)
- 66 mappers, ~99% of the licensed library. From NROM to MMC5, plus
battery-backed PRG-RAM and serial-EEPROM saves in the standard
.savformats. - NTSC, PAL, and Dendy. Full region timing: the master-clock ratio, frame
geometry, APU rates, and host frame pacing all follow the cartridge's region,
with a
-regionoverride for when the header is wrong. - Deterministic record & replay. Captures and replays input as
FCEUX-compatible
.fm2movies; identical ROM and input always reproduce identical output, which also lets it run the community's tool-assisted speedrun corpus. - Save states. Full machine snapshots with a fixed, zero-allocation codec, the foundation for the libretro core's rewind and netplay.
- Runs as a libretro core. Builds a libretro shared library for RetroArch and other frontends, with rewind, netplay, and save states.
- One static binary. Pure Go, no CGo on Linux or macOS, no runtime assets. Built test-first with 100% statement coverage on the emulation core.
Installation
Prebuilt downloads
Tagged releases carry prebuilt artifacts, attached automatically by CI:
- Emulator binary: Linux (
amd64) and Windows (amd64). - libretro core: Linux
amd64andarm64(.so), with the matchingtanuki_libretro.info.
Grab them from the releases page. macOS builds are not yet published (the GUI links Apple frameworks that need a Mac to build); build from source there for now.
From Go
Install the latest version directly (Go 1.26 or newer required):
go install codeberg.org/JohnAnthony/tanuki/cmd/tanuki@latest
Or build from source:
git clone https://codeberg.org/JohnAnthony/tanuki
cd tanuki
go build -o tanuki ./cmd/tanuki
./tanuki game.nes
Dependencies: Builds on ebiten for display and audio, plus a small set of minimal pure-Go libraries. No C toolchain is required on Linux or macOS.
libretro core
tanuki also builds as a libretro core for RetroArch and other libretro frontends:
task libretro # go build -buildmode=c-shared -o tanuki_libretro.so ./libretro
Load the resulting tanuki_libretro.so in your frontend. The core handles video,
audio, controller input, and region reporting, and serializes full machine state,
so RetroArch rewind, netplay, and save states all work. This build is the one
part of tanuki that uses CGo, so it needs a C toolchain; the standalone binary
above does not.
nesinfo
nesinfo is a small companion tool that prints a one-line, colourised header
summary (mapper, PRG/CHR size, mirroring, battery, region) for each ROM passed to
it:
go install codeberg.org/JohnAnthony/tanuki/tools/nesinfo@latest
nesinfo roms/*.nes
It reads headers through the same parser as the emulator. Colour is auto-disabled
when piped, so it greps cleanly over a whole library, for example
nesinfo roms/*.nes | grep PAL to find PAL carts, or | grep MMC5 for a mapper.
Usage
Pass a ROM path (positionally or with -rom) to start playing.
Controls
USB gamepads work with no per-platform setup: tanuki uses ebiten's standard gamepad layout (backed by the SDL game-controller database), so most pads are recognized automatically. The first connected pad drives player 1 and a second drives player 2.
Fallback keyboard bindings are also provided if one or both controllers are
unavailable. These are the defaults; every button can be rebound in -config
(see Configuration).
| Player 1 | Player 2 | NES button |
|---|---|---|
W A S D |
Arrow keys | D-pad |
J |
Num 1 |
B |
K |
Num 2 |
A |
Enter |
Num 8 |
Start |
Backspace |
Num 7 |
Select |
System keys:
| Key | Action |
|---|---|
Ctrl+R |
reset the console |
Ctrl+S |
take screenshot |
Ctrl+F1..F12 |
save state to slot 1..12 |
Alt+F1..F12 |
load state from slot 1..12 |
Alt+Enter |
toggle fullscreen |
Shift (hold) |
fast-forward at 4x; on a gamepad, hold a top shoulder button (L1/R1) |
Fast-forward runs the emulation at 4x while held. Its audio behaviour is set in
-config (Fast-forward audio): skip (the default) keeps normal pitch but
plays only the last frame of each batch; mute is silent; full resamples the
whole sped-up batch so it plays continuously, pitched up like a fast-forwarding
tape. All three push one frame's worth of samples per displayed frame, so the
audio buffer never overruns.
Ctrl+F1 through Ctrl+F12 save the full machine state to one of twelve slots,
and Alt+F1 through Alt+F12 load it back. States are keyed to the ROM, so each
game has its own twelve slots. By default they live in tanuki's own directory
(<user config dir>/tanuki/states/); see Configuration to keep them next to the
ROM instead.
Command-line options
| Flag | Description |
|---|---|
-rom <file> |
ROM to load (or pass the path positionally) |
-record <file> |
record input to an .fm2 movie, written on exit |
-play <file> |
replay input from an .fm2 movie |
-config |
open the configuration screen instead of running a ROM |
-region <r> |
force the video region: auto (from the header), ntsc, pal, dendy |
-headless |
run without a window (requires -play); the fast path for scripts and CI |
Debug options
| Flag | Description |
|---|---|
-screenshot <file> |
with -headless, write the final frame as a PNG |
-trace <mode> |
developer tracing (e.g. mmc3 logs scanline-IRQ banking) |
Configuration
Run tanuki -config to open the configuration screen. Settings are stored as a
TOML file at <user config dir>/tanuki/config.toml (for example
~/.config/tanuki/config.toml on Linux).
tanuki starts fullscreen by default; turn off Start fullscreen in -config
(or set fullscreen = false in the file) to launch in a window. Alt+Enter
toggles fullscreen at runtime either way.
Overscan controls how much of the picture edge is cropped, matching what a CRT TV hid behind the bezel: top & bottom (the default, 256x224) hides the vertical garbage many games leave there while keeping the full width, full (240x224) also crops the sides to remove the left-column scroll seam some games expose, and none (256x240) shows the whole frame.
By default, battery saves and save states live in tanuki's own directory
(<user config dir>/tanuki/saves/ and .../states/), named by ROM checksum so
they never collide. Enable Save files in ROM dir or Save states in ROM
dir in -config to write them next to the ROM instead (as <rom>.sav and
<rom>.state1...state12), the layout other emulators use.
The Controller Selection section assigns each NES port to Auto, a connected
gamepad, or a keyboard layout. Below it, the Keyboards and Controllers
lists let you rebind inputs: select a keyboard set (or a gamepad) and press each
key (or button) as prompted to rebind it, Esc to skip one. Keyboard sets rebind
the eight NES buttons; fast-forward stays on Shift and is not rebindable. Every
change is saved immediately.
Recording and replaying input
tanuki records and replays controller input as FCEUX-compatible .fm2 movies,
so a session can be captured once and replayed deterministically:
tanuki -record session.fm2 game.nes # play; input is saved on exit
tanuki -play session.fm2 game.nes # replay the recorded input
tanuki -play session.fm2 -headless game.nes # replay with no window, fast
A movie stores a checksum of the ROM; replaying against a different ROM prints a
warning, since the run is likely to desync. The files are plain text and
interoperate with other tools that read .fm2, including the existing library of
community tool-assisted speedruns.
A movie can also begin from a save state rather than power-on: an optional
savestate header carries a full machine snapshot, and replay loads it before the
first recorded frame. Power-on movies stay fully FCEUX-compatible; a
save-state-anchored movie uses tanuki's own snapshot format, so it replays in
tanuki but not in FCEUX.
Compatibility
Mappers
tanuki supports 66 mappers, covering approximately 99% of the licensed NES library and a significant share of unlicensed and homebrew titles.
Full mapper list
| # | Name | Representative games |
|---|---|---|
| 0 | NROM | Donkey Kong, Super Mario Bros., Pac-Man |
| 1 | MMC1 / SxROM | The Legend of Zelda, Metroid, Mega Man 2 |
| 2 | UxROM | Mega Man, Contra, Castlevania |
| 3 | CNROM | Arkanoid, Gradius, Paperboy |
| 4 | MMC3 / TxROM | Super Mario Bros. 3, Mega Man 3-6, Kirby's Adventure |
| 5 | MMC5 / ExROM | Castlevania III (US) |
| 7 | AxROM | Battletoads, Wizards and Warriors |
| 9 | MMC2 / PxROM | Punch-Out!! |
| 10 | MMC4 / FxROM | Fire Emblem (Famicom) |
| 11 | Color Dreams | Crystal Mines, Metal Fighter (unlicensed) |
| 13 | CPROM | Videomation |
| 16 | Bandai FCG / LZ93D50 | Dragon Ball, Dragon Ball Z, SD Gundam (Famicom) |
| 18 | Jaleco SS88006 | Pizza Pop, The Lord of King, Magic John (Famicom) |
| 19 | Namco 163 | Rolling Thunder, Pac-Land, Mappy-Land (Famicom) |
| 21, 22, 23, 25 | Konami VRC2 / VRC4 | Gradius II, TMNT, Crisis Force, Ganbare Goemon 2 (Famicom) |
| 24 | VRC6a | Akumajou Densetsu / Castlevania III (Famicom) |
| 26 | VRC6b | Esper Dream 2, Mouryou Senki Madara (Famicom) |
| 28 | Action 53 | Homebrew multicarts and single-game releases |
| 32 | Irem G-101 | Image Fight, Major League, Kaiketsu Yanchamaru 2 |
| 33 | Taito TC0190 | Don Doko Don, Insector X, Power Blazer |
| 34 | BxROM | Deadly Towers, Impossible Mission II |
| 46 | Rumblestation | Rumblestation 15-in-1, Nintendo Test Cart |
| 37, 47 | MMC3 multicart | Super Spike V'Ball + NWC, SMB + Tetris + NWC |
| 48 | Taito TC0690 | The Flintstones, Bubble Bobble Part 2, Don Doko Don 2 |
| 64 | Tengen RAMBO-1 | Klax, Skull & Crossbones, Shinobi (Tengen) |
| 65 | Irem H3001 | Daiku no Gen-san, Spartan X 2 (Famicom) |
| 66 | GxROM | Super Mario Bros. + Duck Hunt multicart |
| 67 | Sunsoft-3 | Fantasy Zone II, Mito Koumon 2 (Famicom) |
| 68 | Sunsoft-4 | After Burner II, Maharaja, Nantettatte!! Baseball |
| 69 | FME-7 / Sunsoft 5B | Gimmick!, Batman: The Video Game, Gremlins 2 |
| 70 | Bandai | Family Trainer, Kamen Rider Club, Space Shadow |
| 71 | Camerica / Codemasters | Dizzy series, Fire Hawk |
| 72 | Jaleco JF-17 | Moero!! Pro Tennis, Pinball Quest (Famicom) |
| 73 | Konami VRC3 | Salamander (Famicom) |
| 75 | Konami VRC1 | Exciting Boxing, Ganbare Goemon, King Kong 2 |
| 76 | Namco 3446 | Digital Devil Story: Megami Tensei (Famicom) |
| 77 | Irem (four-screen) | Napoleon Senki (Famicom) |
| 78 | Irem / Jaleco | Holy Diver, Cosmo Carrier, Mission: Impossible |
| 80 | Taito X1-005 | Kyuukyoku Harikiri Stadium, Minelvaton Saga, Jarvas |
| 82 | Taito X1-017 | Kyuukyoku Harikiri Stadium 3, SD Keiji Blader |
| 86 | Jaleco JF-13 | Moero!! Pro Yakyuu (Famicom) |
| 85 | VRC7 | Lagrange Point (Famicom) |
| 87 | Jaleco/Konami | Argus, City Connection, TwinBee (Famicom) |
| 88 | Namco 118 | Dig Dug II, Track & Field (Famicom) |
| 89 | Sunsoft-2 | Tengu no Boken, Mito Koumon (Famicom) |
| 92 | Jaleco JF-19 | Moero!! Pro Soccer, Moero!! Pro Yakyuu (Famicom) |
| 93 | Sunsoft-3 | Fantasy Zone (Japan) |
| 94 | UN1ROM | Housekeeper |
| 95 | Namco 3425 | Dragon Buster (Famicom) |
| 97 | Irem TAM-S1 | Kaiketsu Yanchamaru (Kid Niki) |
| 118 | MMC3 TxSROM | Armadillo, Ys 3, Goal! (Famicom) |
| 119 | MMC3 TQROM | Pin*Bot, High Speed |
| 140 | Jaleco JF-11/14 | Bio Senshi Dan, Mississippi Satsujin Jiken (Famicom) |
| 152 | Bandai | Arkanoid II, Saint Seiya, Pocket Zaurus |
| 154 | Namco 3453 | Devil Man (Famicom) |
| 158 | Tengen RAMBO-1 | Alien Syndrome (Tengen) |
| 159 | Bandai LZ93D50 + 24C01 | Magical Taruruuto-kun, SD Gundam Gaiden (Famicom) |
| 180 | UxROM (inverted) | Crazy Climber |
| 181, 185 | CNROM copy protection | Mighty Bomb Jack, Spy vs Spy, Seicross |
| 184 | Sunsoft-1 | Atlantis no Nazo (Famicom) |
| 206 | DxROM / Namco 108 | Dig Dug II, Gauntlet, Rolling Thunder, RBI Baseball |
Six carry on-cartridge sound chips, all emulated alongside their banking and IRQs: VRC6 (24, 26), Sunsoft 5B (69), Namco 163 (19), VRC7 (85), and MMC5 (5). The MMC5 is effectively complete - banking (including 8x16 split-CHR), the multiplier, scanline IRQ, ExRAM extended attributes, per-quadrant nametables, expansion audio, and vertical split-screen.
Every mapper with save memory persists it automatically: battery-backed PRG-RAM
as a raw 8 KB .sav, and the Bandai FCG's serial 24C02 EEPROM as a 256-byte
.sav, all compatible with FCEUX, Nestopia, and Mesen.
Found a game that misbehaves? The CPU, PPU, and APU core is held to the timing suites above, but with 66 mappers spanning a wide range of cartridge hardware, any remaining inaccuracy is most likely mapper-specific. Per-game bug reports are very welcome - they are the best way to surface the edge cases that test ROMs miss.
Region
tanuki emulates NTSC, PAL, and Dendy consoles. It reads the cartridge's intended video standard from the iNES / NES 2.0 header, shows it in the launch banner, and runs the matching timing: the master-clock ratio (3 PPU dots per CPU cycle on NTSC, a fractional 3.2 on PAL), the frame geometry (262 vs 312 scanlines), the APU frame sequencer and period tables, and the host frame rate (60 vs 50 Hz). Dendy runs PAL's 312-line frame with an NTSC-style integer clock ratio.
Many dumps leave the region flag unset, so the mapper number is often the better
hint: some titles ship as different mappers per region (Batman, for example, is
mapper 69 on NTSC and mapper 4 on PAL). When the header is wrong, force the region
with -region ntsc|pal|dendy. (PAL coverage has far fewer test ROMs than NTSC, so
it is less exhaustively verified; the Dendy APU uses the NTSC tables, the standard
approximation.)
Timing accuracy
Because every CPU bus access lands at its exact PPU dot and phi1/phi2 phase, the notoriously dot-precise NTSC timing races become expressible, and tanuki passes blargg's exacting suites in full:
ppu_vbl_nmi(10/10) - VBlank flag set/clear timing, the$2002read-suppression race, one-instruction NMI latency, NMI enable/disable across the VBlank edges, and the odd-frame dot skip (includingeven_odd_timing, where the skip decision models the PPUMASK rendering-enable delay).sprite_hit(11/11) - sprite 0 hit timing down to the dot. This needs accurate OAM DMA: a$4014write halts the CPU for 513 cycles (514 on odd alignment) while the PPU keeps running, as the hardware RDY suspend does.sprite_overflow(5/5) - sprite evaluation runs progressively across dots 65-256, raising the overflow flag at the exact dot the ninth sprite is found, including the hardware diagonal over-read bug.cpu_interrupts(5/5) - the two-stage IRQ recognition gate (a level IRQ sampled on one cycle is actionable only on the next) and the taken-branch quirk (a non-page-crossing branch ignores an IRQ on its final cycle).oam_read+oam_stress-$2003/$2004OAM addressing and read/write, including the hardware quirk that bits 2-4 of every sprite's attribute byte are unimplemented in OAM and always read back as 0.
Plus the nestest cycle-exact CPU log and blargg's instruction-timing suite.
Audio accuracy
tanuki models the full 2A03 output stage, not just the channel generators:
- Non-linear DAC mixing via the canonical NESdev formulas, so channels
interact as on hardware: the combined level compresses as it rises, and writing
$4011ducks the whole triangle/noise/DMC group (the crude master-volume trick some games use). The mix is mathematically bounded, so it never clips on its own. - Band-limited (BLEP) resampling to the host rate, removing the aliasing naive sampling produces on the NES's sharp square-wave edges.
- Analog RC filters - a 90 Hz high-pass (DC blocker) and a 14 kHz low-pass. The front-loader's 440 Hz high-pass is deliberately omitted for the fuller low end of a Famicom / AV Famicom.
- Headroom, not clamping - loud noise and DMC transients ride the mixer's own DAC saturation rather than flat-topping the 16-bit ceiling; no artificial soft-clipper, matching Mesen and NSFPlay.
Every cartridge expansion synthesizer is rebuilt from scratch too, including the VRC7's Yamaha OPLL (YM2413): six channels of two-operator FM with per-operator ADSR, feedback, and LFOs, plus the 15 fixed patches - the most complex audio in the library, reimplemented in Go for a single game (Lagrange Point). The only cartridge sound not emulated is the Jaleco uPD7756 ADPCM voice chip (mappers 72/86/92), a sample-playback part whose voice data standard iNES dumps omit.
Architecture
tanuki/
main.go Entry point, flag parsing, launch banner
display_ebiten.go Ebiten window, audio player, input polling
nes/ The emulation library (pure, no display dependency)
apu.go APU: pulse x2, triangle, noise, DMC, frame counter
bus.go CPU memory map, OAM DMA, controller I/O, open-bus MDR
cart.go iNES / NES 2.0 ROM loader and mapper selection
controller.go Standard NES controller (strobe + shift register)
cpu.go Ricoh 2A03: per-cycle dispatch table, interrupts
mapper_*.go Mapper implementations (one file per mapper)
ppu.go PPU: scanline renderer, OAM, palette, NMI, A12 IRQ clock
system.go Ties CPU + PPU + APU together; the tick ordering
fm2/ Standalone FCEUX .fm2 movie codec (no dependency on nes)
test/ Integration tests that run nestest and blargg ROMs
The nes package is a pure library with no display dependency: it exposes a
deterministic System.StepFrame(buttons) primitive, and the binary and tests
drive it. The hot path, System.Tick(), advances the PPU by two dots, ticks the
CPU by one cycle, then advances the PPU by a third dot (and ticks the APU).
Placing the CPU between the PPU dots, rather than after all three, puts its bus
cycle at the NTSC clock-divider phase the VBlank/NMI timing tests require: a
$2002 read sees the PPU state current as of its bus cycle, and a VBlank-onset
NMI is recognized at the correct instant.
Development
The project is built test-first (TDD) and holds 100% statement coverage on the
nes, fm2, and config packages. Common tasks (via
Task, or run the underlying go commands directly):
task test # run the whole suite with coverage
task check # local pre-push gate: mirrors the checks CI runs
task cover-check # fail unless nes/fm2/config hold 100% statement coverage
task bench # hot-loop speed (frames/s, x-realtime) and the 0 allocs/op proof
task escape # static escape-analysis gate: fail on any new hot-path allocation
task dist # build the cross-platform release artifacts into dist/
Continuous integration runs on Codeberg via Forgejo Actions
(.forgejo/workflows/). On every push and pull request, ci.yml runs gofmt,
vet, lint, the 100% coverage gate, the race-detector test suite, and the
zero-alloc gate. When a vX.Y.Z tag is pushed, release.yml builds the
artifacts with tools/dist.sh and attaches them to a Forgejo release.
Accuracy rests on two layers of tests. Unit tests in nes cover every component
against the hardware documentation; integration tests in test/ run real
validation ROMs end to end: the nestest CPU log, blargg's instruction-timing,
ppu_vbl_nmi, and apu_test suites, and movie-driven controller tests. They run
in parallel, so the full suite finishes in seconds despite executing millions of
emulated cycles.
The zero-allocation invariant is enforced, not assumed:
TestSystem_StepFrame_ZeroAlloc fails on any per-frame heap allocation at
runtime, and task escape proves the same thing statically by checking that
every heap escape reported by the Go compiler is an init-only allocation.
Contributing
Bug reports, mapper implementations, and accuracy improvements are welcome. Before opening a pull request:
- Run
task checkand ensure it passes cleanly. - Add a test for new emulation behaviour. The project is TDD: write the test first, then the implementation.
- Keep the zero-allocation invariant; the runtime guard and
task escapewill catch regressions.
License
MIT. See LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
tanuki
command
|
|
|
Package config holds tanuki's user configuration: the small set of settings exposed by the -config screen, persisted as a TOML file.
|
Package config holds tanuki's user configuration: the small set of settings exposed by the -config screen, persisted as a TOML file. |
|
Package fm2 reads and writes FCEUX .fm2 movie files: a small text header identifying the ROM, then one input line per frame.
|
Package fm2 reads and writes FCEUX .fm2 movie files: a small text header identifying the ROM, then one input line per frame. |
|
Command tanuki-libretro builds tanuki as a libretro core: a C-ABI shared library (-buildmode=c-shared) that RetroArch and other libretro frontends load and drive.
|
Command tanuki-libretro builds tanuki as a libretro core: a C-ABI shared library (-buildmode=c-shared) that RetroArch and other libretro frontends load and drive. |
|
tools
|
|
|
moviemin
command
Command moviemin shrinks an .fm2 movie to the shortest version that still passes a given Go test, using that test as the pass/fail oracle.
|
Command moviemin shrinks an .fm2 movie to the shortest version that still passes a given Go test, using that test as the pass/fail oracle. |
|
nesinfo
command
Command nesinfo prints a one-line, colourised summary of each NES ROM's header: mapper, PRG/CHR size, mirroring, battery, and intended video region.
|
Command nesinfo prints a one-line, colourised summary of each NES ROM's header: mapper, PRG/CHR size, mirroring, battery, and intended video region. |
|
shot
command
Command shot renders a headless screenshot of an NES ROM: it runs the emulator for a number of frames, then writes the final framebuffer to a PNG.
|
Command shot renders a headless screenshot of an NES ROM: it runs the emulator for a number of frames, then writes the final framebuffer to a PNG. |