SearchA-ZC › Capcom CPS-1 Classics

Capcom CPS-1 Classics

1988 Arcade Open source · Non-commercial (FBNeo) Online

Four defining action games from Capcom's CP System (CPS-1) arcade board (1988), running in your browser: Ghouls'n Ghosts (the punishing run-and-jump platformer), Strider (the acrobatic ninja classic), Willow (the action-RPG of the film) and U.N. Squadron (the horizontal shoot-'em-up). The core is FinalBurn Neo compiled to WebAssembly with Emscripten and self-hosted, no CDN — the same CPS-1 build as our Street Fighter II page. A standalone frontend drives FBNeo's own burn-library API and boots a standard romset straight to attract mode, 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

Capcom CPS-1 Classics Online Emulator

Play Capcom CPS-1 Classics using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
Ghouls'n GhostsCapcom CPS-1 ClassicsCPS-1greyOpen ⛶
StriderCapcom CPS-1 ClassicsCPS-1greyOpen ⛶
WillowCapcom CPS-1 ClassicsCPS-1greyOpen ⛶
U.N. SquadronCapcom CPS-1 ClassicsCPS-1greyOpen ⛶

Machine emulated

Chips

Notes

Embedding

These are Capcom CP System (CPS-1) arcade games running on FinalBurn Neo (FBNeo) — a mature, accurate arcade emulator whose Capcom driver runs the whole CPS-1 library — compiled to WebAssembly with Emscripten. FBNeo normally ships as a libretro core or an SDL app; rather than pull in either frontend we wrote a tiny standalone Emscripten frontend, cps_wasm.cpp, that talks to FBNeo's OWN C++ burn-library API (BurnLibInit / BurnDrvSelect / BurnDrvInit / BurnDrvFrame) and exposes it to JavaScript through a handful of flat functions. The module is built with -s MODULARIZE=1 so cps1.js is a factory you instantiate. We self-host everything (no CDN): the rebuilt cps1.js / cps1.wasm and each game as a standard FBNeo romset ZIP. This emulator shares the exact same CPS-1 core as our sf2 (Street Fighter II) page; the games differ only in which romset ZIP is staged.

// cps1.js defines a global Module factory; instantiate it, stage game name + romset, boot.
var cps = await Module({ locateFile: f => SRC + f });   // finds cps1.wasm
var np = cps._wasm_game_buf(name.length); cps.HEAPU8.set(name, np); // "ghouls"
var rp = cps._wasm_rom_buf(rom.length); cps.HEAPU8.set(rom, rp);   // stage the romset ZIP
cps._wasm_start();                                    // select driver, load ROMs, BurnDrvInit, reset

Rendering is manual. FBNeo rasters into its own bitmap (pBurnDraw); the frontend converts the visible 384×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(cps.HEAPU8.buffer, cps._wasm_fb_ptr(), 384*224*4);
backImg.data.set(rgba); bctx.putImageData(backImg, 0, 0);
ctx.drawImage(back, 0,0, 384,224, 0,0, canvas.width, canvas.height);

Input is the CPS-1 player latch. These action games use an 8-way stick plus up to three action buttons (jump / fire / weapon, per game), with Start / Coin as separate service bits. We keep the pressed bits in JS and push them once per frame; the frontend maps them onto FBNeo's per-input pVal bytes (found by name through BurnDrvGetInputInfo) that the 68000 reads. The core's name matcher accepts both the fighting-game names and the generic P1 Button 1..6 names, so the one build drives Street Fighter II and these action titles alike.

MemberKindWhat it does
_wasm_game_buf(n) / _wasm_rom_buf(n)exportAllocate + return a staging pointer for the driver short-name and the romset ZIP.
_wasm_start()exportSelect the CPS-1 driver by name, install a zip-backed BurnExtLoadRom, BurnDrvInit, wire input, reset.
_wasm_tick()exportRun exactly one video frame (BurnDrvFrame) and convert it to RGBA. Our frame-step and the run loop's advance.
_wasm_fb_ptr()exportPointer to the 384×224 RGBA framebuffer in the heap.
_wasm_set_input(player,bits)exportWrite a player latch (bit0 Up … bit6 Button 3, pressed=1).
_wasm_set_sys(v)exportStart / Coin / Service bits (needed to credit the game).
_wasm_dbg_*exportThe debug sampling API (registers, memory, step, reset) — see below.
_wasm_vw() / _wasm_vh()exportVisible picture size (384×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. FBNeo'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, cps_wasm.cpp, is the whole frontend: it drives the burn library (BurnLibInit / BurnDrvSelect / BurnDrvInit / BurnDrvFrame) 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 (BurnDrvFrame / m68k_execute) is byte-for-byte unchanged (the golden performance rule).

// cps_wasm.cpp — 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
  if (a < CpsRomLen) return CpsRom[a];        // 68K program ROM
  if ((a & 0xff0000) == 0xff0000) return CpsRam[a & 0xffff]; // 68K work RAM
  return 0; }                                // I/O windows read 0
void wasm_dbg_step(){ m68k_execute(1); }          // REAL one-instruction step

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() = 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 68K program ROM and the 64 KB work RAM at $FF0000 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() (BurnDrvFrame). 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().

Architecture

Capcom's CP System (CPS-1) debuted in 1988 and hosted a run of landmark arcade games. This page gathers four of the board's defining action titles: Ghouls'n Ghosts (1988, the punishing run-and-jump platformer and one of the first CPS-1 games), Strider (1989, the acrobatic ninja arcade classic), Willow (1989, the action-RPG take on the film) and U.N. Squadron / Area 88 (1989, the horizontal shoot-'em-up). FBNeo emulates the whole board; our build is a thin standalone Emscripten port of its capcom driver. The whole machine lives in one WASM module built from C/C++:

  • Musashi 68000 — the Motorola 68000 main CPU at 10 MHz. Its register context is what this integration samples via m68k_get_reg; m68k_execute(1) is the run-one-instruction primitive we use for single-stepping.
  • Zilog Z80 — the sound CPU at ~3.58 MHz, which drives the sound chips and answers the 68000 over a command latch.
  • YM2151 + OKI MSM6295 — the FM synthesiser and the ADPCM sample player that together make the CPS-1 soundtrack and voices.
  • CPS-A / CPS-B — Capcom's custom video chips: a scroll1 (8×8 text), scroll2 (16×16) and scroll3 (32×32) tile layer plus a 16×16 sprite/object layer, composited with a 4096-colour palette into a 384×224 picture.
  • Memory map — the 68000 bus places the program ROM at $000000, the CPS I/O and video registers in the $800000 region, and 64 KB of work RAM at $FF0000. Games load from a standard FBNeo/MAME romset ZIP that packs the program, graphics, and audio ROMs.

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

