tanuki

module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: MIT

README

tanuki

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.

CI Platforms 0 allocs/op MIT license

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, and apu_test.
  • A zero-allocation hot loop, proven two ways. The per-frame path makes no heap allocations: enforced at runtime by an AllocsPerRun guard and a 0 allocs/op benchmark, 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 .sav formats.
  • 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 -region override for when the header is wrong.
  • Deterministic record & replay. Captures and replays input as FCEUX-compatible .fm2 movies; 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 amd64 and arm64 (.so), with the matching tanuki_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 $2002 read-suppression race, one-instruction NMI latency, NMI enable/disable across the VBlank edges, and the odd-frame dot skip (including even_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 $4014 write 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/$2004 OAM 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 $4011 ducks 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:

  1. Run task check and ensure it passes cleanly.
  2. Add a test for new emulation behaviour. The project is TDD: write the test first, then the implementation.
  3. Keep the zero-allocation invariant; the runtime guard and task escape will 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL