SearchA-ZN › Nintendo 64

Nintendo 64

1996 Open source · MIT Online

This is the Nintendo 64 running in your browser: N64Wasm (Neil Barkhina) — the RetroArch ParaLLEl / mupen64plus core — compiled from source to WebAssembly with Emscripten and self-hosted, no CDN. It boots public-domain N64 homebrew and plugs into the shared in-frame debugger for the NEC VR4300 (MIPS III) main CPU, with genuine live registers, a real single-instruction step, side-effect-free RDRAM, breakpoints and watchpoints.

Visit the source repository ↗

Visit the official site ↗

Runs on: Web browser

Nintendo 64 Online Emulator

Play Nintendo 64 using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
Mandelbrot (interactive)Nintendo 64Nintendo 64openOpen ⛶
Mandelbrot (FPU)Nintendo 64Nintendo 64openOpen ⛶
Julia (FPU)Nintendo 64Nintendo 64openOpen ⛶
PCM Sound DemoNintendo 64Nintendo 64openOpen ⛶
CP0 Register TestNintendo 64Nintendo 64openOpen ⛶

Machines emulated

Chips

Notes

Embedding

The Nintendo 64 core is N64Wasm (Neil Barkhina, MIT) — the RetroArch ParaLLEl / mupen64plus emulator compiled to WebAssembly with Emscripten. N64Wasm's frontend (mymain.cpp) is an SDL2 + WebGL2 app that drives the libretro core; we rebuilt it from source and self-host the result (n64wasm.js / n64wasm.wasm, no CDN). The module is a classic (non-MODULARIZE) Emscripten build linked with -sINVOKE_RUN=0, so main() only runs when we call it and the frame loop is ours to drive.

Boot. We define window.Module first (canvas + locateFile), load the glue, then in onRuntimeInitialized stage the assets, a config.txt (key map + options) and the ROM into the Emscripten FS, call main(), and start our own requestAnimationFrame loop over _runMainLoop() (one video frame each call):

var Module = { canvas: cv, locateFile: f => SRC + f, noInitialRun: true,
  onRuntimeInitialized: function(){
    Module.FS.writeFile('assets.zip', assets);
    Module.FS.writeFile('config.txt', config);       // key map + renderer options
    Module.FS.writeFile('game.z64', rom);
    Module.callMain(['game.z64']);                    // load ROM, init RCP, return
    (function loop(){ if(running) Module._runMainLoop(); requestAnimationFrame(loop); })();
  } };

Rendering is native. The core owns a WebGL2 context on the <canvas id="canvas"> and rasters straight to it, so there is no manual blit — pausing is just declining to call _runMainLoop().

Input. The frontend reads the browser keyboard through SDL2 (SDL_GetKeyboardState) mapped by config.txt, so the physical keyboard works with no glue. The on-screen pad (in the shell fold-up and the debugger Controls window) synthesizes DOM keydown/keyup events carrying the same code values, which SDL2 picks up — one input path for both.

MemberKindWhat it does
callMain(['game.z64'])runtimeRun main(): read config, load the ROM into the ParaLLEl core, init the RCP, and return.
_runMainLoop()exportEmulate exactly one video frame (retro_run: VR4300 + RSP + RDP + VI). Our run loop's advance and frame-step.
_neil_dbg_pc() / _neil_dbg_set_pc(v)exportRead / write the VR4300 program counter (r4300_pc / generic_jump_to).
_neil_dbg_reg_lo(i) / _neil_dbg_reg_hi(i)exportLow / high 32 bits of the 64-bit GPR i (r4300_regs()[i]).
_neil_dbg_set_reg(i,lo,hi)exportWrite back a 64-bit GPR.
_neil_dbg_read8(a) / _neil_dbg_write8(a,v)exportSide-effect-free RDRAM byte access (the ^3 big-endian swap), for the hex / disasm views.
_neil_dbg_step_one()exportExecute exactly one VR4300 instruction (InterpretOpcode()), the debugger's Step.

Debugger integration

This is a WebAssembly console, yet it gets the same live debugger as the pure-JavaScript machines — real 64-bit registers, real memory, a real single-instruction step, plus execution breakpoints and memory watchpoints. mupen64plus keeps the VR4300 register file and RDRAM in plain C, reachable through its own accessors, which we expose from the rebuilt frontend as a small sampling API.

