SearchA-ZN › Neo Geo

Neo Geo

1990 Open source · BSD-3-Clause Online

This is the SNK Neo Geo running in your browser: the GEOLITH core (a highly accurate emulator for the Neo Geo AES home console and MVS arcade board) compiled to WebAssembly with Emscripten and self-hosted, no CDN. It boots the Neo Geo system BIOS plus a cartridge in TerraOnion's single-file .NEO format, and plugs into the shared in-frame debugger for the Motorola 68000 main CPU.

Visit the source repository ↗

Visit the official site ↗

Runs on: Web browser

Neo Geo Online Emulator

Play Neo Geo using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
Metal SlugNeo GeoSNK Neo GeogreyOpen ⛶
The King of Fighters '94Neo GeoSNK Neo GeogreyOpen ⛶
Samurai ShodownNeo GeoSNK Neo GeogreyOpen ⛶
Fatal FuryNeo GeoSNK Neo GeogreyOpen ⛶
Puzzle BobbleNeo GeoSNK Neo GeogreyOpen ⛶

Machines emulated

Chips

Notes

Embedding

The Neo Geo core is GEOLITH (Rupert Carmichael), a highly accurate emulator for the Neo Geo AES / MVS, compiled to WebAssembly with Emscripten. GEOLITH normally ships as a libretro core; rather than pull in the whole RetroArch frontend we wrote a tiny standalone Emscripten frontend, neo_wasm.c, that talks to GEOLITH's OWN C API (geo_init / geo_bios_load_mem / geo_neo_load / geo_exec) and exposes it to JavaScript through a handful of flat functions. The module is built with -s MODULARIZE=1 so neogeo.js is a factory you instantiate. We self-host everything (no CDN): the rebuilt neogeo.js / neogeo.wasm, the MVS system BIOS (neogeo.zip), and each game in TerraOnion's single-file .NEO format.

// neogeo.js defines a global Module factory; instantiate it, stage BIOS + game, boot.
var neo = await Module({ locateFile: f => SRC + f });   // finds neogeo.wasm
var bp = neo._wasm_bios_buf(bios.length); neo.HEAPU8.set(bios, bp); // stage neogeo.zip
var gp = neo._wasm_neo_buf(game.length); neo.HEAPU8.set(game, gp); // stage the .NEO
neo._wasm_start();                                    // region+system, load BIOS+cart, reset

Rendering is manual. GEOLITH rasters into a 320×264 XRGB8888 line buffer; the frontend converts the visible 304×224 window to RGBA in the WASM heap. Each frame we blit that buffer to an off-screen canvas and draw it scaled to the visible canvas:

var rgba = new Uint8ClampedArray(neo.HEAPU8.buffer, neo._wasm_fb_ptr(), 304*224*4);
backImg.data.set(rgba); bctx.putImageData(backImg, 0, 0);
ctx.drawImage(back, 0,0, 304,224, 0,0, canvas.width, canvas.height);

Input is the Neo Geo controller latch. Each of the two joystick ports is a byte (Up/Down/Left/Right + A/B/C/D, active low); Start/Select and the coin slots are separate status registers. We keep the pressed bits in JS and push them once per frame; the frontend's registered GEOLITH input callbacks return the active-low bytes the 68000 reads.

MemberKindWhat it does
_wasm_bios_buf(n) / _wasm_neo_buf(n)exportAllocate + return a staging pointer for the BIOS zip and the .NEO cartridge.
_wasm_start()exportSet region US / system MVS, geo_init, load BIOS + cart, wire input, geo_reset.
_wasm_tick()exportRun exactly one video frame (geo_exec) and convert it to RGBA. Our frame-step and the run loop's advance.
_wasm_fb_ptr()exportPointer to the 304×224 RGBA framebuffer in the heap.
_wasm_set_pad(port,bits)exportWrite a joystick latch (bit0 Up … bit7 D, pressed=1).
_wasm_set_sys(v) / _wasm_set_coin(v)exportStart/Select bits; coin-slot bits (needed to credit an MVS game).
_wasm_dbg_*exportThe debug sampling API (registers, memory, step, reset) — see below.
_wasm_vw() / _wasm_vh()exportVisible picture size (304×224).

Debugger integration

This is a WebAssembly core, yet it gets the same live debugger as the pure-JavaScript machines — real registers, real memory, a real single-instruction step, plus execution breakpoints and memory watchpoints. GEOLITH's 68000 is Musashi, whose register file is reachable through m68k_get_reg / m68k_set_reg, and whose m68k_execute(1) runs exactly one instruction. We expose these through a small sampling API from our standalone frontend.

