Phase E/I: expose evalScript() as a permanent debug API, finalize README

Promotes vpinball_wasm_eval_script from a one-off validation hack to a
supported PinballInstance.evalScript() method (runs arbitrary VBScript
against the live table via the interpreter's own debug-console entry
point) - useful standalone, and it's what confirms the DMD script API
rides the same execution path already proven by real flipper input.

Rewrites the README's Status/Roadmap/Known limitations to reflect
tonight's actual, hands-on-confirmed state: keyboard input, audio, and
VBScript-driven gameplay all verified end-to-end (not just code
review); two more real engine bugs found and fixed (GL back buffer
sized in logical instead of device pixels; back buffer never resynced
on resize, breaking fullscreen); the default table swap and why;
B2S backglass explicitly assessed and declined for now, with the
concrete reasons (no plugin subsystem wired for this build, no test
table to validate against) rather than left as a vague TODO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:14:49 +02:00
co-authored by Claude Sonnet 5
parent b8bf9f1510
commit 4638c06b68
5 changed files with 64 additions and 25 deletions
+35 -23
View File
@@ -4,19 +4,22 @@
This project ports [Visual Pinball](https://github.com/vpinball/vpinball) (the C++ pinball table simulator) to WebAssembly via Emscripten. It is a source-level port of the actual engine — not a reimplementation — so table compatibility, physics behavior, and scripting semantics come from the real codebase real tables already run against today.
## Status: the engine plays a real table, live, in the browser
## Status: a real, playable table, live in the browser — keyboard and audio confirmed
The patched engine, running in Chrome via this project's build, renders a real, interactive, physically-simulated pinball table from a real `.vpx` file — lit flippers, a ball, lane guides, wood-grain playfield, all via WebGL2 — driven by a real per-frame game loop, not a static screenshot. Concretely, verified end-to-end:
The patched engine, running in a real browser via this project's build, renders and plays a real, interactive, physically-simulated pinball table from a real `.vpx` file — lit flippers that respond to Left/Right Shift, slingshots, bumpers, targets, a plunger, drain detection, and audible sound effects — all driven by a real per-frame game loop, not a static screenshot. This has been confirmed by hands-on manual testing in Chrome, not just code review. Concretely, verified end-to-end:
- Loads a real `.vpx` file and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (80 shaders across UI/Basic/Ball/DMD/Flasher/Light/Framebuffer) against WebGL2/GLES3, computes environment map radiance (HDR/IBL), and runs the static pre-render pass.
- Initializes the real physics engine and the real VBScript scripting engine (`libwinevbs`).
- Runs a real per-frame game loop (`Player::EmscriptenStepFrame()`, driven by `emscripten_set_main_loop`) stepping input, physics, and rendering every frame — verified by observing the frame counter advance and by a clean `stop()``~Player()` teardown mid-session, with no crash or hang.
- Ships a trimmed ~11MB asset payload (down from an initial ~51MB — see [Roadmap](#roadmap)).
- Runs the real physics engine and the real VBScript scripting engine (`libwinevbs`) — confirmed by pressing a physical key and watching a table-script-driven flipper (`Table1_KeyDown``LeftFlipper.RotateToEnd`) actually move.
- Runs a real per-frame game loop (`Player::EmscriptenStepFrame()`, driven by `emscripten_set_main_loop`) stepping input, physics, and rendering every frame, with a clean `stop()``~Player()` teardown mid-session and no crash or hang.
- **Keyboard input and audio, confirmed by ear and by hand** against the bundled default table (see below) — the full DOM → SDL3 → `InputManager` → VBScript-dispatch pipeline, and the full audio pipeline, both work.
- Ships the bundled default table (`exampleTable.vpx`, a real playable demo table with working gameplay logic) at ~34MB total (`vpinball.wasm` + `vpinball.data` + `vpinball.js`) after trimming ~52MB of editor-only content never used by the player runtime (bundled native-editor templates and a Monaco code editor) — see [Roadmap](#roadmap).
Getting here required finding and fixing two real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see `patches/vpinball/0003-*` and `0004-*` for the exact fixes:
Getting here required finding and fixing four real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see `patches/vpinball/0003-*`, `0004-*` and `0005-*` for the exact fixes:
1. `RenderDevice::WaitForVSync()` unconditionally spawned a real `std::thread` every frame, even on `__STANDALONE__` builds — a hard crash under Emscripten's single-threaded runtime, independent of and encountered before the main-loop-yielding problem below.
2. The desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop`) is a blocking native `while` loop with manual `uSleep` throttling — incompatible with a single-threaded WASM main thread, which must yield control back to the browser every frame. This project adds a new `Player::EmscriptenStepFrame()` (one frame, no internal loop) driven by `emscripten_set_main_loop`, plus new JS-callable lifecycle entry points (`vpinball_wasm_start`/`stop`/`dispose` in `src/core/EmscriptenBridge.cpp`) that don't route through the desktop `main()`/`WinMain()` chain at all, since that chain assumes the whole process runs exactly one table to completion then exits.
2. The desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop`) is a blocking native `while` loop with manual `uSleep` throttling — incompatible with a single-threaded WASM main thread, which must yield control back to the browser every frame. This project adds a new `Player::EmscriptenStepFrame()` (one frame, no internal loop) driven by `emscripten_set_main_loop`, plus new JS-callable lifecycle entry points (`vpinball_wasm_start`/`stop`/`dispose`/`eval_script` in `src/core/EmscriptenBridge.cpp`) that don't route through the desktop `main()`/`WinMain()` chain at all, since that chain assumes the whole process runs exactly one table to completion then exits.
3. The window's OpenGL back buffer was created with the window's *logical* (CSS) pixel size instead of its *device* pixel size — on any browser tab with `devicePixelRatio != 1` (essentially all HiDPI displays), the GL viewport only covered a fraction of the canvas's actual backing buffer, rendering anchored to one corner (GL's viewport origin) instead of filling the canvas.
4. `Window::OnResized()` (called on every SDL resize event, including entering/leaving browser fullscreen) updated the window's own tracked pixel size but never propagated it to the window's back buffer render target — so toggling fullscreen resized the canvas but rendering stayed pinned to the pre-resize viewport size.
## Why a source-level port, not a reimplementation
@@ -55,32 +58,36 @@ Full research and two isolated feasibility spikes (proving real VBScript executi
- Real VBScript execution under wasm32 (libwinevbs).
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
**Proven working (in this project's own build, in a real browser):**
**Proven working (in this project's own build, in a real browser, by hands-on manual testing):**
- `.vpx` file loading and parsing; SDL3 audio/video/window initialization.
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2; environment map/HDR radiance computation; static pre-render pass.
- Physics engine initialization and the VBScript engine starting up.
- **A real, running per-frame game loop** (`emscripten_set_main_loop`-driven), with clean JS-controllable `start()`/`stop()` lifecycle and normal C++ teardown (`~Player()`) on stop — see [Status](#status-the-engine-plays-a-real-table-live-in-the-browser) above.
- A trimmed ~11MB asset payload (~78% smaller than the initial unoptimized ~51MB), by excluding editor-only bundled example tables and a Monaco code editor never used by the player runtime — see `patches/vpinball/0001-*`.
- **The full input pipeline**: a real physical keypress → SDL3 → `InputManager::PushButtonEvent``PinTable::FireDispID(DISPID_GameEvents_KeyDown)` → the table's own VBScript `KeyDown` handler → `LeftFlipper.RotateToEnd` — confirmed by watching a flipper actually respond to Left/Right Shift.
- **Audio**, confirmed audible by ear.
- **A real, running per-frame game loop** (`emscripten_set_main_loop`-driven), with clean JS-controllable `start()`/`stop()` lifecycle and normal C++ teardown (`~Player()`) on stop — see [Status](#status-a-real-playable-table-live-in-the-browser--keyboard-and-audio-confirmed) above.
- **Correct rendering at any canvas size/DPI, including entering/leaving fullscreen** — two real GL back-buffer sizing bugs found and fixed (see Status).
- A bundled default table (`exampleTable.vpx`) with real, working gameplay logic (flippers, slingshots, bumpers, targets, a plunger, drain detection) — swapped in from an earlier choice (`test000-default-table.vpx`) that turned out to be upstream's own rendering/component regression-test fixture with no gameplay script at all (confirmed by diffing a screenshot against vpinball's own official reference image for that table).
- Editor-only content (bundled native-editor example/template tables, a Monaco code editor) excluded from the asset payload — never used by the player runtime — see `patches/vpinball/0001-*`.
- **A full loading → play flow in a real page** (`examples/basic/index.html`): a byte-level download progress bar, a user-gesture "Start" button (required for both audio autoplay and fullscreen to work), a working fullscreen button, and responsive canvas sizing that fills its container correctly.
- **Browser file-loading for self-contained tables**: picking a `.vpx` file, mounting it via `FS.writeFile`, and starting the engine against it — verified end-to-end (`PinTable::LoadGameFromFilename /tables/uploaded.vpx` in the boot log, followed by a normal render).
- Touch-control overlay code (`attachTouchControls`) that synthesizes the real default keyboard scancodes (`SDL_SCANCODE_LSHIFT`/`RSHIFT`/`RETURN` — see `src/input/InputManager.cpp`) on `window`, matching SDL3's Emscripten keyboard target; implemented and included in the example, not yet confirmed to move a flipper on a real touch device (see below).
- A JS-callable `evalScript()` debug/utility hook (`pinball.evalScript(script)`) that runs arbitrary VBScript against the live table via the same entry point the interpreter's own debug console uses.
**Not yet validated (should work, per code review, but unconfirmed end-to-end):**
- Keyboard/gamepad input actually reaching a flipper/plunger during real gameplay. The pipeline is code-complete and correctly wired (verified by reading `SDLInputHandler.h`/`InputManager.cpp`, unmodified), and the underlying key bindings were confirmed by reading the actual default scancode table. A real physical keypress test is still open - the automation environment used for validation in this project could not reliably simulate a modifier-key press (`Shift` alone) as a trusted browser event, which is a tooling limitation of that environment, not a claim that input doesn't work.
- Audio actually audible (the pipeline is fully wired per code review; needs a real speaker/headphone test - the autoplay-policy user-gesture gate is now in place via the example's "Start" button).
- DMD rendering via a script-driven `Flasher`/`ScriptGlobalTable::put_DMDPixels` (this is native, core-engine functionality requiring no new code — see `src/core/ScriptGlobalTable.cpp:886-937`, `src/parts/flasher.cpp:1312-1341` — just needs a test table that exercises it).
- Touch controls actually flipping a flipper on a real touch device (see above).
**Not yet validated (should work, per code review and by analogy to already-proven mechanisms, but unconfirmed end-to-end):**
- Gamepad/joystick input during real gameplay (keyboard input uses the identical `InputManager` pipeline and is confirmed working; gamepad support is code-complete but untested on real hardware).
- Touch-control overlay (`attachTouchControls`) actually flipping a flipper on a real touch device — implemented and included in the example (synthesizes the real default keyboard scancodes on `window`), not yet confirmed on physical touch hardware.
- DMD rendering via a script-driven `Flasher`/`ScriptGlobalTable::put_DMDPixels`. This is native, core-engine functionality requiring no new code (`src/core/ScriptGlobalTable.cpp:886-937`, `src/parts/flasher.cpp:1312-1341`), and rides the exact same `ScriptInterpreter::Evaluate()` VBScript-dispatch path already confirmed working by the flipper test above — but the bundled default table has no DMD-configured `Flasher`, so a full *visual* confirmation needs a real DMD-equipped table (uploadable via the file picker) or authoring one, neither done yet.
**Explicitly out of scope for now** (browser-sandbox constraints or a real infrastructure-cost tradeoff, not a technical dead end — could be revisited):
- **Multi-threading** (pthreads/SharedArrayBuffer): would require mandatory `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers on every page hosting this widget, a `coi-serviceworker`-style workaround for static hosts that can't set custom headers, and real risk of breaking unrelated cross-origin embeds on host pages — a poor tradeoff for something meant to be embeddable in arbitrary third-party pages. `ThreadPool`'s one-off parallel work (e.g. parallel `.vpx` item deserialization) already runs synchronously instead (`patches/vpinball/0002-*`). Revisit only if real-world profiling shows single-threaded performance is genuinely insufficient.
- **The heavy plugin ecosystem** (PinMAME real ROM emulation — copyrighted ROMs, can't legally bundle; DOF/AltSound/PUP/FlexDMD/Serum — real-cabinet-hardware or FFmpeg-dependent; `b2slegacy` — a ~11,000-line legacy VB6-COM compatibility layer). Excluded via a single CMake guard; zero core-engine impact. The *modern* `plugins/b2s` backglass plugin (self-contained, no FFmpeg) is a reasonable future addition via the same static-plugin-linking mode iOS/Android already use (`__LIBVPINBALL__`/`VPinballLib::SetupStaticPlugins()`) — not yet done.
- **The heavy plugin ecosystem** (PinMAME real ROM emulation — copyrighted ROMs, can't legally bundle; DOF/AltSound/PUP/FlexDMD/Serum — real-cabinet-hardware or FFmpeg-dependent; `b2slegacy` — a ~11,000-line legacy VB6-COM compatibility layer). Excluded via a single CMake guard; zero core-engine impact.
- **The modern `plugins/b2s` backglass plugin** — explicitly assessed and declined for now, not just left undone. `B2SServer` itself has no browser-incompatible networking (it's built on vpinball's own in-process plugin messaging bus, not real sockets), but our emscripten build currently excludes the *entire* plugin subsystem outright (`CMakeLists_plugins.txt` isn't included at all), so wiring B2S in means new static-linking plumbing modeled on iOS/Android's `__LIBVPINBALL__` path, which this project's `__STANDALONE__` build doesn't share — a genuinely multi-hour undertaking, and one with no way to confirm it actually renders anything without a real `.directb2s`-equipped test table, which isn't available. Revisit if/when such a table is available to validate against.
- Raw HID device input (`OpenPinDevHandler`/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
**Further follow-up work:**
- A definitive real-device input/audio validation pass (see above).
- Real-device gamepad and touch-control validation (see above).
- Visual confirmation of DMD rendering against a real DMD-equipped table (see above).
- Pointer-lock UI wiring (fullscreen is done; pointer-lock isn't needed for this game's input model but noted here in case a future feature wants it).
- Real-world table support beyond self-contained single-file uploads: tables with an external `.vbs` script override or a `Music/` folder are a documented, known-unsupported gap.
- Further binary-size tuning (the `.wasm` itself is still ~13MB with `-O2`; `--closure 1` was tried but silently stripped `FS.writeFile`/`readFile`/`mkdir` down to low-level node ops - a real Emscripten/Closure interaction bug worth root-causing before re-enabling, since it broke runtime table loading with no compile-time warning. `-sFORCE_FILESYSTEM=1`/broad `EXPORTED_RUNTIME_METHODS` also pull in more than strictly needed).
- Further binary-size tuning (the `.wasm` itself is still ~13MB with `-O2`; `--closure 1` was tried but silently stripped `FS.writeFile`/`readFile`/`mkdir` down to low-level node ops - a real Emscripten/Closure interaction bug worth root-causing before re-enabling, since it broke runtime table loading with no compile-time warning. `-sFORCE_FILESYSTEM=1`/broad `EXPORTED_RUNTIME_METHODS` also pull in more than strictly needed. The default table itself is now ~18MB of the ~34MB total, since a real playable demo table is larger than the rendering-test fixture used before).
- A browser-based (Puppeteer) CI smoke test that actually loads a table and checks for a rendered frame, replacing today's compile-only CI check.
## Build
@@ -115,6 +122,10 @@ startButton.addEventListener('click', () => {
pinball.start();
if ('ontouchstart' in window) attachTouchControls({ container: canvas.parentElement });
});
// Optional debug/utility hook: runs arbitrary VBScript against the live
// table, via the same entry point the interpreter's own debug console uses.
pinball.evalScript('DMDWidth = 128 : DMDHeight = 32');
```
See `examples/basic/index.html` for a complete working page (loading progress, file upload, touch controls, fullscreen). The published package name/registry (`@valknar/vpinball-wasm` on this project's Gitea npm registry) is a placeholder pending the first real release — see `package.json`.
@@ -122,10 +133,11 @@ See `examples/basic/index.html` for a complete working page (loading progress, f
## Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
- No heavy plugin ecosystem (PinMAME/DOF/FlexDMD/etc.) — see Roadmap. Native DMD rendering and a lightweight modern backglass plugin remain possible without it.
- No heavy plugin ecosystem (PinMAME/DOF/FlexDMD/etc.), and the modern `plugins/b2s` backglass plugin specifically assessed and declined for now (no plugin subsystem wired for this build, no test table to validate against) — see Roadmap.
- No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- Keyboard/gamepad input and audio are code-complete and wired correctly, and the embedding shell (loading progress, fullscreen, file upload, touch-control overlay) is built and tested end-to-end, but a real-device confirmation that a physical/touch keypress moves a flipper and that audio is audible is still open — see Roadmap.
- `.wasm`/asset size is reduced (~11MB total, down from ~51MB) but not fully tuned — see Roadmap.
- Keyboard input, audio, and VBScript-driven gameplay are confirmed working end-to-end by hands-on manual testing (not just code review). Gamepad input and the touch-control overlay are code-complete and use the identical input pipeline, but are not yet confirmed on real gamepad/touch hardware — see Roadmap.
- DMD rendering is native, code-complete functionality riding the same proven VBScript-dispatch path, but not yet visually confirmed since the bundled default table has no DMD-configured `Flasher` — see Roadmap.
- `.wasm`/asset size is ~34MB total (a real playable default table is larger than the rendering-test fixture used earlier in development) and not fully tuned — see Roadmap.
## Licensing
+1
View File
@@ -77,6 +77,7 @@
progressBar.style.width = `${Math.round(fraction * 100)}%`;
},
});
window.pinball = pinball; // for console access (e.g. pinball.evalScript(...))
startButton.disabled = false;
startButton.textContent = 'Start';
+3
View File
@@ -55,6 +55,9 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
requestFullscreen() {
return options.canvas.requestFullscreen();
},
evalScript(script: string) {
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
},
};
}
+8
View File
@@ -48,4 +48,12 @@ export interface PinballInstance {
* from the underlying requestFullscreen() call.
*/
requestFullscreen(): Promise<void>;
/**
* Runs arbitrary VBScript against the running table's script interpreter
* (the same entry point the interpreter's own debug console uses).
* Returns false if no table is currently running. Intended for debugging
* and for exercising script-driven engine APIs (e.g. the DMD pixel API)
* from JS - not sandboxed, so only run scripts you trust.
*/
evalScript(script: string): boolean;
}
+17 -2
View File
@@ -54,10 +54,10 @@ index 2a18ceb..a033615 100644
class AuditTableCommand : public TableBasedCommand
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
new file mode 100644
index 0000000..e6bccde
index 0000000..6e20009
--- /dev/null
+++ b/src/core/EmscriptenBridge.cpp
@@ -0,0 +1,78 @@
@@ -0,0 +1,93 @@
+// license:GPLv3+
+
+// vpinball-wasm: JS-callable entry points for controlling the player's
@@ -133,6 +133,21 @@ index 0000000..e6bccde
+ g_wasmPlayer = nullptr;
+}
+
+// Runs arbitrary VBScript against the running table's script interpreter,
+// via the same ScriptInterpreter::Evaluate() entry point the interpreter's
+// own debug console uses. Useful both as a debug/console hook for consumers
+// and, e.g., to exercise script-driven APIs (like the DMD pixel API) against
+// a table that doesn't itself call them, without needing to author a new
+// .vpx table.
+EMSCRIPTEN_KEEPALIVE
+int vpinball_wasm_eval_script(const char* script)
+{
+ if (g_wasmPlayer == nullptr || g_wasmPlayer->m_scriptInterpreter == nullptr)
+ return 0;
+ g_wasmPlayer->m_scriptInterpreter->Evaluate(script, false);
+ return 1;
+}
+
+} // extern "C"
+
+#endif // __EMSCRIPTEN__