1 · Exactly what was added. A block of EMSCRIPTEN_KEEPALIVE functions appended to mupen64plus-core/src/main/main.c (registers + RDRAM) and one to r4300/pure_interp.c (the step). They are sampling functions: the debugger calls them ~10×/second while its window is open, and once per Step. There is no per-instruction or per-cycle callbackretro_run / the interpreter hot path is byte-for-byte unchanged (the golden performance rule). We also flipped the core's default from cached to pure interpreter so the single-step is one exact MIPS instruction.

// main.c — sampling only, no hot-path hook
uint32_t neil_dbg_pc(void)        { return *r4300_pc(); }
uint32_t neil_dbg_reg_lo(int i)  { return (uint32_t)(r4300_regs()[i&31]); }
uint32_t neil_dbg_reg_hi(int i)  { return (uint32_t)((uint64_t)r4300_regs()[i&31] >> 32); }
int neil_dbg_read8(uint32_t a){                 // side-effect-free RDRAM byte
  uint8_t *d = (uint8_t*)g_dev.ri.rdram.dram;
  return d[(a & (g_dev.ri.rdram.dram_size-1)) ^ 3]; }  // ^3 = N64 big-endian byte
// pure_interp.c
void neil_dbg_step_one(void){ InterpretOpcode(); }  // one exact VR4300 instruction

2 · What is REAL here. Because mupen64plus keeps the VR4300 state in a plain C context:

  • Real registers — all 32 GPRs (r0-r31), PC, and HI/LO, read and written live off r4300_regs() / r4300_pc(). The GPRs are 64-bit; the register panel shows the low 32 bits (the shared hex formatter is 32-bit), and writes preserve the high word.
  • Real single-instruction stepStep i calls neil_dbg_step_one() = InterpretOpcode(), which runs exactly one MIPS III instruction (and its delay slot); PC advances by one instruction per click.
  • Side-effect-free memory — reads resolve RDRAM directly with the N64 big-endian byte order, so auto-polling the hex / disasm view never disturbs the machine. The new vr4300 decoder disassembles it as MIPS III.
  • Execution breakpoints & memory watchpoints — implemented host-side in the run loop, so they cost nothing when the debugger is closed.

3 · Breakpoints and watchpoints without a hot-path hook. With nothing armed, the loop runs a whole frame at native speed with _runMainLoop(). As soon as a breakpoint or watchpoint is set, the loop switches to stepping the VR4300 one instruction at a time with neil_dbg_step_one(), comparing the live PC against the breakpoint set and each watched RDRAM byte against its last sampled value, and pausing on a hit. (While single-stepping the CPU the RSP/RDP/VI do not advance, so the picture holds on its last frame until you resume.)

4 · The controllable loop. We own the frame loop so the transport can pause/step. Pause stops calling _runMainLoop(); Resume restarts it; Step (frame) runs one _runMainLoop(); Step i (instruction) calls neil_dbg_step_one().

Architecture