1 · Exactly what was added (the only new source). A single new file, neo_wasm.c, is the whole frontend: it drives the core (geo_init / geo_bios_load_mem / geo_neo_load / geo_exec) and appends a block of EMSCRIPTEN_KEEPALIVE functions that read live state through Musashi's own API. They are sampling functions: the debugger calls them ~10×/second while its window is open, and once per Step. There is no per-instruction or per-cycle callback anywhere — the emulator hot path (geo_exec / m68k_execute) is byte-for-byte unchanged (the golden performance rule). A second tiny file, cd_stubs.c, no-ops the Neo Geo CD symbols so the cartridge-only build skips the whole CD stack.

// neo_wasm.c — sampling only, no hot-path hook
u32  wasm_dbg_reg(int i){                      // 0-7 = D0-D7, 8-15 = A0-A7
  return i<8 ? m68k_get_reg(NULL, M68K_REG_D0+i)
            : m68k_get_reg(NULL, M68K_REG_A0+(i-8)); }
u32  wasm_dbg_pc(){ return m68k_get_reg(NULL, M68K_REG_PC); }
u32  wasm_dbg_sr(){ return m68k_get_reg(NULL, M68K_REG_SR) & 0xffff; }
u32  wasm_dbg_read(u32 a){                    // side-effect-free 68000 bus read
  romdata_t *rd = geo_romdata_ptr();
  if (a < 0x100000) return rd->p[a];        // P ROM
  if (a < 0x110000) return ram[a & 0xffff];  // 68K work RAM
  ... }                                       // BIOS / NVRAM windows, else 0
void wasm_dbg_step(){ geo_m68k_run(1); }        // REAL one-instruction step = m68k_execute(1)

2 · What is REAL here. Because Musashi keeps the 68000 register file in a plain C context that m68k_get_reg exposes, this build has:

  • Real registers — D0-D7, A0-A7, PC, SR (with X N Z V C S flags) and USP/SSP, read and written live off Musashi's register file.
  • Real single-instruction stepStep i calls wasm_dbg_step() = geo_m68k_run(1) = m68k_execute(1), which executes exactly one 68000 instruction; PC and the registers change by one instruction per click.
  • Side-effect-free memory — reads resolve the cartridge P ROM, the 68K work RAM, the system BIOS and NVRAM directly (returning 0 for I-O windows), so auto-polling the hex/disasm view never acknowledges an interrupt or clears a latch.
  • Execution breakpoints & memory watchpoints — implemented host-side in the run loop (see below), so they cost nothing when the debugger is closed.

3 · Breakpoints and watchpoints without a hot-path hook. When no breakpoint or watchpoint is set, the loop runs a whole frame at native speed with _wasm_tick() (geo_exec). As soon as one is armed, the loop switches to stepping the 68000 one instruction at a time with wasm_dbg_step(), comparing the live PC against the breakpoint set and the watched addresses against their last sampled value, and pausing on a hit. This is a pure host-side check that only runs while a guard is armed — the C core is never modified. (While single-stepping under an armed breakpoint the Z80/video do not advance in lockstep with the 68000, so the picture holds on its last frame until you resume.)

4 · The controllable loop. We own the frame loop so the transport can pause/step. While running, each animation frame writes the input latch, advances one frame with _wasm_tick(), and blits. Pause stops the loop; Resume restarts it; Step (frame) runs one _wasm_tick(); Step i (instruction) calls wasm_dbg_step(); Reset calls wasm_reset() = geo_reset(1).

Architecture

