JavaScript MIT

n64js

An n64 emulator in JavaScript

H

hulkholden

Dernière activité 29 sept. 2026
hulkholden/n64js

655

étoiles

87

forks

34

issues ouvertes

Ce README est souvent en anglais.

n64js

n64js is an n64 emulator written in (mostly) pure ES6 JavaScript. It runs many roms at full framerate.

Why?

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.

How To Run

A hosted version is available on GitHub pages at https://hulkholden.github.io/n64js/.

Development

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/.

Headless controller input

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.

Querying graphics modes in ROM inventories

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.

Continuous integration

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.

Lint, tests and build

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 build

Use 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.

N64 system tests

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.

Texture sampler visuals

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

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.

Publishing

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:

  1. In Settings > Pages, set the build and deployment source to GitHub Actions.
  2. In Settings > Environments > github-pages, allow deployment tags matching v* (the existing gh-pages branch 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

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.

CRT display effects

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.

Browser Compatibility

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.

Performance

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.

Implementation Status

  • 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
  • Audio
    • HLE - not implemented
    • LLE - implemented
  • Save
    • Persistence (via localStorage)
    • Import/Export
    • Mempack
    • Eeprom 4k
    • Eeprom 16k
    • SRAM
    • FlashRAM

TODOs

Here are some things I'd like to get around to:

  • Fix graphics issues
  • Save game import/export
  • Savestates
  • Gamepad support.

History

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!

Projets similaires

A JavaScript NES emulator.

JavaScript6502emulationemulator
Bbfirsh
6,4 k étoiles859

N64 Emulator

C++
Pproject64
3,1 k étoiles563

A web-based frontend for RetroArch

JavaScriptatari-lynxatari2600atari7800
EEmulatorJS
4,2 k étoiles997