The web is a terrible console. It has no real-time kernel, no predictable audio clock, and a JavaScript event loop that stalls whenever the browser decides to do something else. Yet we wanted the same Snes9x core we run natively to run in a browser tab at a stable 60 FPS as a baseline. That meant rethinking the wrapper, not just recompiling the core. We built a project-owned Emscripten pipeline, fixed a JS audio bug that doubled the audio buffer and mixed the channels, and measured every compile flag we could. The result is a 2.2 MB snes9x.wasm that runs Super Mario World at roughly 0.92 ms per frame in Node and serves only our own homebrew ROMs on the games site.

Why the old wrapper had to go
Our original web build cloned an external external/snes9x-wasm wrapper repository, which in turn cloned Snes9x and built it with its own s9x_shim.cpp. It worked, but it was fragile: two source lists, two shim files, and a build that could only happen in one specific environment. More importantly, it hid the audio problem. The native snes9x-devkit was fast, but the web page felt sluggish. We assumed the WASM core was slow. It was not.
The native devkit replaced the old Snes9x 1.63 AppImage for a reason. The AppImage bundled GTK/SDL, relied on RPATH, and could not run headless for deterministic capture. It also inherited a unix-port behavior we wanted to avoid: issue #530 showed that with SoundSync enabled, PulseAudio could report a fragment open even when nothing was playing, so the emulator could not throttle and ran too fast. On the web, the equivalent trap is the AudioContext. Browsers do not guarantee a 32 kHz clock like the S-DSP. They give you a 44.1 kHz or 48 kHz callback and expect you to fill it. If you miscalculate the buffer, you get exactly the same symptom: drifting, oversized buffers and mixed channels.
The self-hosted build
scripts/lib/sn/sn-build-wasm.sh is now the canonical build. It compiles the same upstream Snes9x sources from external/snes9x-src together with our vendored sn/shared/snes_emu_s9x_shim.cpp. The output is build/<preset>/wasm/snes9x.js and snes9x.wasm. The build uses the same snes9x_core_sources.cmake list as the native build, so the two cannot drift.
emcc -O3 -std=c++17 -fno-exceptions -fno-rtti \
-s MODULARIZE=1 -s EXPORT_NAME=createSnes9xModule \
-s INITIAL_MEMORY=64MB \
-s ENVIRONMENT=web,node \
-flto # when WASM_LTO=1 (default) \
-o snes9x.js # plus the core source list The build is incremental: it writes a build-args fingerprint to build/<preset>/wasm/build_args.txt and recompiles only sources newer than their object files unless the flags or the script itself changes. That matters because a full LTO rebuild of 52 core files plus the shim takes time, but an incremental build is fast enough to run during site generation.
The audio buffer bug
The real performance win came from the JavaScript side. The old snes-emu.js created an AudioBuffer with a length equal to the interleaved stereo sample count:
// samples is Int16Array of interleaved stereo (LRLR...)
const buffer = audioContext.createBuffer(
2, // 2 channels
samples.length, // BUG: this is 2x the frame count
audioContext.sampleRate
);
// result: 2x-long buffers, channels mixed or silent The fix is to deinterleave left and right into separate Float32Array views and create a two-channel buffer whose length is half the interleaved count. The new code uses frames = samples.length >> 1, builds Float32Array channels from the Int16Array sample data, and calls copyToChannel to fill the buffer. It also creates the AudioContext at the Snes9x output rate of 44.1 kHz and calls s9xwasm_setup(sampleRate, 0, 0) to set DSP_INTERPOLATION_NONE, reducing APU work on every sample.
const frames = samples.length >> 1;
const ch0 = new Float32Array(frames);
const ch1 = new Float32Array(frames);
for (let i = 0, j = 0; i < samples.length; ) {
ch0[j] = samples[i++] / 32768.0;
ch1[j] = samples[i++] / 32768.0;
j++;
}
const buffer = audioCtx.createBuffer(2, frames, sampleRate);
buffer.copyToChannel(ch0, 0);
buffer.copyToChannel(ch1, 1);
// Cap scheduling queue to prevent audio-latency bloat
const MAX_AUDIO_LATENCY = 0.2; // seconds
if (nextStartTime > now + MAX_AUDIO_LATENCY) {
nextStartTime = now;
} We also added s9xwasm_skip_render to the C API and skip PPU output on catch-up frames. If the JS loop is more than one frame behind, it runs the extra emulated frames without uploading them to the canvas, then renders only the last one. That reduces GPU upload work without affecting the emulated machine state.
LTO and SIMD: measuring the levers
Emscripten has two obvious compile-level levers for speed: LTO and SIMD. We tested both. LTO is on by default because it is a clear win. A 600-frame Super Mario World run in Node went from about 1.0 ms/frame without LTO to about 0.92 ms/frame with LTO. The binary size is unchanged enough to ignore. SIMD (-msimd128 -msse2) was a different story: it added about 30 KB to the .wasm and did not meaningfully improve the SNES9x core for this source tree. The Snes9x core is not written with vectorized inner loops that Emscripten can widen cleanly, so the extra instructions mostly bloat the binary. SIMD is off by default.
| Configuration | Hello World | Super Mario World | Size delta |
|---|---|---|---|
| LTO on, SIMD off (default) | ~0.69 ms/frame | ~0.92 ms/frame | baseline |
| LTO on, SIMD on | ~0.68 ms/frame | ~0.91 ms/frame | +30 KB |
We did not measure a no-LTO build for this post; the table only shows the two builds we actually ran. The difference between LTO+SIMD and LTO alone is inside the noise, while the size increase is not, which is why SIMD stays off by default.
These are Node benchmarks, not browser benchmarks, but they are enough to rule SIMD out as a default. The next real gains will come from architecture, not compiler flags.
What we ship (and what we do not)
The games site (games.catgameresearch.net/snes/) only stages homebrew ROMs from data/sn/roms/snes/ that have a matching sn/roms/<id>/<id>.rom.toml. The oem and test_roms directories are excluded, so Super Mario World and other retail ROMs are never published. The wasm core itself does not contain any Nintendo assets. It is a clean recompile of the open-source Snes9x core plus our own shim.
data/sn/roms/snes/oem/ are retained for local reverse-engineering work and are never published. The WASM core is built from the open-source Snes9x source and our own C++ shim. No copyrighted code, samples, or ROM data are embedded.Architecture: why the browser is not a console
A console has a frame clock. The SNES produces 60.09881 frames per second, and the host has to pace the emulator to that clock. In a browser, the display refresh rate is usually 60 Hz, not 60.09881 Hz, and the audio callback runs on yet another clock. Snes9x solves this on the desktop with Dynamic Rate Control and configurable input rates. On the web we do not have that luxury yet. The current JS loop runs a batch of frames, schedules audio, and lets the browser present when it is ready. That works for 60 FPS but is sensitive to garbage collection, layout thrashing, and background tabs.
AudioContext. The target architecture (bottom) moves emulation to a Web Worker and audio to an AudioWorklet with a shared ring buffer.Roadmap: Worker, AudioWorklet, and the real 60 FPS problem
The current build is fast enough on a good machine. The remaining latency is not the emulator; it is the browser's main thread. The next step is the standard pattern used by emoinua, PCem-WebAssembly, flycast-wasm, and other serious web emulators:
| Capability | Status | Notes |
|---|---|---|
| Project-owned WASM build | implemented | sn-build-wasm.sh builds snes9x.js + snes9x.wasm |
| Audio buffer deinterleave fix | implemented | 44.1 kHzAudioContext, MAX_AUDIO_LATENCY=0.2s |
| LTO default | implemented | ~0.92 ms/frame on SMW in Node |
| SIMD | roadmap | Off by default; +30 KB, no meaningful gain |
| Homebrew-only ROM staging | implemented | sn-wasm.sh skips oem and test_roms |
| Web Worker + AudioWorklet | roadmap | SharedArrayBuffer ring buffer architecture |
| Dynamic rate control | roadmap | Match 60.09881 Hz to display refresh |
"The emulator is not the bottleneck. The browser is the console. Until we treat it like one — with a Worker for the machine and an AudioWorklet for the sound — we are only ever 60 FPS on a good day."
Cross-links
References
- Snes9x issue #530: Snes9x v1.60 for UNIX running too fast
- Emscripten documentation: optimizing code
- CatGame:
scripts/lib/sn/sn-build-wasm.sh - CatGame:
scripts/lib/sn/sn-wasm.sh - CatGame:
www/games/html/snes/js/snes-emu.js - CatGame:
sn/shared/snes_emu_s9x_shim.cpp - Related post: Where Super Mario World Hides Its Samples
