Files
vpinball-wasm/package/src/index.ts
T
valknarandClaude Sonnet 5 9479cea280 Add real PinMAME (VPinMAME.Controller) integration for ROM-based tables
Statically links the real PinMAME emulation core (libpinmame) instead of
leaving VPinMAME.Controller creation to fail, which was crashing table
scripts on ROM-based tables before they could spawn a ball. PinMAME's own
run_game()->cpu_run() loop is split into a one-shot init, a per-frame step,
and a one-shot teardown (patches/pinmame/0004) so it runs cooperatively on
the same frame callback as vpinball's own loop instead of on a real
std::thread, which hard-aborts under Emscripten's single-threaded runtime -
three smaller wasm32 portability fixes to libpinmame itself round out the
patch set (0001-0003). Adds pinball.loadRom() to supply a ROM zip, written
to the table-relative pinmame/roms/ path vpinball's own plugin already
checks. Confirmed against a real community ROM-based table: Controller
creation and game identification succeed, and a missing ROM now fails
cleanly instead of crashing the page - actual ROM-driven gameplay is still
unconfirmed since no ROM was available (or sought out) to test with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
2026-08-23 12:09:35 +02:00

138 lines
4.9 KiB
TypeScript

import type { LoadPinballOptions, PinballInstance } from './types.js';
export type { LoadPinballOptions, PinballInstance } from './types.js';
export { attachTouchControls } from './touch-controls.js';
export type { TouchControlsOptions, TouchControlsHandle } from './touch-controls.js';
const DEFAULT_TABLE_PATH = '/tables/default.vpx';
const UPLOADED_TABLE_PATH = '/tables/uploaded.vpx';
/**
* Instantiates the WebAssembly build of Visual Pinball against the given
* canvas and returns a handle to control its lifecycle.
*/
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
// Resolved to an absolute URL against the *page's* location up front,
// rather than used as-is: a bare relative baseUrl works for
// Module.locateFile and wireDownloadProgress's fetch() (both resolve
// relative to the page), but not for the dynamic import() below, whose
// relative-specifier resolution is against *this module's own* URL
// (dist/index.js) instead - the two disagree for any page location that
// doesn't happen to cancel the difference out (as examples/basic/'s
// '../../dist' accidentally does). Resolving once here, to an absolute
// URL, makes every use of it agree.
const baseUrl = new URL(options.baseUrl ?? '.', document.baseURI).href.replace(/\/$/, '');
let tablePath = DEFAULT_TABLE_PATH;
const moduleArgs: Record<string, unknown> = {
canvas: options.canvas,
locateFile: (path: string) => `${baseUrl}/${path}`,
};
if (options.onProgress) {
await wireDownloadProgress(moduleArgs, baseUrl, options.onProgress);
}
// dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
// so importing it yields a factory function, not a module with side effects.
const factory = (await import(/* @vite-ignore */ `${baseUrl}/vpinball.js`)).default as (
moduleArgs: Record<string, unknown>
) => Promise<EmscriptenModule>;
const module = await factory(moduleArgs);
options.onProgress?.(1);
if (options.tableData) {
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
tablePath = UPLOADED_TABLE_PATH;
}
return {
loadTable(vpxBytes: ArrayBuffer) {
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
tablePath = UPLOADED_TABLE_PATH;
},
loadRom(gameName: string, romZipBytes: ArrayBuffer) {
module.FS.mkdirTree(PINMAME_ROMS_DIR);
module.FS.writeFile(`${PINMAME_ROMS_DIR}/${gameName}.zip`, new Uint8Array(romZipBytes));
},
start() {
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
},
stop() {
module.ccall?.('vpinball_wasm_stop', null, [], []);
},
dispose() {
module.ccall?.('vpinball_wasm_dispose', null, [], []);
},
requestFullscreen() {
return options.canvas.requestFullscreen();
},
evalScript(script: string) {
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
},
};
}
/**
* Best-effort byte-level download progress: manually fetches vpinball.wasm
* with a streaming reader (assigning the result to Module.wasmBinary so the
* factory doesn't re-fetch it), weighted against Module's own coarse
* "Downloading data..." status callback for the preloaded asset package.
* Emscripten's own dependency counter (monitorRunDependencies) doesn't give
* byte-level granularity, hence fetching the .wasm by hand instead.
*/
async function wireDownloadProgress(
moduleArgs: Record<string, unknown>,
baseUrl: string,
onProgress: (fraction: number) => void
): Promise<void> {
// Roughly half the total download is the wasm binary, half the data
// package - this is an estimate (see README's size-tuning roadmap item),
// not a guarantee, so progress may jump at the wasm/data boundary.
const WASM_WEIGHT = 0.5;
try {
const response = await fetch(`${baseUrl}/vpinball.wasm`);
const total = Number(response.headers.get('Content-Length') ?? 0);
const reader = response.body?.getReader();
if (!reader || !total) {
return;
}
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress((received / total) * WASM_WEIGHT);
}
const wasmBinary = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) {
wasmBinary.set(chunk, offset);
offset += chunk.length;
}
moduleArgs.wasmBinary = wasmBinary;
} catch {
// Streaming progress is best-effort; fall through to the factory's
// own default fetch if this fails for any reason (e.g. no CORS
// Content-Length exposed, older browser).
}
moduleArgs.setStatus = (text: string) => {
if (text) onProgress(WASM_WEIGHT + (1 - WASM_WEIGHT) * 0.5);
};
}
interface EmscriptenModule {
FS: {
writeFile(path: string, data: Uint8Array): void;
mkdirTree(path: string): void;
};
ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown;
}
const PINMAME_ROMS_DIR = '/tables/pinmame/roms';