The Nintendo 64 (1996) is a 64-bit cartridge console built around two big chips. Its main CPU is the NEC VR4300, a 64-bit MIPS III core (the debugger's vr4300 decoder covers the MIPS I/II/III integer set, the 64-bit D-ops, and the COP0/COP1 FPU). Alongside it the RCP (Reality Co-Processor) holds the RSP (a MIPS-based vector unit for audio/geometry microcode) and the RDP (the rasteriser). mupen64plus emulates the whole machine; N64Wasm's ParaLLEl build runs it in the browser.

  • NEC VR4300 (MIPS III) — the main CPU at ~93.75 MHz. mupen64plus runs it here as a pure interpreter; InterpretOpcode() is the run-one-instruction primitive this integration single-steps with.
  • RSP — the Reality Signal Processor, a MIPS core running microcode; ParaLLEl emulates it (with the HLE / cxd4 paths) for graphics and audio lists.
  • RDP — the Reality Display Processor, which rasterises triangles and the framebuffer, presented to the WebGL2 canvas.
  • RDRAM — 4 MB (8 MB with the Expansion Pak) of Rambus memory; the debugger reads it live and side-effect-free.
  • Cartridges load as .z64 / .n64 / .v64 images; mupen64plus HLE-boots the PIF, so no separate boot ROM is needed. The bundled ROMs are public-domain homebrew (krom / PeterLemon).

How to build this exact artefact. Toolchain: Homebrew emscripten 6.0.3 (emcc on PATH).

git clone https://github.com/nbarkhina/N64Wasm
# 1. append the neil_dbg_* sampling hooks to mupen64plus-core/src/main/main.c
#    and neil_dbg_step_one to r4300/pure_interp.c; force pure interpreter
# 2. add the hooks to EXPORTED_FUNCTIONS and rebuild (modern clang needs
#    -Wno-error=incompatible-pointer-types for the 2021-era C):
cd N64Wasm/code && make        # -> dist/n64wasm.js + dist/n64wasm.wasm

The build is single-threaded (no SharedArrayBuffer), so it hosts anywhere. It needs a WebGL2 context for the RCP output.

Sound

Pattern V-native. N64Wasm carries its own WebAudio pipeline, so the sound is driven through the core's native path and is not rerouted through the site's shared EmuAudio sink (/debugger/src/audio.js is not loaded). This is not an SDL2 build — for Emscripten the SDL audio device is compiled out (#ifndef __EMSCRIPTEN__). Instead mupen64plus's audio backend resamples the N64's AI (Audio Interface) output to 44100 Hz stereo-interleaved Int16 into a 64000-entry ring buffer, exported to JS as _neilGetSoundBufferResampledAddress() (the buffer pointer) and _neilGetAudioWritePosition() (the write cursor). The stock N64Wasm page owns the player that drains that ring; our minimal embed had omitted it, so there was no audio path at all.

What we added. We recreate that player inside the boot script: our own AudioContext forced to sampleRate: 44100 (it MUST match the core's resample target or the pitch is wrong), a ScriptProcessorNode whose onaudioprocess copies L,R pairs from the ring (following the write cursor, emitting silence when starved) into gain → destination, and it reports the queued-sample count back to the core via _neil_set_buffer_remaining(). The ring view is taken over Module.HEAPU8.buffer (this build exports HEAPU8, not HEAP16) and re-acquired if wasm memory grows.

function onAudio(ev){
  var L = ev.outputBuffer.getChannelData(0), R = ev.outputBuffer.getChannelData(1);
  var ring = ringView(), wp = M._neilGetAudioWritePosition()|0;
  for (var i=0; i<1024; i++){
    if (ring && _readPos !== wp){ L[i]=ring[_readPos]/32768; R[i]=ring[_readPos+1]/32768; _readPos+=2; }
    else { L[i]=0; R[i]=0; }
  }
}

Mute contract. window.EMU_BOOT.transport exposes isMuted() / setMute(m), and it starts muted (browsers block pre-gesture audio). The context is created suspended with gain 0; the shell's Sound button — a real click — calls setMute(false), which sets gain 1 and resume()s the context; muting again drops gain to 0 and suspend()s it. A ~300 ms guard re-asserts silence so a stray page gesture never leaks sound. Verified headless: on the button click the player builds and the context reaches running at 44100 Hz, and re-muting returns it to suspended.

The core fix — AI DMA address masking. Getting real samples into the ring needed a one-line-class fix inside mupen64plus's Audio Interface, not the player. The N64 AI_DRAM_ADDR register is only a 24-bit physical pointer; real hardware ignores the upper (KSEG0 / virtual) bits. mupen64plus, however, indexed rdram.dram[address / 4] with the raw register value. Ordinary games route audio through the RSP microcode, which programs a physical address, so they were fine. But bare-metal PCM demos write a cached virtual address (e.g. 0x80001090) straight to AI_DRAM_ADDR; that value indexed roughly 2 GB past the 8 MB dram[] array, so the AI's endian byte-swap loop read out of bounds and trapped the WebAssembly instance mid-DMA — before it could clear the AI busy flag or hand the samples to the resampler. The CPU then span forever in the demo's AIBusy wait, the write cursor stayed at 0, and nothing was audible. The fix masks the AI DMA source to the physical RDRAM range (address & (dram_size - 1)) at both push sites in ai_controller.c, matching the hardware's address masking / RDRAM mirroring. With that, the AI DMA completes, the resampler fills the ring, and the write cursor advances.

// ai_controller.c — mask KSEG0/virtual bits so a bare-metal AI address hits physical RDRAM
uint32_t rd_addr = ai->fifo[0].address & (ai->ri->rdram.dram_size - 1);
uint8_t *p = (uint8_t*)&ai->ri->rdram.dram[rd_addr / 4];

Default is audible. The console now boots a public-domain PCM Sound Demo (krom / PeterLemon, Unlicense) that streams a looping 16-bit 44.1 kHz stereo sample directly through the AI, so pressing the Sound button plays continuous audible sound. Verified headless on this exact from-source build: after the button's real click the resampled ring's write cursor advances and wraps, with clearly non-zero peak samples, and re-muting silences the output. The bundled fractal / register demos (Mandelbrot, Julia, CP0 test) drive no audio hardware and stay silent by nature — that is the software, not the console.