Skip to content
rmstdopePublic

Latest commit

 

History

4,251 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NESER

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:

Installation

Pre-built releases

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

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

Install from crates.io

cargo install neser

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

Building from source

Hosted browser frontend

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.toml and rustup installs it, with clippy, rustfmt and the wasm target, on the first cargo run
  • Platform build tools needed by Rust native dependencies
  • Node.js 22.13+ and pnpm for the web frontend (corepack enable installs the pnpm version package.json pins); run pnpm install once

Native desktop build:

cargo build --release --bin neser

Native 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.sfc

The repository contains multiple binaries, so include --bin neser when using cargo run.

Running NESER

NESER can be launched with or without a ROM path:

neser
neser path/to/rom

When 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 --help

Common examples:

neser --fullscreen path/to/rom
neser --no-audio path/to/rom
neser --config path/to/neser.conf path/to/rom

Headless frame capture

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

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

Timing traces against Mesen2

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

Web frontend

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 serve

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

Configuration

NESER can be configured with config files and command-line arguments.

Configuration priority is:

  1. Built-in defaults
  2. ~/.neser/neser.conf
  3. ./neser.conf
  4. --config <file> if specified
  5. Command-line arguments

Copy the example config to get started:

mkdir -p ~/.neser
cp neser.conf.example ~/.neser/neser.conf

See neser.conf.example for all documented configuration keys and valid values.

Controls

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:

Development

Recommended setup after cloning:

git config core.hooksPath .githooks

The 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 scripts

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

Bumping the Rust toolchain

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:

  1. Change channel in rust-toolchain.toml to the new exact version (for example 1.99.0).
  2. Run ./scripts/gate-full.sh; rustup installs the new toolchain on the first cargo call.
  3. 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.

Python tooling

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

The 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 scripts

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

Repository guide

  • 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/ and vendor/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.

License

NESER is licensed under the MIT License. See LICENSE.

Releases

Packages

Contributors

Languages