NESER is a Nintendo emulation systems engine written in Rust. It provides native desktop and browser frontends for multiple emulated systems, with shared platform code for audio, video, input, save states, configuration, ROM browsing, and debugging.
Supported emulation targets:
- Nintendo Entertainment System / Famicom / related variants
- Game Boy and Game Boy Color
- Game Boy Advance
- Super Nintendo Entertainment System (SNES)
For emulator-specific options and notes, see:
- NES-specific documentation
- Game Boy-specific documentation
- Game Boy Advance-specific documentation
- SNES-specific documentation
Download the latest archive for your platform from GitHub Releases.
Each release's notes are also in docs/releases/. Releases are cut with the release skill
(.claude/skills/release/SKILL.md): it computes the next version (maintenance, minor or major),
drafts the notes and the web frontend's scroll text from what merged since the previous tag, lands
them through a pull request once approved, and pushes the tag that runs the release workflow.
Release archives contain a top-level neser/ directory. Extract the archive, enter that directory, and run the included binary:
cd neser
./neser --version
./neserOn Windows, run neser.exe --version and neser.exe from the extracted neser\ directory.
Release packages include the native emulator binary, runtime assets, shader presets required by configured presets, neser.conf.example, README.md, the system-specific README files, and LICENSE.
cargo install neserNo SDL2 setup is required. The native frontend uses Rust crates for windowing, audio, and gamepad input. Linux source builds may still require system development packages for backend libraries used by those crates, such as ALSA and Wayland/X11.
The browser frontend is also packaged for https://neser.rmstdope.eu.
Successful main CI builds publish an immutable ghcr.io/rmstdope/neser-web image
for the opt-in VPS poller managed in rmstdope-infra. The hosted image contains
the emulator only: players load their own ROMs and firmware. Browser saves are
local to that site's origin, not shared with other NESER sites.
See container deployment for the build and release contract.
The existing SFTP deployment and native releases are unchanged.
Prerequisites:
- Rust via rustup; the exact toolchain is pinned in
rust-toolchain.tomland rustup installs it, with clippy, rustfmt and the wasm target, on the firstcargorun - Platform build tools needed by Rust native dependencies
- Node.js 22.13+ and pnpm for the web frontend (
corepack enableinstalls the pnpm versionpackage.jsonpins); runpnpm installonce
Native desktop build:
cargo build --release --bin neserNative desktop run from source:
cargo run --release --bin neser -- [OPTIONS] [ROM]Examples:
cargo run --release --bin neser --
cargo run --release --bin neser -- path/to/game.nes
cargo run --release --bin neser -- path/to/game.gb
cargo run --release --bin neser -- path/to/game.gba
cargo run --release --bin neser -- path/to/game.sfcThe repository contains multiple binaries, so include --bin neser when using cargo run.
NESER can be launched with or without a ROM path:
neser
neser path/to/romWhen launched without a ROM path, NESER opens the ROM browser. When launched with a ROM path, it loads that ROM directly.
Use the built-in help for the complete current CLI reference:
neser --helpCommon examples:
neser --fullscreen path/to/rom
neser --no-audio path/to/rom
neser --config path/to/neser.conf path/to/rom--headless runs a ROM without opening a window and writes a single frame to a
PNG, then exits. It works for all four systems and is intended for scripting and
CI — comparing a capture against a reference image, for example.
neser --headless --output shot.png path/to/rom # 60 frames (default)
neser --headless --frames 300 --output shot.png path/to/rom--output is required, and --frames, --capture-every and --output are rejected
without --headless. The exit code is 0 on success and 1 with a message on failure, so a
script can branch on it.
To capture a series of checkpoints in one run, add --capture-every K: every frame that
is a multiple of K is also written next to --output as <stem>_<N>.png, with N
zero-padded to the width of --frames, and --output still receives the final frame.
neser --headless --frames 3600 --capture-every 300 --output out/shot.png path/to/rom
# writes out/shot_0300.png, out/shot_0600.png, ..., out/shot_3600.png and out/shot.pngEach checkpoint is byte-identical to a single-frame capture at the same --frames, so a
sweep against a reference emulator (see scripts/reference_capture/README.md) costs one
run per ROM instead of one per checkpoint. --capture-every must be at least 1 and at
most --frames. Existing files at those paths are overwritten, as --output is.
Captures are reproducible: the mode forces zero-initialised RAM, takes no input,
and refuses to combine with the autorun flags or --tui. The same ROM and frame
count produce byte-identical PNGs across runs and across builds.
NES captures use the same palette as the window: --nes-palette (or nes-palette= in the config file) applies. Pass --nes-palette mesen to compare against Mesen2. Original Game Boy captures likewise use --gb-palette (or gb-palette=), and grey when neither is set; on Game Boy Color hardware they use --gbc-palette (or gbc-palette=), and auto when neither is set.
Note that a ROM may still be showing a blank screen in its first frames — the
default of 60 is enough for the test ROMs in roms/, but a ROM with a longer
boot or intro sequence needs a larger --frames.
When an NES or SNES game drifts from Mesen2, timing_trace (cargo build --release --features native --bin timing_trace) writes NESER's half of two traces: the clock at each
NMI entry, and every instruction between two NMI entries. The Mesen2 scripts in
scripts/reference_capture/ write the other half, and python -m scripts.diff_timing_traces
names the first divergent line. The recipe is in scripts/reference_capture/README.md,
"Tracing against Mesen2".
The browser frontend is built from the Rust WASM target and the JavaScript frontend under web/.
Supported ROM extensions in the web frontend include .nes, .gb/.gbc/.cgb, .gba, and SNES .sfc/.smc.
SNES games with a DSP-1, DSP-2, DSP-3 or DSP-4 chip (Super Mario Kart, Pilotwings, Dungeon
Master, SD Gundam GX, Top Gear 3000) need the chip's genuine firmware, which you supply: on the
desktop put dsp1b.rom, dsp2.rom, dsp3.rom or dsp4.rom in ~/.neser/firmware (or set snes-firmware-dir); the browser version
asks for each chip's file once. See README-SNES.md.
Play together (top bar) connects two browsers for a NES or Super Nintendo network game:
the host gets a short room code (like K7Q-M4X) to read out or copy, and the other player types it.
The two browsers meet through NESER's meeting service, liaison (wss://liaison.rmstdope.eu/ws), which
only passes the connection setup between them and never sees the game; they then connect directly,
each browser first asking outpost, NESER's own STUN server (stun:outpost.rmstdope.eu:3478), for its
public address, so players in different homes can play together and each can see the other's internet
address. Networks that pick a new public port for every destination (common on mobile, hotspot and
work networks) cannot be connected directly; for those, liaison hands both browsers the room's TURN
relay on outpost, in Sweden, and the game goes through it, which the window and the top-bar chip
("Relayed") say. Players who can connect directly still do. When even the relay cannot connect them,
or liaison had no relay to give, both players are told so after about 10 seconds. With outpost down,
only computers on the same network (or VPN) connect. liaison accepts pages served from
https://neser.rmstdope.eu and from localhost/127.0.0.1 only, so a self-hosted copy reached by another
address (a LAN IP, say) cannot start a network game. The two must run the same NESER build, and
a connection slower than 120 ms round trip is refused. Once connected the host presses Start game:
the game (with any SNES firmware it needs) and the host's current moment are sent to the other
player, who needs no copy of the game and does not keep it, and both start from that moment after a
3-2-1 count. Then they play one game together: the host is player 1 and the other player is
player 2, each with their usual keys or gamepad. Pause and Reset act on both games, and Settings,
Keys and controls and the Play together window pause both while open; Save, Load, Eject, Library
and the debugger are greyed out meanwhile. If one game falls behind (a hidden tab), the other waits
for it. The two games check once a second that they still match; if they ever drift apart, both
stop, the other player's game is set to the host's moment, and both carry on after a 3-2-1 count
(if that takes more than 15 seconds, playing together ends). Either player can end playing
together; the host can then keep playing alone.
See web/README.md for detailed prerequisites, build, run, and test commands.
Build and serve it:
pnpm install # once, and after package.json changes
pnpm start # pnpm run build, then pnpm run servepnpm run build compiles the wasm and bundles the app into dist/; pnpm run serve serves
dist/ at http://localhost:8000 (or on the port in NESER_WEB_PORT).
While developing, run pnpm run dev instead: edits to the web code under web/ show up in the
open page at once, and saving Rust code rebuilds the wasm and reloads the page on its own.
NESER can be configured with config files and command-line arguments.
Configuration priority is:
- Built-in defaults
~/.neser/neser.conf./neser.conf--config <file>if specified- Command-line arguments
Copy the example config to get started:
mkdir -p ~/.neser
cp neser.conf.example ~/.neser/neser.confSee neser.conf.example for all documented configuration keys and valid values.
The native frontend supports keyboard and gamepad input.
Common hotkeys:
| Key | Action |
|---|---|
Space |
Pause/resume |
Escape |
Release mouse grab / close overlays |
Ctrl+Q |
Quit |
Ctrl+R |
Reset |
Ctrl+F |
Toggle fullscreen |
F1 |
Toggle FPS overlay |
F2 / F3 |
Volume up/down |
F4 |
Cycle shader preset |
F5 |
Toggle debugger |
F6 / F7 |
Save/load state |
F8 |
Cycle the palette (NES system palette, or the original Game Boy's shade palette or Game Boy Color tints), or switch Game Boy Color or Game Boy Advance colour correction |
F10 / F11 |
Debugger step over/into |
System-specific controls and controller options are documented in:
Recommended setup after cloning:
git config core.hooksPath .githooksThe hooks in .githooks auto-format staged Rust and Python files (Python with the checkout's
.venv/bin/ruff, or ruff on PATH; a commit with staged Python and no ruff is refused), then
forward to the beads hooks under .beads/hooks that keep the work board in step with git.
Running bd init again will point core.hooksPath at .beads/hooks; set it back to
.githooks afterwards so both keep running.
Working with the fleet: development is driven by a fleet of AI agents run by
Cerebro, and planned work is tracked as beads (bd) on a
board that syncs through a Dolt remote on this repository, not through git. After cloning:
git submodule update --init --recursive # cerebro, shaders and SNES test ROMs
bd bootstrap # fetch the work board (refuses if .beads has a database already)
bd list # see it; bd dolt pull / bd dolt push keep it in sync
.cerebro/cerebro/scripts/cerebro-tui # the fleet view (needs cargo)CLAUDE.md describes how work moves; docs/migration/github-to-beads.md records how the GitHub
issues were moved onto the board. GitHub issues remain open as the inbox for bug reports and
requests from outside.
The full pre-merge gate is one script, ./scripts/gate-full.sh (--fast for the quick subset).
Before its long legs it runs scripts/chromedriver_match.py, which picks a ChromeDriver whose major
version matches the installed Chrome (any chromedriver on PATH first, then wasm-pack's cache) and
hands it to wasm-pack test --chromedriver; with none it stops at once with one line naming both
versions. The fix is a matching ChromeDriver first on PATH, e.g. from
Chrome for Testing.
Useful checks on their own:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --target wasm32-unknown-unknown --no-default-features --features wasm --all-targets -- -D warnings
cargo clippy --no-default-features --features frontend --all-targets -- -D warnings
cargo test --all-features --lib
pnpm test
pnpm run build:wasm && pnpm run typecheck
ruff check scripts
ruff format --check scripts
mypy --config-file scripts/pyproject.toml scriptsRun tests for a specific Rust source area:
./scripts/test-dir.sh src/gb --skip-integration
./scripts/test-dir.sh src/nes/cartridge
./scripts/test-dir.sh src/snes--skip-integration skips the integration-test modules of all consoles
(nes/gb/gba/snes) for fast iteration; run without it before creating a
PR. SNES test commands, asset policy, and the golden-baseline approval
workflow are documented in README-SNES.md.
Local builds and CI use the one Rust version pinned in rust-toolchain.toml, so a new stable
release cannot turn CI red on unchanged code. Moving to a newer Rust is a deliberate pull request:
- Change
channelinrust-toolchain.tomlto the new exact version (for example1.99.0). - Run
./scripts/gate-full.sh; rustup installs the new toolchain on the firstcargocall. - Fix every new clippy lint and rustfmt change in the same pull request.
RUSTUP_TOOLCHAIN in the environment beats the file. rustup's cargo sets it for every process
it starts, so a shell opened from a program launched with cargo run inherits stable.
scripts/gate-full.sh, scripts/test-dir.sh, pnpm run build:wasm and the pre-commit hook
unset it. For any other command, run unset RUSTUP_TOOLCHAIN first, and check with rustup show active-toolchain.
The pin uses rustup's minimal profile, so CI downloads no more than it needs. rust-analyzer needs
the standard library source: run rustup component add rust-src once per pinned version.
The tools under scripts/ are configured by
scripts/pyproject.toml, which holds the ruff and
mypy settings and pins the dependencies as PEP 735 dependency groups.
Create the virtualenv once, from the repository root:
./scripts/setup-venv.sh # or PYTHON=python3.14 ./scripts/setup-venv.sh
source .venv/bin/activateThe script creates .venv if it is missing and installs the test and dev
groups exactly as CI does. Every Cerebro-prepared worktree runs it on install.
scripts/gate-full.sh uses .venv/bin/python and never the system python3,
so the full gate stops at once, with one line naming this script, when .venv
is missing.
The test group holds the runtime dependencies of the tools; dev holds ruff
and mypy. A third group, deploy, is only needed for scripts/deploy.py.
Installing the groups requires pip 25.1 or newer.
Then, with the virtualenv active:
python -m unittest discover -s scripts -t . -p "test_*.py"
ruff check scripts
ruff format --check scripts
mypy --config-file scripts/pyproject.toml scriptsCI runs exactly these four commands on Python 3.14. ruff format rewrites in
place, so enabling the pre-commit hook above keeps the formatting check green
without thinking about it. mypy is silent by default and strict only for the
shared data-access modules listed in scripts/pyproject.toml; adding a module
to that list is how type coverage grows.
src/contains Rust emulator, frontend, and shared platform code.web/contains the browser frontend.roms/contains automated test ROMs and test assets.scripts/contains build, packaging, test, ROM management, and metadata tools.shaders/andvendor/slang-shaders/contain shader presets used by the native frontend.vendor/slang-shaders/is a pinned submodule; see docs/VENDOR_SUBMODULES.md for when and how to move the pin, and how to re-verify shader reachability afterwards.- architecture.md describes the codebase structure in more detail.
NESER is licensed under the MIT License. See LICENSE.