n64js is an n64 emulator written in (mostly) pure ES6 JavaScript. It runs many roms at full framerate.
Mostly for the challenge. I've spent ~25 years (on and off) working on N64 emulators and writing one in JavaScript gives me the opportunity to expand my comfort zone and learn something new. It's a good showcase for how powerful modern browsers have become.
A hosted version is available on GitHub pages at https://hulkholden.github.io/n64js/.
Install bun: https://bun.sh/.
Install dependencies from the repository root:
bun install
Compile sources (add --watch to automatically recompile on any change):
bun run build
The generated build/ directory is ignored by Git. Build locally before running
the site. The footer shows development by default; to preview a release version,
run BUILD_VERSION=v1.0.1 bun run build. bun run build-debug produces an
unminified bundle with the same version handling. The Pages workflow sets
BUILD_VERSION to the tag that triggered deployment.
Run a local webserver in the root directory:
python3 -m http.server
Navigate to http://localhost:8000/.
Bun scripts can set controller input through the live inputs array returned by
createHeadlessEmulator. For example, from a script in the repository root:
import { createHeadlessEmulator, loadROMFile, runCycles } from './src/headless/headless_env.js';
const emulator = await createHeadlessEmulator(await loadROMFile('path/to/game.z64'));
const controller = emulator.inputs[0];
controller.buttons = 0x1000; // Hold Start.
controller.stick_x = -80;
controller.stick_y = 0;
runCycles(emulator, 10_000_000);
controller.buttons = 0; // Release Start and centre the stick.
controller.stick_x = 0;
runCycles(emulator, 10_000_000);buttons is a 16-bit button mask; stick_x and stick_y are signed 8-bit values.
Update fields on the existing objects rather than replacing array entries. Each
fresh emulator has four independent, neutral input states; only port 0 is
connected by default. Changing input state does not connect another port.
Pass { executeGraphics: true } to createHeadlessEmulator when investigating
graphics tasks or guest scheduling. This executes HLE display lists without
pixels and raises DP interrupts on FullSync commands. The default skips lists
and approximates one DP interrupt per task; games that split a frame across
tasks, such as Griffey Slugfest and MLB, can stall under that approximation.
The shared GBI display-list runner bounds synchronous work to one million
commands, checked between batches. Exceeding that host safeguard raises
DisplayListLimitError; inventory reports retain it as a halt with exception
evidence, rather than claiming task completion. CPU producer waits receive a
fresh budget on resume. Object-list microcodes use separate execution paths.
New inventory runs collect distinct draw-time RDP states in
graphics.drawModes. Query saved reports or an inventory directory:
bun run inventory-query /path/to/inventory --blend-mode 0xc811
bun run inventory-query /path/to/inventory --blend-mode 0xc811 --cycle-type 2cycle
bun run inventory-query /path/to/inventory --cycle-type copy --texture RGBA16
bun run inventory-summary /path/to/inventory--blend-mode accepts a decimal or 0x-prefixed 16-bit value: the complete
blender mux (otherModeL >>> 16), including both cycles. --cycle-type accepts
1cycle, 2cycle, copy, or fill. Combined blender/cycle filters must match
the same draw state; microcode and texture filters need only match the same run.
The summary exposes all collected states so you can discover values to query.
Each state stores unsigned otherModeH, otherModeL, combineHi, and
combineLo words plus a draws count. The command opcode byte is removed
from otherModeH. Observations cover headless HLE and raw
RDP draw submissions, including rectangles, untextured triangles, lines, and
clears. Counts represent renderer submissions (triangle batches count once),
not pixels or individual triangles. A recorded blender or combiner setting may
be inactive in that cycle type; this is evidence of submitted state, not visible
blending. Configured states never submitted to a draw are excluded.
Older reports without this collector return unknown and need a new scan for graphics-mode coverage. A supported empty collector means not observed in that run. Partial runs can still supply matches; absence is not proof that a ROM never uses a mode.
Pull requests run three GitHub Actions workflows. Open the failing check on the PR for its logs and download the run's artifacts when investigating a failure.
The CI workflow runs ESLint, Bun tests and the
production build on pull requests and pushes to main. Run the same checks
locally from the repository root:
bun run lint
bun test
bun run buildUse bun run lint:fix to apply automatic lint fixes. Linting covers src/,
including tests and benchmarks, tools/, and the ESLint config. Warnings fail
the check as well as errors. Existing no-unused-vars findings are recorded in
eslint-suppressions.json as a baseline of counts per file; increases fail CI.
Other recommended rules remain fully enforced. When cleaning up existing unused
variables, run bun run lint --prune-suppressions and commit the reduced baseline.
Conditionals and loops require braces and multiline bodies. bun run lint:fix
adds the braces, line breaks and indentation automatically. Device implementations
in src/devices/ may use compact braced blocks such as if (flag) { update(); }
to keep register handling and conditional logging readable; device tests and
benchmarks follow the normal multiline rule. Short functions may remain on one
line.
The system-test workflow builds pinned test
ROMs from source and compares the PR against its base. It checks the main, TLB
and 64-bit-addressing groups for new failures and lost coverage. The
n64-systemtest artifact contains logs and JSON results for both revisions.
See the system-test guide for comparison policy,
configuration and debugging.
The texture-sampler workflow runs GPU
pixel checks and compares synthetic rendered scenes with golden images in
pinned Chromium with SwiftShader. It also verifies that a deliberately introduced
sampling regression is detected. The texture-sampler-visuals artifact contains
results and actual, golden and difference images for failed comparisons.
See the texture-sampler test guide for running
the checks locally, using the browser gallery and reviewing golden updates.
Audio HLE support is a work in progress, derived from captured RSP disassembly and libultra headers. Supported ABI1 variants include those used by Super Mario 64, Tetrisphere, GoldenEye 007, Diddy Kong Racing and Blast Corps. Supported NAUDIO variants include those used by Army Men: Air Combat, Banjo-Kazooie and Donkey Kong 64. NEAD variants include Mario Kart 64, Star Fox 64, Shindou editions of Mario and Wave Race, Yoshi’s Story, 1080 Snowboarding, Ocarina of Time, Majora’s Mask, F-Zero X and Animal Forest.
HLE is the default audio mode for supported microcodes; unreviewed identities and command shapes fall back to LLE. Select Audio → Emulation Mode → LLE to run all audio tasks on the RSP.
Push a new v* tag (for example, v1.2.3) to publish that commit to GitHub Pages.
The tagged commit must include the Pages workflow. It installs dependencies using
the pinned Bun version, builds the bundle, and deploys the site files directly as
a Pages artifact. Generated files do not need to be committed, and ordinary
branch pushes do not update the published site.
One-time repository setup when migrating from branch-based Pages publishing:
- In Settings > Pages, set the build and deployment source to GitHub Actions.
- In Settings > Environments > github-pages, allow deployment tags matching
v*(the existinggh-pagesbranch rule does not allow tags).
The deployment includes index.html, n64js.css, ui.css, js/, and the generated
build/ directory. Each deployment replaces the site at the existing Pages URL.
Compatibility has improved a lot over the past few months.
As of 2023-09-23 95% of n64-systemtest tests now pass.
The areas where tests are failing are:
- 64-bit memory access (rarely/never used by roms)
- RDP (shouldn't be a problem, as n64js uses HLE)
- Floating point accuracy
The floating point issues are largely edge cases with rounding values close to the numerical limits for 32 bit floats.
Beyond the things n64-systemtest covers, the main compatibility issues I'm aware of are:
- imprecise cycle counting
- graphics
Imprecise cycle counting affects some roms more than others. GoldenEye in particular seems to hang when LLE audio emulation is enabled on the RSP. I suspect this is due to the CPU running faster than it should be and causing the game to overflow audio buffers.
Graphics are rendered using high-level emulation and there are still a lot of TODOs. Many roms are playable but most have graphical issues of some kind.
Choose Off, Simple, or Mattias under Debug → Graphics → CRT. The default is Off. Both styles apply to the finished frame. Mattias adds colour separation, spatial ghosting, curvature, and animated scanlines that pause with emulation.
Internal Render Scale defaults to 1×, using the VI source resolution (often 320×240). Increase it for higher-resolution geometry. Canvas Scale controls display resolution independently: the final presentation pass upscales the frame and applies CRT effects at the canvas resolution. Use 1× internal rendering for a native-pixel CRT look; 2× gives 640×480 rendering for a 320×240 game. Progressive CPU framebuffers also upload native pixels; interlaced CPU video retains the existing field-weaving path.
The Mattias style follows MattiasCRT by Mattias Gustavsson. Its implementation is adapted from his MIT-licensed crtemu_pc.h. The attribution and full MIT notice are retained in crt_mattias.glsl, including in the bundled shader source. This adaptation uses the current frame for ghosting and omits the original library's temporal blur buffers and frame overlay.
Saving and loading require native Uint8Array.prototype.toBase64() and
Uint8Array.fromBase64() support: Chrome/Edge 140+, Firefox 133+, or Safari 18.2+
(including iOS). See browser compatibility.
Chrome is the preferred development browser.
I've been testing on an Apple M2 Max and most roms run at full framerate most of the time. LLE audio emulation seems to be the biggest performance hit. To date I've mostly been focused on compatibility so there are likely a lot of improvements to be made here.
- CPU
- cop0 instructions
- cop1 instructions
- TLB
- Cycle accuracy
- RSP
- Controller
- Static key bindings
- Configurable bindings
- Gamepad API
- Graphics
- HLE
- GBI0 - mostly implemented
- GBI1 - partially implemented
- GBI2 - partially implemented
- LLE - not implemented
- HLE
- Audio
- HLE - not implemented
- LLE - implemented
- Save
- Persistence (via localStorage)
- Import/Export
- Mempack
- Eeprom 4k
- Eeprom 16k
- SRAM
- FlashRAM
Here are some things I'd like to get around to:
- Fix graphics issues
- Save game import/export
- Savestates
- Gamepad support.
n64js is derived from Daedalus, an emulator I started working on around 1999 and continued working on periodically for many years. Around 2012 I made a bet with @mmalex that I could write a port in JavaScript, and n64js was born!