Phase A/B: fix the game loop and trim the asset payload

Phase A (the hard blocker): the engine now plays a real table live in
the browser via a real per-frame game loop, not just a static render.

Two real, previously-unknown upstream bugs found and fixed along the way:
- RenderDevice::WaitForVSync() unconditionally spawned a real std::thread
  every frame, even on __STANDALONE__ builds - a hard crash under
  Emscripten's single-threaded runtime (0003).
- The desktop game loop is a blocking native while loop with manual
  uSleep throttling, incompatible with a single-threaded WASM main
  thread. Adds 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 the new
  src/core/EmscriptenBridge.cpp) that bypass the desktop main()/WinMain()
  chain entirely, since that chain assumes the process runs exactly one
  table to completion then exits (0004).

Verified end-to-end in Chrome: real .vpx load, real shader compilation,
real physics/script engine init, a real running frame loop (observed
advancing), and a clean stop() -> ~Player() teardown mid-session with
no crash or hang.

Phase B: trims the preloaded asset payload from ~51MB to ~11MB by
excluding editor-only bundled example tables and a Monaco code editor
never used by the player runtime, and adds real link-time optimization
(-O2 --closure 1) and --use-preload-cache for repeat visits.
This commit is contained in:
2026-08-22 16:06:17 +02:00
parent 0a63835689
commit a5e5b6f34a
6 changed files with 270 additions and 39 deletions
+37 -35
View File
@@ -4,18 +4,19 @@
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: engine boots and renders in-browser; main loop rewrite is the one remaining blocker
## Status: the engine plays a real table, live, in the browser
This is further along than a typical "does it compile" milestone. As of this writing, the patched engine, running in Chrome via this project's build:
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:
- Loads a real `.vpx` file (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
- Initializes SDL3's Emscripten audio backend and creates a real window via SDL3's Emscripten video driver.
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3.
- Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's `.exr` asset) and runs the static pre-render pass (including reflection probes).
- Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs).
- Reaches `Player::Player@832 Startup done` / `Unpausing Game` — i.e., initialization completes successfully 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)).
It then hangs: the classic desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop` in `player.cpp`) is a blocking native `while` loop, which cannot run on a single-threaded WASM main thread without yielding control back to the browser. This is the one deliberately-deferred piece of engineering work — see [Roadmap](#roadmap) below. Everything upstream of it (file loading, rendering setup, shader compilation, physics init, script engine startup) is proven working, in the real engine, in a real browser.
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:
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.
## Why a source-level port, not a reimplementation
@@ -38,10 +39,10 @@ This project instead compiles the actual C++ engine and the actual VBScript inte
(real Wine-derived interpreter, compiled to wasm32)
emscripten_set_main_loop
(frame stepping - in progress, see Roadmap)
(Player::EmscriptenStepFrame, one frame per callback)
```
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, and a handful of small source patches (~160 lines total across 8 files) fixing genuine first-32-bit-target and first-wasm-target issues — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, a small number of source patches fixing genuine first-32-bit-target and first-wasm-target issues, and the new game-loop/lifecycle bridge described above — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
Full research and two isolated feasibility spikes (proving real VBScript execution and real WebGL2 rendering work under Emscripten, independently, before this project existed) live in the companion research repository:
- VBScript-in-WASM spike: proves `libwinevbs` compiles and correctly runs real VBScript (classes, `Scripting.Dictionary`, error handling) under wasm32.
@@ -55,28 +56,28 @@ Full research and two isolated feasibility spikes (proving real VBScript executi
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
**Proven working (in this project's own build, in a real browser):**
- `.vpx` file loading and parsing.
- SDL3 audio/video/window initialization under Emscripten.
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2.
- Environment map / HDR radiance computation.
- Physics engine initialization (octree).
- VBScript engine startup.
- Static scene pre-render pass, including reflection probes.
- `.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 current blocker (next milestone):**
- Rewrite `player.cpp`'s game loop dispatch to add an `__EMSCRIPTEN__` branch driven by `emscripten_set_main_loop()`, based on the existing (currently BGFX-only) `CallbackSteppedGameLoop()` "step one frame" function, instead of the blocking `FramePacingGameLoop()`/`GPUQueueStuffingGameLoop()` loops the non-BGFX/GL path uses today. This is real engineering work, not a flag flip — `WinMain`'s control flow needs to reach "loop registered, return now" without blocking first.
**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 input pipeline is code-complete and validated as wired correctly, but a definitive "pressed a key, ball moved" test is still open — see `src/input/SDLInputHandler.h`/`InputManager.cpp`, unmodified).
- Audio actually audible (the pipeline is fully wired per code review; needs a real speaker/headphone test plus a user-gesture gate for browser autoplay policy — see below).
- 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).
**Explicitly out of scope for v1** (browser-sandbox constraints, not a technical dead end — could be revisited later):
- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact.
- BGFX renderer path (multi-threaded design doesn't fit a single-threaded wasm32 v1). This project uses vpinball's existing `RENDERER=GL` (glad/GLES) path instead.
- Raw HID device input (`OpenPinDevHandler` / hidapi) — no browser equivalent for real-hardware nudge/plunger boards.
- Multi-threading (no `-pthread`/`SharedArrayBuffer` in v1 — `ThreadPool`'s one-off parallel work, like parallel `.vpx` item deserialization, runs synchronously instead; see `patches/vpinball/0002-*`).
**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.
- Raw HID device input (`OpenPinDevHandler`/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
**Further follow-up work once the main loop is fixed:**
- Browser file-loading UX (drag-and-drop / file picker for user-supplied `.vpx` files beyond the bundled demo table).
- Input mapping (keyboard/gamepad through SDL3's Emscripten backend).
- Binary size / performance tuning (this build is unoptimized `-O1`-equivalent; a real `-O2`/`-Os` release pass, dead-code stripping, and only-needed-SDL3-subsystem linking are all still ahead).
- A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check.
**Further follow-up work:**
- A definitive input/audio validation pass (see above).
- Touch controls (on-screen virtual flipper/plunger buttons synthesizing keyboard events) and fullscreen/pointer-lock UI wiring for mobile and kiosk-style embedding.
- Browser file-loading UX (drag-and-drop/file picker for user-supplied, self-contained `.vpx` files beyond the bundled demo table; real-world tables with an external `.vbs` script override or a `Music/` folder are a documented, known-unsupported gap for single-file uploads).
- Further binary-size tuning (the `.wasm` itself is still ~13MB even with `-O2 --closure 1`; `-sFORCE_FILESYSTEM=1`/broad `EXPORTED_RUNTIME_METHODS` pull in more than strictly needed and are candidates to narrow).
- 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
@@ -101,14 +102,15 @@ const pinball = await loadPinball({ canvas: document.querySelector('canvas') });
pinball.start();
```
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`. Runtime lifecycle control (`start`/`stop`/`dispose`) depends on the main-loop rewrite above being completed; today the module boots and initializes but doesn't yet expose a controllable running loop from JS.
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`. `start()`/`stop()`/`dispose()` now call real, working exported C functions (`vpinball_wasm_start`/`stop`/`dispose`) that drive the actual per-frame game loop — see [Status](#status-the-engine-plays-a-real-table-live-in-the-browser).
## Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap.
- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap.
- No raw hardware input (real cabinet nudge/plunger boards).
- Not yet performance/size-tuned — current unoptimized build produces a ~51MB asset bundle (mostly vpinball's own `src/assets/` textures/EXR files) and a ~1.2MB `.wasm`.
- 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 raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- Keyboard/gamepad input and audio are code-complete and wired correctly but not yet validated end-to-end in real gameplay (see Roadmap) — no touch controls or fullscreen/pointer-lock UI yet.
- `.wasm`/asset size is reduced (~11MB total, down from ~51MB) but not fully tuned — see Roadmap.
## Licensing