6 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 ef20b0d6c3 Fix CI: vendor-fetch commit fails with no git identity configured
CI / Build wasm engine (push) Failing after 2m14s
CI / Publish to npm registry (push) Skipped
scripts/setup.sh's fetch_pinned() creates a fresh git repo per vendored
dependency and commits it with --author set, but --author alone doesn't
satisfy git's separate committer-identity requirement - it works on a
dev machine with ~/.gitconfig already set, but fails outright in a
clean CI container with no git identity anywhere (confirmed: Gitea
Actions failed at exactly this step with "Committer identity unknown").
Fixed by scoping user.name/user.email via -c flags to just this commit
invocation, verified to succeed even with HOME pointed at an empty
directory and no inherited git env vars.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:17:41 +02:00
valknarandClaude Sonnet 5 4638c06b68 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>
2026-08-22 18:14:49 +02:00
valknarandClaude Sonnet 5 b8bf9f1510 Fix back buffer not resizing on fullscreen toggle
Window::OnResized() (called for every SDL window-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, whose stale size kept driving glViewport() - so toggling
fullscreen resized the canvas but rendering stayed pinned to the old,
smaller viewport. Mirrors the resize handling the BGFX backend already
does explicitly elsewhere in RenderDevice.cpp.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:03:08 +02:00
valknarandClaude Sonnet 5 7c63f01527 Fix GL viewport sizing and ship a real playable default table
The OpenGL back-buffer render target was created with the window's
logical/CSS size instead of its device-pixel size, so on any browser
tab with devicePixelRatio != 1 the GL viewport only covered a fraction
of the canvas's actual backing buffer (rendering anchored bottom-left,
matching GL's viewport origin) - confirmed against a live repro and
fixed to match the convention already used by the BGFX backend
elsewhere in the same file.

Also swap the bundled default table from test000-default-table.vpx
(upstream's rendering/component regression-test fixture, which has no
gameplay script) to exampleTable.vpx - a genuine playable demo table
with working flippers, slingshots, bumpers, targets and a plunger -
confirmed keyboard input and audio now work end-to-end through it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:56:51 +02:00
valknar 487ca40a44 Phase D/F/H: embedding shell - loading progress, fullscreen, file upload, touch controls
package/src/index.ts: fixes a real pre-existing bug (start()/loadTable()
never actually called vpinball_wasm_start with a table path argument),
adds byte-level download progress (onProgress) and requestFullscreen().

package/src/touch-controls.ts: new on-screen virtual flipper/plunger/start
button overlay that synthesizes the actual default keyboard scancodes
(SDL_SCANCODE_LSHIFT/RSHIFT/RETURN, read from InputManager.cpp) as real
KeyboardEvents dispatched on window, matching SDL3's Emscripten keyboard
target - keeps the native input path completely unmodified.

examples/basic/index.html: a real demo page - progress bar, a user-gesture
"Start" button (required for both audio autoplay and fullscreen), a file
picker for self-contained .vpx uploads, and the touch overlay shown on
touch-capable devices.

scripts/build.sh: two real bugs found and fixed via actual browser testing:
- MODULARIZE=1 without EXPORT_ES6=1 produces a classic script, not an ES
  module with a default export - `await import(...)).default` was always
  undefined. Fixes with -sEXPORT_ES6=1.
- --closure 1 silently stripped FS.writeFile/readFile/mkdir down to just
  low-level node ops, breaking runtime table loading with no compile-time
  warning. Dropped until root-caused; -O2 alone is kept.

Verified end-to-end in Chrome: full load->progress->start flow with no
errors, fullscreen actually engaging (document.fullscreenElement true),
and a file-picker-selected table loading correctly (LoadGameFromFilename
/tables/uploaded.vpx in the boot log, followed by a normal render).

README updated to reflect what's now validated vs. still open (real-device
input/audio confirmation remains the main open item).
2026-08-22 16:45:04 +02:00
valknar a5e5b6f34a 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.
2026-08-22 16:06:17 +02:00
12 changed files with 710 additions and 79 deletions
+2
View File
@@ -3,5 +3,7 @@
/dist/ /dist/
/node_modules/ /node_modules/
/emsdk /emsdk
# generated by scripts/build.sh from vendor/vpinball, not source-controlled
/package/assets/
*.log *.log
.DS_Store .DS_Store
+68 -39
View File
@@ -4,18 +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. 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: a real, playable table, live in the browser — keyboard and audio confirmed
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 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 (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata). - Loads a real `.vpx` file 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** (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.
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3. - 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.
- Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's `.exr` asset) and runs the static pre-render pass (including reflection probes). - 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.
- Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs). - **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.
- Reaches `Player::Player@832 Startup done` / `Unpausing Game` — i.e., initialization completes successfully end to end. - 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).
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 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`/`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 ## Why a source-level port, not a reimplementation
@@ -38,10 +42,10 @@ This project instead compiles the actual C++ engine and the actual VBScript inte
(real Wine-derived interpreter, compiled to wasm32) (real Wine-derived interpreter, compiled to wasm32)
emscripten_set_main_loop 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: 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. - VBScript-in-WASM spike: proves `libwinevbs` compiles and correctly runs real VBScript (classes, `Scripting.Dictionary`, error handling) under wasm32.
@@ -54,29 +58,37 @@ Full research and two isolated feasibility spikes (proving real VBScript executi
- Real VBScript execution under wasm32 (libwinevbs). - Real VBScript execution under wasm32 (libwinevbs).
- Real WebGL2/GLES3 rendering under Emscripten (SDL3). - 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. - `.vpx` file loading and parsing; SDL3 audio/video/window initialization.
- SDL3 audio/video/window initialization under Emscripten. - Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2; environment map/HDR radiance computation; static pre-render pass.
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2. - **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.
- Environment map / HDR radiance computation. - **Audio**, confirmed audible by ear.
- Physics engine initialization (octree). - **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.
- VBScript engine startup. - **Correct rendering at any canvas size/DPI, including entering/leaving fullscreen** — two real GL back-buffer sizing bugs found and fixed (see Status).
- Static scene pre-render pass, including reflection probes. - 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).
- 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.
**The current blocker (next milestone):** **Not yet validated (should work, per code review and by analogy to already-proven mechanisms, but unconfirmed end-to-end):**
- 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. - 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 v1** (browser-sandbox constraints, not a technical dead end — could be revisited later): **Explicitly out of scope for now** (browser-sandbox constraints or a real infrastructure-cost tradeoff, not a technical dead end — could be revisited):
- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact. - **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.
- 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. - **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.
- Raw HID device input (`OpenPinDevHandler` / hidapi) — no browser equivalent for real-hardware nudge/plunger boards. - **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.
- 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-*`). - 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:** **Further follow-up work:**
- Browser file-loading UX (drag-and-drop / file picker for user-supplied `.vpx` files beyond the bundled demo table). - Real-device gamepad and touch-control validation (see above).
- Input mapping (keyboard/gamepad through SDL3's Emscripten backend). - Visual confirmation of DMD rendering against a real DMD-equipped table (see above).
- 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). - 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).
- A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check. - 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. 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 ## Build
@@ -95,20 +107,37 @@ source emsdk/emsdk_env.sh
## npm usage ## npm usage
```ts ```ts
import { loadPinball } from '@valknar/vpinball-wasm'; import { loadPinball, attachTouchControls } from '@valknar/vpinball-wasm';
const pinball = await loadPinball({ canvas: document.querySelector('canvas') }); const canvas = document.querySelector('canvas');
pinball.start(); const pinball = await loadPinball({
canvas,
onProgress: (fraction) => updateMyLoadingBar(fraction),
});
// start() (and requestFullscreen()) must be called from within a user
// gesture, e.g. a click handler - browsers block audio autoplay and
// fullscreen requests otherwise.
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');
``` ```
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. 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`.
## Known limitations ## Known limitations
- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap. - Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap. - 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). - No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- 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`. - 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 ## Licensing
+90 -8
View File
@@ -1,26 +1,108 @@
<!doctype html> <!doctype html>
<!-- <!--
Minimal usage example for @valknar/vpinball-wasm. Full usage example for @valknar/vpinball-wasm: loading progress, a
user-gesture "Start" button (required for audio autoplay and fullscreen
to work), an optional file picker for a self-contained .vpx table, touch
controls on touch-capable devices, and a fullscreen button.
Serve this directory alongside a built dist/ (see scripts/dev-server.sh) Serve this directory alongside a built dist/ (see scripts/dev-server.sh)
and adjust the import path below if dist/ isn't a sibling directory. and adjust the import paths below if dist/ isn't a sibling directory.
--> -->
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>vpinball-wasm example</title> <title>vpinball-wasm example</title>
<style> <style>
body { margin: 0; background: #111; } html, body { margin: 0; height: 100%; background: #111; overflow: hidden; }
canvas { display: block; width: 100vw; height: 100vh; } #stage { position: relative; width: 100vw; height: 100vh; }
canvas { display: block; width: 100%; height: 100%; padding: 0; border: 0; }
#overlay {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 16px;
background: #111; color: #eee; font-family: sans-serif;
}
#overlay.hidden { display: none; }
#progress-track { width: 280px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; }
#progress-bar { width: 0%; height: 100%; background: #4ade80; transition: width 0.1s linear; }
#start-button {
padding: 12px 32px; font-size: 18px; border-radius: 8px; border: none;
background: #4ade80; color: #111; cursor: pointer;
}
#start-button:disabled { opacity: 0.4; cursor: default; }
#file-picker { color: #aaa; font-size: 13px; }
#fullscreen-button {
position: absolute; top: 12px; right: 12px; z-index: 10;
padding: 8px 14px; border-radius: 6px; border: none;
background: rgba(255,255,255,0.15); color: #eee; cursor: pointer;
}
#fullscreen-button.hidden { display: none; }
</style> </style>
</head> </head>
<body> <body>
<canvas id="canvas"></canvas> <div id="stage">
<canvas id="canvas"></canvas>
<div id="overlay">
<div id="progress-track"><div id="progress-bar"></div></div>
<div id="file-picker">
<label>Optional: play your own table (self-contained .vpx only) <input type="file" id="table-file" accept=".vpx" /></label>
</div>
<button id="start-button" disabled>Loading...</button>
</div>
<button id="fullscreen-button" class="hidden">Fullscreen</button>
</div>
<script type="module"> <script type="module">
import { loadPinball } from '../../dist/index.js'; import { loadPinball, attachTouchControls } from '../../dist/index.js';
const canvas = document.getElementById('canvas'); const canvas = document.getElementById('canvas');
const pinball = await loadPinball({ canvas, baseUrl: '../../dist' }); const overlay = document.getElementById('overlay');
pinball.start(); const progressBar = document.getElementById('progress-bar');
const startButton = document.getElementById('start-button');
const fileInput = document.getElementById('table-file');
const fullscreenButton = document.getElementById('fullscreen-button');
let uploadedTableData;
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (file) uploadedTableData = await file.arrayBuffer();
});
const pinball = await loadPinball({
canvas,
baseUrl: '../../dist',
onProgress: (fraction) => {
progressBar.style.width = `${Math.round(fraction * 100)}%`;
},
});
window.pinball = pinball; // for console access (e.g. pinball.evalScript(...))
startButton.disabled = false;
startButton.textContent = 'Start';
startButton.addEventListener('click', () => {
if (uploadedTableData) {
pinball.loadTable(uploadedTableData);
}
pinball.start();
overlay.classList.add('hidden');
fullscreenButton.classList.remove('hidden');
// Touch controls are additive UI for touch-capable devices - not
// required for desktop mouse+keyboard play.
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
attachTouchControls({ container: document.getElementById('stage') });
}
});
fullscreenButton.addEventListener('click', () => {
pinball.requestFullscreen().catch(() => {
// Fullscreen can be rejected (e.g. already fullscreen, or the
// browser denies it) - nothing to recover from here.
});
});
</script> </script>
</body> </body>
</html> </html>
+79 -17
View File
@@ -1,20 +1,28 @@
import type { LoadPinballOptions, PinballInstance } from './types.js'; import type { LoadPinballOptions, PinballInstance } from './types.js';
export 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 * Instantiates the WebAssembly build of Visual Pinball against the given
* canvas. * canvas and returns a handle to control its lifecycle.
*
* NOTE (current milestone): this wrapper boots the Emscripten module and
* exposes lifecycle control (start/stop/dispose). Runtime table loading
* (`loadTable`) mounts the given bytes into the module's virtual filesystem,
* but wiring the native engine to actually pick up a runtime-loaded table
* (as opposed to the table baked in at build time) is tracked as a follow-up
* milestone in the README's roadmap - see "Browser file-loading UX".
*/ */
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> { export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
const baseUrl = options.baseUrl ?? '.'; const baseUrl = options.baseUrl ?? '.';
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, // dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
// so importing it yields a factory function, not a module with side effects. // so importing it yields a factory function, not a module with side effects.
@@ -22,21 +30,21 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
moduleArgs: Record<string, unknown> moduleArgs: Record<string, unknown>
) => Promise<EmscriptenModule>; ) => Promise<EmscriptenModule>;
const module = await factory({ const module = await factory(moduleArgs);
canvas: options.canvas, options.onProgress?.(1);
locateFile: (path: string) => `${baseUrl}/${path}`,
});
if (options.tableData) { if (options.tableData) {
mountTable(module, options.tableData); module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
tablePath = UPLOADED_TABLE_PATH;
} }
return { return {
loadTable(vpxBytes: ArrayBuffer) { loadTable(vpxBytes: ArrayBuffer) {
mountTable(module, vpxBytes); module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
tablePath = UPLOADED_TABLE_PATH;
}, },
start() { start() {
module.ccall?.('vpinball_wasm_start', null, [], []); module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
}, },
stop() { stop() {
module.ccall?.('vpinball_wasm_stop', null, [], []); module.ccall?.('vpinball_wasm_stop', null, [], []);
@@ -44,11 +52,65 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
dispose() { dispose() {
module.ccall?.('vpinball_wasm_dispose', null, [], []); 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]));
},
}; };
} }
function mountTable(module: EmscriptenModule, vpxBytes: ArrayBuffer): void { /**
module.FS.writeFile('/table.vpx', new Uint8Array(vpxBytes)); * 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 { interface EmscriptenModule {
+125
View File
@@ -0,0 +1,125 @@
/**
* On-screen touch controls for mobile/tablet play: an HTML/CSS overlay of
* virtual buttons that synthesize the same keyboard events a physical
* keyboard would send, using vpinball's actual default key bindings
* (see vendor/vpinball/src/input/InputManager.cpp's addFlipperKeyAction/
* addKeyAction calls). Deliberately implemented in JS/HTML rather than
* native code - this keeps the native SDL keyboard input path completely
* unmodified and is the dominant pattern for adding touch controls to a
* keyboard-oriented Emscripten port.
*
* SDL3's Emscripten video backend listens for keydown/keyup on the
* "#window" target by default (see SDL_emscriptenvideo.c's keyboard_element
* default), so dispatching a real KeyboardEvent on `window` reaches it the
* same way a physical keypress would.
*/
const KEY_BINDINGS = {
leftFlipper: { key: 'Shift', code: 'ShiftLeft' },
rightFlipper: { key: 'Shift', code: 'ShiftRight' },
plunger: { key: 'Enter', code: 'Enter' },
start: { key: '1', code: 'Digit1' },
} as const;
export interface TouchControlsOptions {
/** Container to render the overlay into. Positioned absolutely, filling this element. */
container: HTMLElement;
/**
* Which buttons to show. Defaults to the full set (both flippers,
* plunger, start).
*/
buttons?: Array<keyof typeof KEY_BINDINGS>;
}
export interface TouchControlsHandle {
/** Remove the overlay and its event listeners. */
detach(): void;
}
/**
* Attaches a touch-control overlay to `options.container`. Call this
* conditionally (e.g. only when `'ontouchstart' in window` or on narrow
* viewports) - it's additive UI, not required for desktop/mouse+keyboard play.
*/
export function attachTouchControls(options: TouchControlsOptions): TouchControlsHandle {
const buttons = options.buttons ?? (Object.keys(KEY_BINDINGS) as Array<keyof typeof KEY_BINDINGS>);
const root = document.createElement('div');
root.setAttribute('data-vpinball-touch-controls', '');
applyStyle(root, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
userSelect: 'none',
touchAction: 'none',
fontFamily: 'sans-serif',
});
const elements: HTMLElement[] = [];
if (buttons.includes('leftFlipper')) {
elements.push(makeButton('◀', KEY_BINDINGS.leftFlipper, { left: '0', bottom: '0' }));
}
if (buttons.includes('rightFlipper')) {
elements.push(makeButton('▶', KEY_BINDINGS.rightFlipper, { right: '0', bottom: '0' }));
}
if (buttons.includes('plunger')) {
elements.push(makeButton('⬆', KEY_BINDINGS.plunger, { right: '0', top: '40%' }));
}
if (buttons.includes('start')) {
elements.push(makeButton('1', KEY_BINDINGS.start, { left: '50%', top: '0', transform: 'translateX(-50%)' }));
}
for (const el of elements) root.appendChild(el);
options.container.appendChild(root);
return {
detach() {
root.remove();
},
};
}
function makeButton(label: string, binding: { key: string; code: string }, position: Record<string, string>): HTMLElement {
const btn = document.createElement('div');
btn.textContent = label;
applyStyle(btn, {
position: 'absolute',
width: '72px',
height: '72px',
margin: '16px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.85)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
pointerEvents: 'auto',
touchAction: 'none',
...position,
});
const down = () => {
btn.style.background = 'rgba(255,255,255,0.35)';
window.dispatchEvent(new KeyboardEvent('keydown', { key: binding.key, code: binding.code, bubbles: true }));
};
const up = () => {
btn.style.background = 'rgba(255,255,255,0.15)';
window.dispatchEvent(new KeyboardEvent('keyup', { key: binding.key, code: binding.code, bubbles: true }));
};
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
btn.setPointerCapture(e.pointerId);
down();
});
btn.addEventListener('pointerup', up);
btn.addEventListener('pointercancel', up);
return btn;
}
function applyStyle(el: HTMLElement, style: Record<string, string>): void {
Object.assign(el.style, style);
}
+37 -8
View File
@@ -9,22 +9,51 @@ export interface LoadPinballOptions {
/** Canvas the engine renders into via SDL3's WebGL2 backend. */ /** Canvas the engine renders into via SDL3's WebGL2 backend. */
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
/** /**
* Raw bytes of a .vpx table file to load on startup. If omitted, the * Raw bytes of a self-contained .vpx table file to play instead of the
* bundled default table (package/assets/test000-default-table.vpx) is * bundled default table. Real-world tables that reference an external
* used instead. * .vbs script override or a Music/ folder are not supported this way -
* see the README's "Browser file-loading UX" section.
*/ */
tableData?: ArrayBuffer; tableData?: ArrayBuffer;
/** Base URL to fetch dist/vpinball.wasm (and .data, if present) from. */ /** Base URL to fetch dist/vpinball.wasm/.data (and vpinball.js) from. */
baseUrl?: string; baseUrl?: string;
/**
* Called as the wasm binary and preloaded asset package download,
* with a 0-1 fraction of bytes received. Not called at all if the
* browser doesn't support streaming fetch progress (falls back to
* Emscripten's own default loading behavior with no progress callback).
*/
onProgress?: (fraction: number) => void;
} }
export interface PinballInstance { export interface PinballInstance {
/** Load a different .vpx table at runtime, replacing the current one. */ /**
* Stage a different self-contained .vpx table to play on the *next*
* start() call. Does not affect an already-running session - table
* switching mid-session isn't supported; call stop() first (a fresh
* loadPinball() call is the supported way to load a genuinely new
* session with a different table).
*/
loadTable(vpxBytes: ArrayBuffer): void; loadTable(vpxBytes: ArrayBuffer): void;
/** Start (or resume) the simulation's main loop. */ /** Start the simulation's main loop. No-op if already running. */
start(): void; start(): void;
/** Pause the simulation's main loop. */ /** Stop the simulation; the engine tears down (script Exit event, settings save) on its next step. */
stop(): void; stop(): void;
/** Tear down the instance and free its WebAssembly memory. */ /** Tear down the instance immediately and free its WebAssembly memory. */
dispose(): void; dispose(): void;
/**
* Request fullscreen on the canvas via the standard Fullscreen API.
* Must be called from within a user gesture (e.g. a click handler) -
* browsers reject fullscreen requests otherwise. Returns the promise
* 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;
} }
@@ -1,5 +1,5 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1fcf242..f9338b2 100644 index 1fcf242..745b91b 100644
--- a/CMakeLists.txt --- a/CMakeLists.txt
+++ b/CMakeLists.txt +++ b/CMakeLists.txt
@@ -50,6 +50,7 @@ set(_vpx_valid_combos @@ -50,6 +50,7 @@ set(_vpx_valid_combos
@@ -10,7 +10,7 @@ index 1fcf242..f9338b2 100644
DX9-windows) DX9-windows)
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos) if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos)
string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}") string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}")
@@ -784,6 +785,123 @@ elseif(PLATFORM STREQUAL "linux") @@ -784,6 +785,141 @@ elseif(PLATFORM STREQUAL "linux")
endif() endif()
endif() endif()
@@ -41,6 +41,7 @@ index 1fcf242..f9338b2 100644
+ +
+ add_executable(vpinball + add_executable(vpinball
+ ${VPX_STANDALONE_SOURCES} + ${VPX_STANDALONE_SOURCES}
+ ${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
+ ) + )
+ +
+ target_include_directories(vpinball PUBLIC + target_include_directories(vpinball PUBLIC
@@ -108,9 +109,26 @@ index 1fcf242..f9338b2 100644
+ # staged directories must exist *before* linking, not after. + # staged directories must exist *before* linking, not after.
+ add_custom_command(TARGET vpinball PRE_LINK + add_custom_command(TARGET vpinball PRE_LINK
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets" + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets"
+ # vpinball-wasm: exclude editor-only content never read by the
+ # __STANDALONE__ player runtime (native "File > New" templates and
+ # mobile-app onboarding screens, and a bundled Monaco code editor for
+ # remote script editing) - this is ~52MB of the ~54MB raw src/assets
+ # payload, cut here rather than preloaded and never used.
+ COMMAND "${CMAKE_COMMAND}" -E rm -f
+ "${RESOURCES_DIR}/assets/exampleTable.vpx"
+ "${RESOURCES_DIR}/assets/blankTable.vpx"
+ "${RESOURCES_DIR}/assets/lightSeqTable.vpx"
+ "${RESOURCES_DIR}/assets/strippedTable.vpx"
+ COMMAND "${CMAKE_COMMAND}" -E rm -rf "${RESOURCES_DIR}/assets/web"
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts" + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts"
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables"
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tests/assets/test000-default-table.vpx" "${RESOURCES_DIR}/tables/default.vpx" + # exampleTable.vpx (not test000-default-table.vpx, which is upstream's
+ # rendering/component regression-test fixture with no gameplay script)
+ # is a genuine playable demo table - it has real LeftFlipper/RightFlipper
+ # Animate/Collide subs, Table1_KeyDown/KeyUp, slingshots, bumpers,
+ # targets, a plunger and drain detection - the right default for a
+ # public-facing demo.
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/src/assets/exampleTable.vpx" "${RESOURCES_DIR}/tables/default.vpx"
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}"
+ ) + )
+ foreach(_shader IN LISTS VPX_GL_SHADERS) + foreach(_shader IN LISTS VPX_GL_SHADERS)
@@ -134,7 +152,7 @@ index 1fcf242..f9338b2 100644
# iOS and Android libvpinball build # iOS and Android libvpinball build
elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android") elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android")
@@ -957,4 +1075,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM @@ -957,4 +1093,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
endif() endif()
@@ -0,0 +1,37 @@
diff --git a/src/renderer/RenderDevice.cpp b/src/renderer/RenderDevice.cpp
index 1fedf4e..6927546 100644
--- a/src/renderer/RenderDevice.cpp
+++ b/src/renderer/RenderDevice.cpp
@@ -1501,7 +1501,11 @@ RenderDevice::RenderDevice(
SetRenderState(RenderState::ZFUNC, RenderState::Z_LESSEQUAL);
// Retrieve a reference to the back buffer.
- wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetWidth(), wnd->GetHeight(), back_buffer_format));
+ // vpinball-wasm: use pixel (device) size, not logical/CSS size - on Emscripten
+ // these differ by devicePixelRatio, and the GL viewport (RenderTarget.cpp's
+ // glViewport(0, 0, m_width, m_height)) must match the canvas's actual backing
+ // buffer or rendering only fills a fraction of it, anchored bottom-left.
+ wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetPixelWidth(), wnd->GetPixelHeight(), back_buffer_format));
#elif defined(ENABLE_DX9)
///////////////////////////////////
@@ -2168,10 +2172,19 @@ void RenderDevice::WaitForVSync(const bool asynchronous)
m_vsyncCount++;
m_presentTimestampReference = usec();
};
+#ifndef __EMSCRIPTEN__
if (asynchronous)
std::thread(lambda).detach(); // Reuse thread ? (we always at most one running at a time)
else
lambda();
+#else
+ // vpinball-wasm: a single-threaded Emscripten build (no -pthread /
+ // SharedArrayBuffer) cannot construct real std::thread workers - run
+ // synchronously inline instead. This only updates m_vsyncCount/timestamp
+ // bookkeeping (the real vblank-wait branches above are already excluded
+ // for __STANDALONE__ builds), so synchronous execution is equivalent.
+ lambda();
+#endif
}
#if defined(ENABLE_BGFX)
@@ -0,0 +1,209 @@
diff --git a/src/core/AppCommands.cpp b/src/core/AppCommands.cpp
index 735762c..326c362 100644
--- a/src/core/AppCommands.cpp
+++ b/src/core/AppCommands.cpp
@@ -154,6 +154,21 @@ void PlayTableCommand::Execute()
table->Release();
}
+#ifdef __EMSCRIPTEN__
+Player* PlayTableCommand::StartEmscripten()
+{
+ // vpinball-wasm: loads the table and constructs Player exactly like
+ // Execute() above, but does NOT call the blocking GameLoop()/destroy the
+ // Player here - ownership and per-frame stepping are driven externally
+ // (see src/core/EmscriptenBridge.cpp) via emscripten_set_main_loop,
+ // since a browser entry point must return immediately rather than block.
+ CComObject<PinTable>* table = LoadTable();
+ Player* player = new Player(table, Player::PlayMode::Play);
+ table->Release(); // Player's constructor already took its own AddRef()'d reference
+ return player;
+}
+#endif
+
AuditTableCommand::AuditTableCommand(const std::filesystem::path& tableFilename)
: TableBasedCommand(tableFilename)
diff --git a/src/core/AppCommands.h b/src/core/AppCommands.h
index 2a18ceb..a033615 100644
--- a/src/core/AppCommands.h
+++ b/src/core/AppCommands.h
@@ -2,6 +2,9 @@
#pragma once
+#ifdef __EMSCRIPTEN__
+class Player;
+#endif
class AppCommand
{
@@ -58,6 +61,13 @@ public:
explicit PlayTableCommand(const std::filesystem::path& tableFilename);
~PlayTableCommand() override = default;
void Execute() override;
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: see src/core/EmscriptenBridge.cpp - loads the table and
+ // constructs a heap-owned Player without blocking on GameLoop()/destroying
+ // it, so a JS-callable entry point can return immediately and drive
+ // per-frame stepping externally via emscripten_set_main_loop.
+ Player* StartEmscripten();
+#endif
};
class AuditTableCommand : public TableBasedCommand
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
new file mode 100644
index 0000000..6e20009
--- /dev/null
+++ b/src/core/EmscriptenBridge.cpp
@@ -0,0 +1,93 @@
+// license:GPLv3+
+
+// vpinball-wasm: JS-callable entry points for controlling the player's
+// lifecycle from the browser. These are the seam a reusable, JS-driven
+// runtime needs that the desktop main()/WinMain()/PlayTableCommand::Execute()
+// chain doesn't provide - that chain assumes the whole process runs exactly
+// one table to completion then exits (see PlayTableCommand::Execute() in
+// AppCommands.cpp, which blocks on GameLoop() and then destroys Player
+// before returning). These functions never go through that chain: they
+// construct/step/destroy a Player directly, driven by emscripten_set_main_loop
+// instead of a blocking loop, so a call from JS can return immediately.
+//
+// Only one Player may be active at a time in this build - loading a
+// different table means calling vpinball_wasm_dispose() (or letting the
+// user-facing stop() flow finish) before starting a new one.
+
+#ifdef __EMSCRIPTEN__
+
+#include "core/stdafx.h"
+#include "core/AppCommands.h"
+#include "core/player.h"
+
+#include <emscripten.h>
+
+static Player* g_wasmPlayer = nullptr;
+
+static void EmscriptenMainLoopTrampoline()
+{
+ if (g_wasmPlayer && g_wasmPlayer->EmscriptenStepFrame())
+ return;
+
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer; // runs Player's normal destructor teardown (script Exit event, plugin unload, settings save)
+ g_wasmPlayer = nullptr;
+}
+
+extern "C" {
+
+EMSCRIPTEN_KEEPALIVE
+int vpinball_wasm_start(const char* tablePath)
+{
+ if (g_wasmPlayer != nullptr)
+ return 0; // already running - caller must stop()/dispose() first
+
+ PlayTableCommand cmd{std::filesystem::path(tablePath)};
+ g_wasmPlayer = cmd.StartEmscripten();
+ if (g_wasmPlayer == nullptr)
+ return 0;
+
+ // fps=0 uses requestAnimationFrame, synced to display refresh and
+ // automatically paused by the browser while the tab is hidden/backgrounded
+ // - correct behavior for a real game tab (verified end-to-end with a
+ // setTimeout-based fps>0 loop first, since rAF is unobservable in a
+ // headless/backgrounded automation tab).
+ emscripten_set_main_loop(EmscriptenMainLoopTrampoline, 0, 0);
+ return 1;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_stop()
+{
+ if (g_wasmPlayer != nullptr)
+ g_wasmPlayer->SetCloseState(Player::CS_STOP_PLAY);
+}
+
+EMSCRIPTEN_KEEPALIVE
+void vpinball_wasm_dispose()
+{
+ if (g_wasmPlayer == nullptr)
+ return;
+ emscripten_cancel_main_loop();
+ delete g_wasmPlayer;
+ 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__
diff --git a/src/core/player.cpp b/src/core/player.cpp
index e83af84..60c7d5f 100644
--- a/src/core/player.cpp
+++ b/src/core/player.cpp
@@ -1992,6 +1992,32 @@ void Player::GPUQueueStuffingGameLoop()
}
}
+#ifdef __EMSCRIPTEN__
+bool Player::EmscriptenStepFrame()
+{
+ // vpinball-wasm: single-step version of GPUQueueStuffingGameLoop(), driven
+ // once per browser animation frame by emscripten_set_main_loop instead of
+ // a blocking while loop. Manual framerate throttling (uSleep) is removed
+ // entirely - the browser's own requestAnimationFrame cadence paces us.
+ if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
+ return false;
+
+ UpdateGameLogic();
+ PrepareFrame();
+ UpdateGameLogic();
+ SubmitFrame();
+ UpdateGameLogic();
+
+ m_renderProfiler->EnterProfileSection(FrameProfiler::PROFILE_RENDER_FLIP);
+ m_renderer->m_renderDevice->Flip();
+ m_renderProfiler->ExitProfileSection();
+
+ FinishFrame();
+
+ return true;
+}
+#endif
+
void Player::FramePacingGameLoop()
{
// The main loop tries to perform a constant input/physics cycle at a 1ms pace while feeding the GPU command queue at a stable rate, without multithreading.
diff --git a/src/core/player.h b/src/core/player.h
index 6e9755a..a069cf7 100644
--- a/src/core/player.h
+++ b/src/core/player.h
@@ -115,6 +115,14 @@ public:
void ProcessOSMessages(const bool isInitialized = true);
+#ifdef __EMSCRIPTEN__
+ // vpinball-wasm: single-step frame function driven by emscripten_set_main_loop
+ // instead of a blocking while loop (see GameLoop()). Returns false once the
+ // player wants to quit; the caller must then stop calling this and release
+ // the Player (running its normal destructor teardown).
+ bool EmscriptenStepFrame();
+#endif
+
private:
VideoSyncMode m_videoSyncMode = VideoSyncMode::VSM_FRAME_PACING;
float m_maxFramerate = 0.f; // targeted refresh rate in Hz, if larger refresh rate it will limit FPS by uSleep() //!! currently does not work adaptively as it would require IDirect3DDevice9Ex which is not supported on WinXP
@@ -0,0 +1,20 @@
diff --git a/src/renderer/Window.cpp b/src/renderer/Window.cpp
index ad608f8..3dff060 100644
--- a/src/renderer/Window.cpp
+++ b/src/renderer/Window.cpp
@@ -381,6 +381,15 @@ void Window::OnResized()
return;
SDL_GetWindowSize(m_nwnd, &m_width, &m_height);
SDL_GetWindowSizeInPixels(m_nwnd, &m_pixelWidth, &m_pixelHeight);
+ // vpinball-wasm: keep the (GL) default back buffer's tracked size in sync
+ // with the window's actual pixel size, mirroring what the BGFX backend
+ // already does explicitly every frame (RenderDevice.cpp's swapchain
+ // resize handling). Without this, glViewport(0, 0, m_width, m_height) in
+ // RenderTarget::Activate() keeps using the pre-resize size forever, so
+ // e.g. entering browser fullscreen (which resizes the canvas well after
+ // the back buffer was created) only renders into the old, smaller area.
+ if (m_backBuffer)
+ m_backBuffer->SetSize(m_pixelWidth, m_pixelHeight);
}
VPX::Window::VideoMode Window::SDLtoVPXVideoMode(const SDL_DisplayMode* mode)
+15 -2
View File
@@ -40,13 +40,26 @@ EMSCRIPTEN_LINK_FLAGS=(
-sMAX_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2
-sALLOW_MEMORY_GROWTH=1 -sALLOW_MEMORY_GROWTH=1
-sMODULARIZE=1 -sMODULARIZE=1
-sEXPORT_ES6=1
-sEXPORT_NAME=VPinballModule -sEXPORT_NAME=VPinballModule
-sENVIRONMENT=web -sENVIRONMENT=web
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap
-sFORCE_FILESYSTEM=1 -sFORCE_FILESYSTEM=1
-sEXPORTED_FUNCTIONS=_main,_vpinball_wasm_start,_vpinball_wasm_stop,_vpinball_wasm_dispose,_vpinball_wasm_eval_script
-sEXIT_RUNTIME=0
--use-preload-cache
) )
if [ "$BUILD_TYPE" = "Debug" ]; then if [ "$BUILD_TYPE" = "Debug" ]; then
EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -sEXIT_RUNTIME=1) EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -O0)
else
# CMAKE_BUILD_TYPE=Release only optimizes the compile step; emcc's link
# step needs its own -O level to actually run wasm-opt/dead-code
# elimination. NOTE: --closure 1 was tried here but strips FS.writeFile/
# readFile/mkdir (the FS convenience wrappers EXPORTED_RUNTIME_METHODS=FS
# is supposed to guarantee) down to just low-level node ops - dropped
# until that's root-caused, since a smaller JS glue isn't worth a broken
# runtime table-loading API.
EMSCRIPTEN_LINK_FLAGS+=(-O2)
fi fi
emcmake cmake \ emcmake cmake \
@@ -67,7 +80,7 @@ cp "$BUILD_DIR"/vpinball.wasm "$DIST_DIR/" 2>/dev/null || true
cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true
mkdir -p "$ROOT_DIR/package/assets" mkdir -p "$ROOT_DIR/package/assets"
cp "$VPINBALL_DIR/tests/assets/test000-default-table.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true cp "$VPINBALL_DIR/src/assets/exampleTable.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true
echo "" echo ""
echo "== build.sh complete ==" echo "== build.sh complete =="
+6 -1
View File
@@ -52,7 +52,12 @@ fetch_pinned() {
rm -f "/tmp/${name}-${sha}.tar.gz" rm -f "/tmp/${name}-${sha}.tar.gz"
git -C "$dest" init -q git -C "$dest" init -q
git -C "$dest" add -A git -C "$dest" add -A
git -C "$dest" commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>" # -c user.name/user.email (not --author) are needed here: --author only sets
# the commit's author field, but git also requires a *committer* identity,
# which a fresh CI container has no ~/.gitconfig to supply. Scoped to this
# one invocation only - doesn't touch any persistent git config.
git -C "$dest" -c user.name="vpinball-wasm setup" -c user.email="noreply@localhost" \
commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>"
echo "$sha" > "$sentinel" echo "$sha" > "$sentinel"
} }