git clone https://github.com/finalburnneo/FBNeo.git fbneo
# 1. generate m68kops.c (m68kmake), ctv.h (ctv_make), a capcom-only driverlist.h (gamelist.pl)
# 2. add cps_wasm.cpp (burn-library frontend + wasm_dbg_* hooks) + burner_stubs.cpp
# 3. compile the burn core + capcom driver + Musashi 68000 + Z80 + YM2151/MSM6295 + the shim:
emcc -O2 <burn core + capcom + cpu + snd .c/.cpp> cps_wasm.cpp -o cps1.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,...   // -> cps1.js + .wasm

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

Sound

Pattern: V-stub. FBNeo already emulates the CPS-1 sound hardware — the Yamaha YM2151 (OPM 8-channel FM) and the OKI MSM6295 (4-channel ADPCM) — inside the WASM; the mixed output was simply never handed to the browser. There is no separate AudioContext in the core (this is a standalone Emscripten frontend with no SDL), so V-native does not apply. We route FBNeo's own per-frame sample buffer into the shared EmuAudio sink. This is the same rebuilt CPS-1 core the sf2 page uses — the audio exports were added to the one shared build, so both emulators get sound from a single core.

The hook. FBNeo renders sound during BurnDrvFrame() into pBurnSoundOut as interleaved-stereo INT16 (L,R,L,R…). Our frontend points that at a static buffer and exposes it to JavaScript, so each frame the boot script reads the buffer and pushes it straight to the sink:

function pushAudio(){                          // called right after every _wasm_tick()
  var n = cps._wasm_snd_len();               // stereo frames FBNeo rendered this frame
  var buf = new Int16Array(cps.HEAPU8.buffer, cps._wasm_snd_ptr(), n*2);
  EmuAudio.push(buf);                          // interleaved L,R,L,R… Int16
}

Sample rate / pitch. The single new-source change is small: the frontend now sets nBurnSoundRate and nBurnSoundLen before BurnDrvInit() (so the chips initialise at the right rate) and adds three exports — wasm_set_srate, wasm_snd_ptr, wasm_snd_len. The boot script calls _wasm_set_srate(EmuAudio.sampleRate) before _wasm_start(), so the chips run at the AudioContext rate and no JS resampling is needed. We push exactly Math.round(EmuAudio.sampleRate/60) stereo frames per video frame; 44100 and 48000 both divide evenly by 60 (735 / 800), so the stream stays in sync with no drift.

Mute contract. window.EMU_BOOT.transport exposes isMuted() / setMute(m), which delegate to EmuAudio; the shell's Sound/Mute button drives them. Audio starts muted (browsers block sound before a gesture), and the first unmute — a real user click — resumes the shared AudioContext. While muted, EmuAudio.push returns immediately, so the audio path costs nothing until you turn it on.

Verify against attract mode. The rebuilt core also applies FBNeo's DIP defaults, so Demo Sound is ON: each of these CPS-1 games plays its attract-mode music, and the default boot (Ghouls'n Ghosts) is audible on its own once unmuted — no coin needed. The audio feed lives inside the debugger's owned run loop (right after _wasm_tick()), so pause/step/breakpoints all stay in lockstep with the sound.