The Neo Geo (SNK, 1990) shipped as the MVS arcade board and the identical AES home console — the most powerful 2D hardware of its era, with a huge 24-bit sprite engine. GEOLITH emulates the whole machine at instruction-level granularity; our build is a thin standalone Emscripten port of its core. The whole machine lives in one WASM module built from C:

  • Musashi 68000 — the Motorola 68000 main CPU (src/m68k) at ~12 MHz. Its register context is what this integration samples via m68k_get_reg; m68k_execute(1) (wrapped by GEOLITH's geo_m68k_run) is the run-one-instruction primitive we use for single-stepping.
  • Zilog Z80 — the sound CPU (src/z80), which drives the sound chip and answers the 68000 over a command/reply latch.
  • YM2610 (OPNB) — the FM + SSG + ADPCM-A/B sound chip, emulated by ymfm (Aaron Giles).
  • LSPC — the Neo Geo line-sprite/video controller (geo_lspc.c): up to 381 sprites of 16×(16..512), a fix layer, and a 4096-colour-from-65536 palette, composited into a 320×224 picture.
  • Memory mapgeo_m68k.c builds the 68000 bus: P ROM at $000000 (with a banked window at $200000), 64 KB work RAM at 00000, the palette / video / I-O registers, the system BIOS at $C00000, and NVRAM at $D00000. Cartridges load from TerraOnion's single-file .NEO container, which packs the P / S / M1 / V / C ROMs unencrypted but unpatched, so GEOLITH still emulates the real protection/bankswitch chips.

How to build this exact artefact. Toolchain: Homebrew emscripten 6.0.3 (emcc on PATH).

git clone https://github.com/libretro/geolith-libretro.git geolith
# 1. add neo_wasm.c (core-API frontend + wasm_dbg_* hooks) and cd_stubs.c
# 2. compile the core (src/geo*.c, src/m68k, src/z80, src/ymfm, deps/miniz,
#    deps/speex) + the shim, MVS cartridge only (CD subsystem stubbed):
emcc -O2 -Igeolith/src -Igeolith/deps/miniz -c <core .c files> neo_wasm.c cd_stubs.c
emcc -O2 *.o -o neogeo.js -sMODULARIZE=1 -sEXPORT_NAME=Module      -sALLOW_MEMORY_GROWTH=1 -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8,HEAPU32      -sEXPORTED_FUNCTIONS=_wasm_start,_wasm_tick,_wasm_dbg_pc,...   # -> neogeo.js + .wasm

The build is single-threaded (no SharedArrayBuffer), so it hosts anywhere. Musashi and the Z80/ymfm cores all run in plain C; there is no dynarec, so nothing needs writable-executable memory.

Sound

Pattern: V-stub — the vendored core already emulates the sound chip, but this Emscripten frontend was discarding its samples, so we route them to the shared WebAudio sink (/debugger/src/audio.js, window.EmuAudio) rather than give the core its own AudioContext. The sound chip is the YM2610 (OPNB) — four FM channels, a three-voice SSG, and ADPCM-A/B PCM — emulated by ymfm (Aaron Giles) and clocked in lock-step with the Z80 inside geo_exec(). Each video frame GEOLITH's mixer (geo_mixer.c) hands the frame's YM2610 output to a callback; the stock frontend registered a callback that threw the samples away (and forced the mixer into raw mode), which is why the machine ran silent even though the chip was fully emulated.

The hook. The mixer already contains a Speex resampler that converts the YM2610's native ~56 kHz stream to any target rate. We now drive it at the WebAudio context rate: before boot the boot script reads EmuAudio.sampleRate and calls _wasm_set_audio_rate(), and wasm_start() sets geo_mixer_set_rate(rate) then geo_mixer_init() (leaving the resampling path in place instead of calling geo_mixer_set_raw(1)). The audio callback records how many interleaved-stereo Int16 samples the resampler wrote into a static buffer, and three tiny EMSCRIPTEN_KEEPALIVE exports surface it to JS:

static void audio_cb(size_t nsamps){ g_audio_ready = nsamps; }   // mixer -> us, per frame
void wasm_set_audio_rate(int r){ g_audio_rate = r; }              // = EmuAudio.sampleRate
int  wasm_audio_ptr(){ return (int)abuf; }                        // interleaved L,R,L,R... Int16
int  wasm_audio_samps(){ return (int)g_audio_ready; }              // count produced this frame

Sample rate / pitch. Because the mixer resamples to EmuAudio.sampleRate, pitch is correct with no JS-side resampling. Right after each _wasm_tick() the loop reads the wasm buffer and pushes exactly Math.round(EmuAudio.sampleRate/60) interleaved-stereo Int16 pairs (1600 Int16 at 48 kHz) to EmuAudio.push():

var got = neo._wasm_audio_samps();
var src = new Int16Array(neo.HEAPU8.buffer, neo._wasm_audio_ptr(), got);
// copy the frame into a fixed sampleRate/60 buffer (pad a short frame with silence)
EmuAudio.push(audioOut);

Mute contract. The transport exposes the standard isMuted() / setMute(m), delegating straight to EmuAudio. Audio starts muted (browsers block sound before a gesture, and EmuAudio itself boots muted with its AudioContext suspended); the shell's Sound button unmutes from a real click, which resumes the context and opens the gain. The samples flow every frame regardless, but EmuAudio.push() ignores them while muted, so re-muting silences the machine instantly.

Caveat. The Neo Geo runs at ~59.6 fps but the run loop is driven at 60 fps, so the resampler emits a few more pairs per frame than sampleRate/60; we keep exactly sampleRate/60 and let the sink's ring buffer absorb the <1 % difference. The audible content is whatever the running cartridge plays — the BIOS boot chime and each game's attract-mode music — so unmuting at the NEO·GEO boot logo plays the famous startup chime.