romdev-core-runner
v0.2.18
Published
Play a ROM you just built: an SDL window over the romdev libretro host (romdev-core-host) with keyboard + hot-plug gamepad input, audio, and aspect-correct pixel-perfect scaling. The human 'fire it up' tier between an external emulator and the romdev MCP
Readme
romdev-core-runner
Play the ROM you just built: one call opens a real SDL window running any
romdev-core-* emulator core, with keyboard + hot-plug gamepad input,
audio at the core's native rate, and aspect-correct pixel-perfect scaling.
import { runRom } from "romdev-core-runner";
import * as core from "romdev-core-fceumm"; // any romdev-core-* package
const session = await runRom("game.nes", { core });
await session.closed; // resolves when the human closes the windowrom is a file path or raw Uint8Array bytes. No cores are bundled; the
caller passes the core package (or a { jsPath, wasmPath } pair). This is
the same SDL host the romdev MCP
server's playtest window is built on (the server layers agent-tier extras
on top). One host implementation in the whole ecosystem.
Options
runRom(rom, {
core, // REQUIRED: romdev-core-* package namespace or { jsPath, wasmPath }
platform, // platform id; inferred from the core package when possible
scale: 3, // initial window size, in multiples of the framebuffer
title, // window title (default: ROM basename + platform)
aspect: "tv",// "tv" (real-hardware display shape) | "fb" (raw framebuffer)
// | "core" (core-reported ratio)
buttonMap, // SDL button name -> RetroPad bit, replaces the default map
keyMap, // keyboard key -> RetroPad bit, replaces the default map
log, // (msg) => void progress/warning logger (default: silent)
})buttonMap / keyMap exist for platforms with custom layouts (GameTank's
two-button pad, C64 joystick + keyboard); the defaults cover the standard
console pads.
The session object
runRom resolves once the window is up:
const { stop, closed, host, frameCount, running } = session;closedresolves when the window closes (ESC, close button, or Select+Start on the pad).stop()closes it programmatically.hostis the liveromdev-core-hostLibretroHostinstance, so while the human plays you can read/write memory, take screenshots, save states, or set cheats from the same process.
Controls
Gamepads hot-plug (connect/disconnect any time). Face buttons follow physical position: SDL bottom = RetroPad B (main action), right = A, left = Y, top = X. Analog left stick doubles as the d-pad past a deadzone.
Keyboard fallback: arrows = d-pad, Z/X/A/S = the same four face
positions, Enter = Start, Right-Shift (or Backspace) = Select, Q/W =
L/R shoulders. ESC closes the window.
The gamepad keeps working when the window is not focused (initSdl() sets
SDL's joystick background-events hint, so you can watch a terminal while
playing; export SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=0 to restore the
focus-gated default). The keyboard fallback still requires focus - keys
route to the focused window.
The title bar shows live fps (<game> | 60 fps, updated once a second) -
the rate the machine actually achieves, not the target.
SDL availability
@kmamal/sdl is an optionalDependency, declared once here so consuming
CLIs and SDKs never carry it themselves. When its native binary is missing,
runRom first re-runs SDL's own install script (self-repair), and only
then throws a structured error, never a hard module-load crash:
try {
await runRom("game.nes", { core });
} catch (e) {
if (e.code === "SDL_UNAVAILABLE") console.error(e.fixCmd); // print, don't crash
}so a headless environment (CI, a container) can import this package safely and print its own "install SDL or use an external emulator" message.
Building a custom frontend
The presentation layer is exported for frontends that want the maps and
math without the stock window: SDL_BUTTON_TO_LIBRETRO_BIT,
KEY_TO_LIBRETRO_BIT, STICK_DEADZONE, normAxis(value) (any node-sdl
axis unit → −1..1), makeTriggerState() + deriveTriggerState(axes, state)
(analog triggers → L2/R2 bits + 0..1 pressure, baseline-relative with
hysteresis so an X360 trigger that idles mid-scale neither sticks nor
chatters - use this instead of a raw threshold so every window agrees on
"pressed"; TRIGGER_PRESS/TRIGGER_RELEASE are the constants),
bitToName(bit),
tvAspectFor(platform, displayAspect),
effectiveAspect(statusAspect, fbW, fbH) (a usable ratio even when the
host reports 0/NaN), initialWindowSize({fbWidth, fbHeight, scale,
aspectMode, platform, displayAspect}) (the tested open-size math - never
produces a zero-size window), letterbox(winW, winH, aspect),
framebufferToRgba(fb, out?) (pass the previous return value as out to
reuse the buffer across ticks - no per-frame allocation), and
drawFpsOverlay(rgba, width, height, fps) (the corner fps counter, pure
pixel writes), plus initSdl() (the hardened loader) and
sdlPackageRoot().
Requirements
Node >= 24, ESM only. Software-rendered cores work everywhere SDL does;
the 3D cores additionally want the native-gles optional dependency (see
the romdev-core-host README).
