21 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 6ff116ba02 Bump version to 0.3.2 for the MsgBox fix
CI / Build wasm engine (push) Successful in 10m55s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 19:31:05 +02:00
valknarandClaude Sonnet 5 1d7c4e7b7d Implement real MsgBox() for VBScript table scripts (browser confirm/alert)
libwinevbs's __LIBWINEVBS__ build of Global_MsgBox() only logged the
prompt text and never set a return value, since it has no host UI to
show a dialog through. Any table script that branches on
MsgBox(...) = vbYes (a common pattern - e.g. core.vbs's own trough
ball-count dialog) always took the "no" branch, since the result
stayed at its zero-initialized default.

Adds a msgbox callback to libwinevbs_callbacks_t, wired on the
vpinball-wasm side to a real window.confirm()/alert() call. Both are
synchronous browser APIs that block JS execution and return a value
immediately, matching what VBScript's MsgBox() needs (its result is
used by the calling statement right away) - the only way to answer it
asynchronously would require Asyncify or a busy-wait poll loop, which
this project has deliberately avoided elsewhere.

Confirmed fixed by hands-on testing: a table's ball-count confirm
dialog now correctly returns Yes/No based on the user's click, instead
of always silently taking the "no" branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 19:30:25 +02:00
valknarandClaude Sonnet 5 c7f7cb6afa Bump version to 0.3.1 for the focus-loss freeze fix
CI / Build wasm engine (push) Successful in 10m50s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:17:21 +02:00
valknarandClaude Sonnet 5 99ae5b9eda Fix PinMAME busy-wait freeze on browser/window focus loss
core.vbs auto-wires Controller.Pause = True into every table's Paused
event, which Player::OnFocusChanged() fires on focus loss. PinMAME's
own pause handling in usrintrf.c is a "while (g_fPause) { ...;
draw_screen(); ... }" busy-wait written for a real-OS-thread host,
where a separate thread later flips g_fPause back to 0. Under our
single-threaded Emscripten cooperative-scheduling model there is no
second thread to clear it, so entering that loop spins forever at
100% CPU with the tab completely unresponsive.

cpu_run_emscripten_step() now checks g_fPause up front and skips
cpu_timeslice() (and therefore that loop) entirely while paused;
emulation simply doesn't advance until Controller.Pause is set back
to False and the step function is called again next frame.

Confirmed fixed by hands-on testing: unfocusing the browser during a
PinMAME ROM session no longer freezes the tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:15:48 +02:00
valknarandClaude Sonnet 5 5570955bc5 Fix patches/pinmame/0004: drop duplicate hunk conflicting with 0002
CI / Build wasm engine (push) Successful in 10m51s
0004 was generated via a plain `git diff` against pinmame's pristine
commit, which captured the *cumulative* diff for cpuexec.c - including
patch 0002's time_fence stub hunk, not just 0004's own new cpu_run
splitting code. Applying 0002 then 0004 in sequence (exactly what
setup.sh does) failed: 0004's duplicate hunk expected pre-0002 context
that no longer matched.

Regenerated by diffing the current tree against a baseline with 0001-0003
already applied (matching the vpinball/0006 patch's existing approach)
instead of against pristine directly. Verified: all 4 pinmame patches now
apply cleanly in sequence against a fresh pristine checkout, and the
result is byte-identical to the working tree that was actually built and
tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
2026-08-23 12:47:51 +02:00
valknarandClaude Sonnet 5 6c8d08e618 Bump version to 0.3.0 for the PinMAME integration release
CI / Build wasm engine (push) Failing after 2m19s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
2026-08-23 12:38:16 +02:00
valknarandClaude Sonnet 5 a7b85ec8a4 Confirm real PinMAME ROM-driven emulation actually running
Tested pinball.loadRom() against a correctly-matched ROM zip for a real
community table (Heavy Metal Meltdown, Bally 1987): PinMAME loads the ROM,
and its own video/audio subsystems come up and run with no errors
(osd_create_display: 60.00 fps; real INT16/44100Hz audio format), not just
Controller object creation succeeding as before. Updates the README to move
this from "not yet validated" to confirmed, narrowing the remaining gap to
on-screen DMD/backglass output and hands-on switch/solenoid/scoring
behavior during actual play, which nobody has watched yet.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
2026-08-23 12:09:35 +02:00
valknarandClaude Sonnet 5 d91ef0c96d Rename package to scoped @valknar/vpinball-wasm
CI / Build wasm engine (push) Successful in 9m24s
Reverts the earlier unscoping: an unscoped package on a Gitea npm
registry has no `.npmrc` mapping a consumer can use other than pinning
the exact tarball URL, since Gitea's registry doesn't proxy npmjs.org
and only scoped packages support the standard `@scope:registry=`
config. Scoping restores that: consumers add one `@valknar:registry=`
line and depend on a normal semver range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hQoM3jJT1Lx7CMTciMzvD
2026-08-23 01:06:15 +02:00
valknarandClaude Sonnet 5 ad66bbf6be Rename package from @valknar/vpinball-wasm to vpinball-wasm
CI / Build wasm engine (push) Successful in 9m32s
Unscoped, matching this project's other Gitea-published package
(triggershell) - the registry is already scoped to the right owner via
publishConfig.registry's URL path (.../valknar/npm/), so an npm scope
on the package name itself is redundant. The old @valknar/vpinball-wasm
package published under the previous name is being removed manually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:06:44 +02:00
valknarandClaude Sonnet 5 243c975cec Merge build and publish into one job, eliminating redundant CI builds
CI / Build wasm engine (push) Successful in 9m24s
The separate "publish" job had needs: build but never reused any of
build's output - it re-ran the entire checkout/cache/setup/build-deps/
build/wrapper pipeline from scratch, so every tagged release did two
full independent builds for no benefit. Gitea Actions doesn't support
upload-artifact@v4+/download-artifact@v4+ (GHESNotSupportedError),
which rules out the usual "build uploads, publish downloads" pattern
anyway, so the simplest fix is to run the build pipeline exactly once
and gate the three publish-only steps (version bump from tag, registry
auth, npm publish) behind `if: startsWith(github.ref, 'refs/tags/')`
at the step level instead of duplicating everything in a second job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:50:12 +02:00
valknarandClaude Sonnet 5 536e09dff4 Bump version to 0.1.0 for the first release
CI / Build wasm engine (push) Successful in 9m17s
CI / Publish to npm registry (push) Canceled after 3m47s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:36:26 +02:00
valknarandClaude Sonnet 5 d22dc28af3 Fix CI: drop upload-artifact (unsupported on Gitea, unused downstream)
CI / Publish to npm registry (push) Canceled after 0s
CI / Build wasm engine (push) Canceled after 36s
actions/upload-artifact@v4+ refuses to run on Gitea (it's detected as
GHES, and v4's new backend API explicitly isn't supported there -
GHESNotSupportedError). Rather than pin back to the last GHES-compatible
v3.2.2, just remove the step: the publish job doesn't consume this
artifact - it does its own independent checkout+build - so it was only
ever a convenience for manually downloading dist/ from a CI run, not
something the pipeline depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:34:09 +02:00
valknarandClaude Sonnet 5 4f4c2b6b91 Fix CI: Gitea Actions cache keys never actually changed
CI / Publish to npm registry (push) Canceled after 0s
CI / Build wasm engine (push) Canceled after 5m11s
Confirmed via Gitea's own documentation (about.gitea.com's actions-cache
tutorial): Gitea Actions doesn't support the built-in hashFiles()
expression function GitHub Actions provides - it silently evaluates to
an empty string rather than erroring, so all three cache keys
(emsdk-${{hashFiles(...)}}, etc.) were actually just the constant
strings "emsdk-", "deps-wasm32-", "libwinevbs-wasm32-" on every run,
regardless of whether scripts/versions.sh or patches/ actually changed -
this is why caching "didn't work properly": correctness-wise it's worse
than no caching, since a stale cache from before a version/patch bump
would keep being reused indefinitely instead of invalidating.

Replaced with a plain sha256sum-based step that hashes the same inputs
by hand and exposes them via GITHUB_OUTPUT - no dependency on Gitea
gaining hashFiles() support, and verified locally to produce distinct,
non-empty hashes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:27:07 +02:00
valknarandClaude Sonnet 5 d7b95dbac8 Fix CI: install cmake/curl/python3, not just bison
CI / Build wasm engine (push) Failing after 8m31s
CI / Publish to npm registry (push) Skipped
The CI runner's base image doesn't ship cmake (build failed with
"cmake executable not found on PATH" during emcmake), and only bison
was ever explicitly installed - curl and python3 happened to work by
luck of the current runner image, not because the workflow guarantees
them. Install the full README-documented toolchain explicitly in both
the build and publish jobs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:22:00 +02:00
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
24 changed files with 1596 additions and 144 deletions
+29 -59
View File
@@ -11,28 +11,41 @@ jobs:
steps:
- uses: https://github.com/actions/checkout@v4
- name: Install bison
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y bison
sudo apt-get install -y bison cmake curl python3
# Gitea Actions doesn't support the built-in hashFiles() expression
# function (unlike GitHub Actions) - it silently evaluates to an empty
# string, which turned every cache key below into the same constant
# string regardless of what actually changed, so a cache entry from
# before a dependency-version or patch change would still be reused
# forever. Hash the same inputs by hand instead.
- name: Compute cache keys
id: cache-keys
run: |
echo "emsdk=$(sha256sum scripts/versions.sh | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "deps-wasm32=$(cat scripts/versions.sh $(find patches -type f | sort) | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "libwinevbs=$(cat scripts/versions.sh $(find patches/libwinevbs -type f | sort) | sha256sum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Cache emsdk
uses: https://github.com/actions/cache@v4
with:
path: emsdk
key: emsdk-${{ hashFiles('scripts/versions.sh') }}
key: emsdk-${{ steps.cache-keys.outputs.emsdk }}
- name: Cache wasm32 dependency builds
uses: https://github.com/actions/cache@v4
with:
path: build/deps-wasm32
key: deps-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/**') }}
key: deps-wasm32-${{ steps.cache-keys.outputs.deps-wasm32 }}
- name: Cache libwinevbs build
uses: https://github.com/actions/cache@v4
with:
path: vendor/libwinevbs/build
key: libwinevbs-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/libwinevbs/**') }}
key: libwinevbs-wasm32-${{ steps.cache-keys.outputs.libwinevbs }}
- name: Setup (fetch + patch vendor sources)
run: ./scripts/setup.sh
@@ -59,70 +72,27 @@ jobs:
npm install --no-save typescript
npx tsc -p package/tsconfig.json
- uses: https://github.com/actions/upload-artifact@v4
with:
name: vpinball-wasm-dist
path: dist/
publish:
name: Publish to npm registry
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- name: Install bison
run: |
sudo apt-get update
sudo apt-get install -y bison
- name: Cache emsdk
uses: https://github.com/actions/cache@v4
with:
path: emsdk
key: emsdk-${{ hashFiles('scripts/versions.sh') }}
- name: Cache wasm32 dependency builds
uses: https://github.com/actions/cache@v4
with:
path: build/deps-wasm32
key: deps-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/**') }}
- name: Cache libwinevbs build
uses: https://github.com/actions/cache@v4
with:
path: vendor/libwinevbs/build
key: libwinevbs-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/libwinevbs/**') }}
- name: Setup (fetch + patch vendor sources)
run: ./scripts/setup.sh
- name: Build wasm32 dependencies
run: |
source emsdk/emsdk_env.sh
./scripts/build-deps.sh
- name: Build vpinball
run: |
source emsdk/emsdk_env.sh
./scripts/build.sh
- name: Build npm wrapper
run: |
npm install --no-save typescript
npx tsc -p package/tsconfig.json
# Everything below only runs on a tag push (a release) - reuses the
# exact build just done above instead of the previous separate
# "publish" job, which had `needs: build` but still redundantly
# re-ran the *entire* checkout/cache/setup/build-deps/build/wrapper
# pipeline from scratch, doubling CI time on every release for no
# benefit (Gitea Actions' artifact-passing story is also awkward
# here - upload-artifact@v4+/download-artifact@v4+ aren't supported
# on Gitea at all, see the upload-artifact removal above).
- name: Set package version from the tag
if: startsWith(github.ref, 'refs/tags/')
run: npm pkg set version="${GITHUB_REF_NAME#v}"
# Scoped to this one registry host+path (via publishConfig.registry in
# package.json) rather than actions/setup-node's registry-url, which
# would set it as the *default* registry for every install.
- name: Configure registry auth for publish
if: startsWith(github.ref, 'refs/tags/')
run: npm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN"
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
- name: Publish to Gitea npm registry
if: startsWith(github.ref, 'refs/tags/')
run: npm publish
+2
View File
@@ -3,5 +3,7 @@
/dist/
/node_modules/
/emsdk
# generated by scripts/build.sh from vendor/vpinball, not source-controlled
/package/assets/
*.log
.DS_Store
+92 -42
View File
@@ -4,18 +4,33 @@
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).
- 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.
- 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).
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.
## Status: real PinMAME (`VPinMAME.Controller`) integration — real ROM-driven emulation confirmed running
Real-hardware ("SS"/ROM-based) tables `CreateObject("VPinMAME.Controller")` against the actual PinMAME emulation core (`libpinmame`, from the real [vpinball/pinmame](https://github.com/vpinball/pinmame) project — not a stub, not a mock), compiled to wasm32 and statically linked in. Confirmed by hands-on testing against a real community ROM-based table with its real, correctly-matched ROM zip supplied via `pinball.loadRom()`: the Controller identifies the requested game from PinMAME's actual ~2,900-entry built-in driver database (`PinMAME::Controller::SetGameName``Game found: name=hvymetal, description=Heavy Metal Meltdown, manufacturer=Bally, year=1987`), loads the ROM successfully, and PinMAME's own video and audio subsystems come up and run with no errors (`osd_create_display: 60.00 fps`; `OnAudioAvailable: format=INT16, channels=1, sampleRate=44100.00, framesPerSecond=60.00`) — this is real emulation actually executing, not just object creation succeeding. A missing/invalid ROM (verified separately, with two different non-matching ROM zips) fails cleanly instead — a logged error, the game just doesn't start, no page crash.
Getting a real OS-thread-based emulator core running inside a single-threaded WASM build required the same class of fix as the vsync/game-loop bugs above, just one level deeper — this time inside PinMAME's own CPU-execution loop, not vpinball's:
1. `PinmameRun()` spawned PinMAME's actual emulation main loop (`run_game()``run_machine()``run_machine_core()``cpu_run()`, MAME's real CPU-cycle scheduler) on a real `std::thread` — the same hard-abort-under-Emscripten bug as `RenderDevice::WaitForVSync()` above, just for an entire emulation session instead of one vsync wait. Fixed by splitting that whole call chain into a one-shot init, a step bounded to roughly one host video frame of emulated time (`timer_get_time()`-bounded, called once per frame from `Player::EmscriptenStepFrame()` itself), and a one-shot teardown — see `patches/pinmame/0004-emscripten-cooperative-scheduling.patch`.
2. Two small wasm32 portability bugs in libpinmame itself, unrelated to threading: an x86-only compiler intrinsic (`__rolq`/`__rorq`) reached by a portability guard that didn't exclude wasm32, and a bundled-zlib include path only wired up for the Windows build — see `patches/pinmame/0001-*` and `0003-*`.
3. An optional external-clock-sync feature (`time_fence`, mirrored by `Controller.TimeFence` in table scripts) used POSIX semaphores unavailable in this build; it now reports itself as unsupported instead, the same way every other optional sync/timing feature becomes a no-op under this build's single-threaded model — see `patches/pinmame/0002-*`.
A ROM zip can be supplied via `pinball.loadRom(gameName, romZipBytes)`, written to `/tables/pinmame/roms/<gameName>.zip` — the same table-relative convention vpinball's own PinMAME plugin already checks before any global settings path, so no extra configuration is needed. **Still not visually/interactively confirmed:** actual on-screen DMD/backglass output and hands-on switch/solenoid/scoring behavior during play — video/audio subsystem startup is confirmed (see above), but no one has yet watched a full game played against this integration. Table script compatibility for tables that don't need real ROM emulation — most tables, including the bundled default — is unaffected either way.
## Why a source-level port, not a reimplementation
@@ -38,10 +53,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.
@@ -54,38 +69,48 @@ 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):**
- `.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.
**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.
- **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).
- 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.
- **Real PinMAME (`VPinMAME.Controller`) ROM-driven emulation actually running**, not just object creation — see Status above for the exact confirmation (real video/audio subsystem startup against a real, correctly-matched ROM zip supplied via `pinball.loadRom()`).
**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 and by analogy to already-proven mechanisms, but unconfirmed end-to-end):**
- **PinMAME on-screen DMD/backglass output and hands-on gameplay** — the emulation session itself is confirmed running (see Status above), but no one has yet watched real DMD frames render or played a full game against it to confirm switch/solenoid/scoring behavior 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 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 rest of the plugin ecosystem beyond PinMAME** (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. (PinMAME itself is no longer in this category — see Status above.)
- **The modern `plugins/b2s` backglass plugin** — still not wired in, but meaningfully cheaper now than when this was last assessed: PinMAME's own static-linking plumbing (`patches/vpinball/0006-*`, see Status above) already established the exact pattern B2S would need — registering a statically-linked plugin via `MsgPluginManager::RegisterPlugin` instead of desktop VP's dynamic `/plugins` folder scan — so this is now mostly "repeat the same wiring for a second plugin" rather than new infrastructure. Still no `.directb2s`-equipped test table available to confirm it actually renders anything against. Revisit if/when one is available.
- 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:**
- 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. 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
Requires: `bison` ≥ 3.8.2, `curl`, `git`, `cmake` ≥ 3.25, `python3` (for the dev server), a POSIX shell. Emscripten itself is installed automatically by `setup.sh`. Expect a multi-gigabyte `emsdk/` + `build/` footprint and a first build in the tens of minutes (subsequent builds are much faster, especially with CI caching).
```bash
./scripts/setup.sh # installs emsdk + bison check, fetches vpinball + libwinevbs at pinned commits, applies patches/
./scripts/setup.sh # installs emsdk + bison check, fetches vpinball + libwinevbs + pinmame at pinned commits, applies patches/
source emsdk/emsdk_env.sh
./scripts/build-deps.sh # builds SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs for wasm32
./scripts/build-deps.sh # builds SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs/libpinmame for wasm32
./scripts/build.sh # configures + builds vpinball itself -> dist/vpinball.{js,wasm,data}
./scripts/dev-server.sh # serves dist/ locally for manual testing
```
@@ -95,27 +120,52 @@ source emsdk/emsdk_env.sh
## npm usage
```ts
import { loadPinball } from '@valknar/vpinball-wasm';
import { loadPinball, attachTouchControls } from '@valknar/vpinball-wasm';
const pinball = await loadPinball({ canvas: document.querySelector('canvas') });
pinball.start();
const canvas = document.querySelector('canvas');
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');
// Optional: supply a ROM zip for a real-hardware ("SS") table's
// CreateObject("VPinMAME.Controller")/Controller.Run() before start() -
// ROM files are copyrighted, so bring your own legally-obtained one.
// gameName is the short ROM name the table's script passes to LoadVPM
// (e.g. "hvymetal"), not the table's display title.
pinball.loadRom('hvymetal', romZipArrayBuffer);
```
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). Published as `@valknar/vpinball-wasm` on this project's Gitea npm registry — see `package.json`.
## 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.
- Real PinMAME (`VPinMAME.Controller`) integration for ROM-based tables — confirmed actually running real ROM-driven emulation (video/audio subsystem startup against a real ROM), but on-screen DMD/backglass output and hands-on switch/solenoid/scoring behavior during play are not yet confirmed — see Status and Roadmap. The rest of the plugin ecosystem (DOF/FlexDMD/etc., and the modern `plugins/b2s` backglass plugin) remains unwired — see Roadmap.
- No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
- 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
Visual Pinball itself is under a **mixed license**: the project has been migrating file-by-file from a legacy "old MAME"-like license to GPLv3+ since October 2020; each GPLv3+ file is marked `// license:GPLv3+` at its top, and any file without that marking remains under the legacy license. See `vendor/vpinball/LICENSE` (fetched by `setup.sh`) for the authoritative text — **do not treat this README as a substitute for reading it** before redistributing built artifacts. `libwinevbs` is LGPL-2.1 (Wine-derived) with a handful of small ATL header stubs of less certain provenance (see its own `README.md`). This project's own glue code (CMake integration, patches, npm wrapper) has no license conflict with either, but the combined built artifact's distribution terms are governed by vpinball's and libwinevbs's licenses, not just this repository's.
Visual Pinball itself is under a **mixed license**: the project has been migrating file-by-file from a legacy "old MAME"-like license to GPLv3+ since October 2020; each GPLv3+ file is marked `// license:GPLv3+` at its top, and any file without that marking remains under the legacy license. See `vendor/vpinball/LICENSE` (fetched by `setup.sh`) for the authoritative text — **do not treat this README as a substitute for reading it** before redistributing built artifacts. `libwinevbs` is LGPL-2.1 (Wine-derived) with a handful of small ATL header stubs of less certain provenance (see its own `README.md`). PinMAME is under a similar **mixed license** to vpinball's own — migrating file-by-file from the same inherited "old MAME" license to 3-Clause BSD, with each migrated file marked `// license:BSD-3-Clause`; see `vendor/pinmame/LICENSE` for the authoritative text. Separately, and unrelated to source licensing: PinMAME requires ROM images dumped from the real arcade/pinball hardware to actually run a game, which this project does not and cannot bundle — see `pinball.loadRom()` above. This project's own glue code (CMake integration, patches, npm wrapper) has no license conflict with any of the above, but the combined built artifact's distribution terms are governed by vpinball's, libwinevbs's, and PinMAME's licenses, not just this repository's.
## Attribution
- [Visual Pinball](https://github.com/vpinball/vpinball) — the engine this project ports.
- [libwinevbs](https://github.com/vpinball/libwinevbs) — the real VBScript interpreter (Wine-derived), compiled here to wasm32.
- [Wine](https://www.winehq.org/) — original source of the VBScript/OLE Automation engine libwinevbs extracts and packages.
- [PinMAME](https://github.com/vpinball/pinmame) — the real ROM/hardware emulation core `VPinMAME.Controller` wraps, compiled here to wasm32.
+105 -8
View File
@@ -1,26 +1,123 @@
<!doctype html>
<!--
Minimal usage example for @valknar/vpinball-wasm.
Full usage example for 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)
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>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<title>vpinball-wasm example</title>
<style>
body { margin: 0; background: #111; }
canvas { display: block; width: 100vw; height: 100vh; }
html, body { margin: 0; height: 100%; background: #111; overflow: hidden; }
#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>
</head>
<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>
<br />
<label>Optional: ROM zip for a real-hardware table (e.g. hvymetal.zip) <input type="file" id="rom-file" accept=".zip" /></label>
</div>
<button id="start-button" disabled>Loading...</button>
</div>
<button id="fullscreen-button" class="hidden">Fullscreen</button>
</div>
<script type="module">
import { loadPinball } from '../../dist/index.js';
import { loadPinball, attachTouchControls } from '../../dist/index.js';
const canvas = document.getElementById('canvas');
const pinball = await loadPinball({ canvas, baseUrl: '../../dist' });
pinball.start();
const overlay = document.getElementById('overlay');
const progressBar = document.getElementById('progress-bar');
const startButton = document.getElementById('start-button');
const fileInput = document.getElementById('table-file');
const romInput = document.getElementById('rom-file');
const fullscreenButton = document.getElementById('fullscreen-button');
let uploadedTableData;
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (file) uploadedTableData = await file.arrayBuffer();
});
// ROM zips are copyrighted - only pick one you're legally entitled to
// use. Named after its own short ROM/game name by convention (e.g.
// hvymetal.zip), which loadRom() below relies on to derive gameName.
let uploadedRomFile;
romInput.addEventListener('change', () => {
uploadedRomFile = romInput.files?.[0];
});
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', async () => {
if (uploadedTableData) {
pinball.loadTable(uploadedTableData);
}
if (uploadedRomFile) {
const gameName = uploadedRomFile.name.replace(/\.zip$/i, '');
pinball.loadRom(gameName, await uploadedRomFile.arrayBuffer());
}
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>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@valknar/vpinball-wasm",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@valknar/vpinball-wasm",
"version": "0.3.2",
"license": "SEE LICENSE IN LICENSE",
"devDependencies": {
"typescript": "^5.6.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@valknar/vpinball-wasm",
"version": "0.0.0",
"version": "0.3.2",
"description": "Visual Pinball's engine compiled to WebAssembly - real .vpx tables, real VBScript, WebGL2 rendering, in the browser",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
+96 -18
View File
@@ -1,20 +1,37 @@
import type { LoadPinballOptions, PinballInstance } from './types.js';
export type { LoadPinballOptions, PinballInstance } from './types.js';
export { attachTouchControls } from './touch-controls.js';
export type { TouchControlsOptions, TouchControlsHandle } from './touch-controls.js';
const DEFAULT_TABLE_PATH = '/tables/default.vpx';
const UPLOADED_TABLE_PATH = '/tables/uploaded.vpx';
/**
* Instantiates the WebAssembly build of Visual Pinball against the given
* canvas.
*
* 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".
* canvas and returns a handle to control its lifecycle.
*/
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
const baseUrl = options.baseUrl ?? '.';
// Resolved to an absolute URL against the *page's* location up front,
// rather than used as-is: a bare relative baseUrl works for
// Module.locateFile and wireDownloadProgress's fetch() (both resolve
// relative to the page), but not for the dynamic import() below, whose
// relative-specifier resolution is against *this module's own* URL
// (dist/index.js) instead - the two disagree for any page location that
// doesn't happen to cancel the difference out (as examples/basic/'s
// '../../dist' accidentally does). Resolving once here, to an absolute
// URL, makes every use of it agree.
const baseUrl = new URL(options.baseUrl ?? '.', document.baseURI).href.replace(/\/$/, '');
let tablePath = DEFAULT_TABLE_PATH;
const moduleArgs: Record<string, unknown> = {
canvas: options.canvas,
locateFile: (path: string) => `${baseUrl}/${path}`,
};
if (options.onProgress) {
await wireDownloadProgress(moduleArgs, baseUrl, options.onProgress);
}
// dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
// so importing it yields a factory function, not a module with side effects.
@@ -22,21 +39,25 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
moduleArgs: Record<string, unknown>
) => Promise<EmscriptenModule>;
const module = await factory({
canvas: options.canvas,
locateFile: (path: string) => `${baseUrl}/${path}`,
});
const module = await factory(moduleArgs);
options.onProgress?.(1);
if (options.tableData) {
mountTable(module, options.tableData);
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
tablePath = UPLOADED_TABLE_PATH;
}
return {
loadTable(vpxBytes: ArrayBuffer) {
mountTable(module, vpxBytes);
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
tablePath = UPLOADED_TABLE_PATH;
},
loadRom(gameName: string, romZipBytes: ArrayBuffer) {
module.FS.mkdirTree(PINMAME_ROMS_DIR);
module.FS.writeFile(`${PINMAME_ROMS_DIR}/${gameName}.zip`, new Uint8Array(romZipBytes));
},
start() {
module.ccall?.('vpinball_wasm_start', null, [], []);
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
},
stop() {
module.ccall?.('vpinball_wasm_stop', null, [], []);
@@ -44,16 +65,73 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
dispose() {
module.ccall?.('vpinball_wasm_dispose', null, [], []);
},
requestFullscreen() {
return options.canvas.requestFullscreen();
},
evalScript(script: string) {
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
},
};
}
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 {
FS: {
writeFile(path: string, data: Uint8Array): void;
mkdirTree(path: string): void;
};
ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown;
}
const PINMAME_ROMS_DIR = '/tables/pinmame/roms';
+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);
}
+51 -9
View File
@@ -1,5 +1,5 @@
/**
* Public TypeScript surface for @valknar/vpinball-wasm.
* Public TypeScript surface for vpinball-wasm.
*
* This wraps the raw Emscripten-generated module (dist/vpinball.js) so
* consumers don't need to know about ccall/cwrap/FS internals directly.
@@ -9,22 +9,64 @@ export interface LoadPinballOptions {
/** Canvas the engine renders into via SDL3's WebGL2 backend. */
canvas: HTMLCanvasElement;
/**
* Raw bytes of a .vpx table file to load on startup. If omitted, the
* bundled default table (package/assets/test000-default-table.vpx) is
* used instead.
* Raw bytes of a self-contained .vpx table file to play instead of the
* bundled default table. Real-world tables that reference an external
* .vbs script override or a Music/ folder are not supported this way -
* see the README's "Browser file-loading UX" section.
*/
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;
/**
* 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 {
/** 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;
/** Start (or resume) the simulation's main loop. */
/**
* Makes a ROM zip available to PinMAME (VPinMAME.Controller) for a
* ROM-based ("SS"/real-hardware) table's Controller.Run() call - written
* to /tables/pinmame/roms/<gameName>.zip, the table-relative convention
* PinMAME's plugin already looks for before any global settings path.
* gameName is the short ROM/game name the table's own script passes to
* LoadVPM (e.g. "hvymetal"), not the table's display title. Must be
* called before start() (or loadTable(), if switching tables) since
* Controller.Run() reads the filesystem synchronously at table-init
* time. ROM files are copyrighted - only supply ones you're legally
* entitled to use.
*/
loadRom(gameName: string, romZipBytes: ArrayBuffer): void;
/** Start the simulation's main loop. No-op if already running. */
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;
/** Tear down the instance and free its WebAssembly memory. */
/** Tear down the instance immediately and free its WebAssembly memory. */
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;
}
@@ -0,0 +1,64 @@
diff --git a/include/libwinevbs.h b/include/libwinevbs.h
index 3ba4df9..6cac7df 100644
--- a/include/libwinevbs.h
+++ b/include/libwinevbs.h
@@ -52,6 +52,14 @@ LIBWINEVBS_API const char* libwinevbs_hresult_name(HRESULT hr);
typedef struct {
void (*log)(libwinevbs_log_level_t level, const char* format, va_list args);
HRESULT (*create_object)(const WCHAR* progid, IClassFactory* cf, IUnknown** obj);
+ /* Answers a VBScript MsgBox() call. Must return synchronously (its result
+ is used by the calling script statement immediately) - e.g. a real,
+ blocking confirm()/alert() call under Emscripten. type is the raw
+ VBScript "buttons" argument (MB_OK=0, MB_OKCANCEL=1, MB_YESNO=4, ...);
+ title may be NULL. Return the matching button id (IDOK=1, IDCANCEL=2,
+ IDABORT=3, IDRETRY=4, IDIGNORE=5, IDYES=6, IDNO=7), matching vbOK..vbNo.
+ If unset, MsgBox always answers IDOK/vbOK without asking anything. */
+ int (*msgbox)(const char* prompt, int type, const char* title);
} libwinevbs_callbacks_t;
LIBWINEVBS_API void libwinevbs_init(const libwinevbs_callbacks_t* callbacks);
diff --git a/src/libwinevbs.c b/src/libwinevbs.c
index 7b3d6f0..91091e4 100644
--- a/src/libwinevbs.c
+++ b/src/libwinevbs.c
@@ -32,6 +32,14 @@ HRESULT libwinevbs_create_object(const WCHAR* progid, IClassFactory* cf, IUnknow
return CLASS_E_CLASSNOTAVAILABLE;
}
+int libwinevbs_msgbox(const char* prompt, int type, const char* title)
+{
+ if (g_callbacks.msgbox)
+ return g_callbacks.msgbox(prompt, type, title);
+
+ return 1; /* IDOK/vbOK - no host callback registered, so just proceed */
+}
+
const char* libwinevbs_hresult_name(HRESULT hr)
{
switch (hr) {
diff --git a/wine/dlls/vbscript/global.c b/wine/dlls/vbscript/global.c
index 0e0e144..351f967 100644
--- a/wine/dlls/vbscript/global.c
+++ b/wine/dlls/vbscript/global.c
@@ -33,6 +33,7 @@
#include <locale.h>
#include "scrrun_private.h"
HRESULT libwinevbs_create_object(const WCHAR *progid, IClassFactory* cf, IUnknown** obj);
+int libwinevbs_msgbox(const char *prompt, int type, const char *title);
extern HRESULT WINAPI WshShellFactory_CreateInstance(IClassFactory*,IUnknown*,REFIID,void**);
#endif
@@ -2834,9 +2835,12 @@ static HRESULT Global_MsgBox(BuiltinDisp *This, VARIANT *args, unsigned args_cnt
hres = show_msgbox(This->ctx, prompt, type, title, res);
#else
if(SUCCEEDED(hres)) {
- char buf[2048];
+ char buf[2048], title_buf[256] = {0};
WideCharToMultiByte(CP_ACP, 0, prompt, -1, buf, sizeof(buf) - 1, NULL, NULL);
+ if (title)
+ WideCharToMultiByte(CP_ACP, 0, title, -1, title_buf, sizeof(title_buf) - 1, NULL, NULL);
libwinevbs_log(LIBWINEVBS_LOG_INFO, "vbscript: MsgBox prompt=%s", buf);
+ hres = return_short(res, libwinevbs_msgbox(buf, type, title ? title_buf : NULL));
}
#endif
@@ -0,0 +1,22 @@
diff --git a/src/common.h b/src/common.h
index f0249b6..854bd16 100644
--- a/src/common.h
+++ b/src/common.h
@@ -533,7 +533,7 @@ INLINE unsigned long long rotl_64(const unsigned long long x, const unsigned int
{
#ifdef _MSC_VER
return _rotl64(x, count);
-#elif !defined(__arm__) && !defined(__aarch64__) && (defined(__INTEL_COMPILER) || (defined(__GNUC__) && (__GNUC__ > 3)) || defined(__clang__))
+#elif !defined(__arm__) && !defined(__aarch64__) && !defined(__EMSCRIPTEN__) && !defined(__wasm__) && (defined(__INTEL_COMPILER) || (defined(__GNUC__) && (__GNUC__ > 3)) || defined(__clang__))
return __rolq(x, count);
#else
return (x<<count) | (x>>( (unsigned int)(-(int)count)&63 )); // -count&63 instead of 64-count to handle count==0
@@ -555,7 +555,7 @@ INLINE unsigned long long rotr_64(const unsigned long long x, const unsigned int
{
#ifdef _MSC_VER
return _rotr64(x, count);
-#elif !defined(__arm__) && !defined(__aarch64__) && (defined(__INTEL_COMPILER) || (defined(__GNUC__) && (__GNUC__ > 3)) || defined(__clang__))
+#elif !defined(__arm__) && !defined(__aarch64__) && !defined(__EMSCRIPTEN__) && !defined(__wasm__) && (defined(__INTEL_COMPILER) || (defined(__GNUC__) && (__GNUC__ > 3)) || defined(__clang__))
return __rorq(x, count);
#else
return (x>>count) | (x<<( (unsigned int)(-(int)count)&63 )); // -count&63 instead of 64-count to handle count==0
@@ -0,0 +1,39 @@
diff --git a/src/cpuexec.c b/src/cpuexec.c
index ca7401f..07db6a1 100644
--- a/src/cpuexec.c
+++ b/src/cpuexec.c
@@ -943,6 +943,34 @@ void time_fence_exit()
}
}
+#elif defined(__EMSCRIPTEN__)
+
+// No cross-thread wait primitive is used here: this build is single-threaded
+// (see vpinball-wasm's README on why pthreads/SharedArrayBuffer are out of
+// scope), and time_fence is purely an *optional* external-clock-sync feature
+// (mirrored by Controller.TimeFence in controller.vbs) - reporting it as
+// unsupported just means the emulator paces itself on its own internal
+// timing instead of syncing to the host's clock, which is what every other
+// platform this library runs on outside of this fence do anyway.
+int time_fence_is_supported()
+{
+ return 0;
+}
+
+void time_fence_post()
+{
+}
+
+int time_fence_wait(double secs)
+{
+ (void)secs;
+ return 0;
+}
+
+void time_fence_exit()
+{
+}
+
#else
#include <semaphore.h>
@@ -0,0 +1,20 @@
diff --git a/cmake/libpinmame/CMakeLists.txt b/cmake/libpinmame/CMakeLists.txt
index a2dfd1a..119e3ec 100644
--- a/cmake/libpinmame/CMakeLists.txt
+++ b/cmake/libpinmame/CMakeLists.txt
@@ -745,6 +745,7 @@ set(PINMAME_INCLUDE_DIRS
src/cpu/m68000/generated_by_m68kmake
src/unix
src/unix/sysdep
+ ext/zlib
)
@@ -752,7 +753,6 @@ if(PLATFORM STREQUAL "win" OR PLATFORM STREQUAL "win-mingw")
list(APPEND PINMAME_INCLUDE_DIRS
src/vc
src/windows
- ext/zlib
)
endif()
@@ -0,0 +1,402 @@
diff --git a/src/cpuexec.c b/src/cpuexec.c
index 07db6a1..19da31a 100644
--- a/src/cpuexec.c
+++ b/src/cpuexec.c
@@ -466,6 +466,78 @@ void cpu_run(void)
#endif
}
+#if defined(LIBPINMAME) && defined(__EMSCRIPTEN__)
+/* Emscripten is single-threaded, so cpu_run()'s own blocking "while
+ (!time_to_quit) { ... }" loop above can't run on a real OS thread the way
+ it does everywhere else libpinmame runs (see PinmameRun() in
+ libpinmame.cpp). These three entry points split that same loop, using the
+ exact same globals/logic, into a one-shot init, a step callable once per
+ host video frame (bounded to roughly one frame of emulated time via
+ timer_get_time(), the same clock cpu_timeslice()'s own time_fence logic
+ above already uses), and a one-shot teardown - so the host's own
+ per-frame callback can drive it cooperatively instead. */
+
+void cpu_run_emscripten_init(void)
+{
+ time_to_quit = 0;
+ cpu_pre_run();
+ time_to_reset = 0;
+ time_fence_global_offset = -options.time_fence;
+}
+
+/* Returns 1 if the emulation is still running (call again next frame), or 0
+ once it has fully quit (cpu_post_run() has already been invoked). */
+int cpu_run_emscripten_step(void)
+{
+ /* usrintrf.c's per-frame video update contains a "while (g_fPause) {
+ ...; draw_screen(); ... }" busy-wait (see the VPINMAME/LIBPINMAME
+ branch around updatescreen()) that assumes a real OS thread will
+ later flip g_fPause back to 0 from outside it. We're single-threaded
+ here, so entering that loop at all would spin forever burning 100%
+ CPU with no way out - nothing else can ever run to clear the flag.
+ Skip cpu_timeslice() (and therefore that loop) entirely while paused;
+ the emulation simply doesn't advance until Controller.Pause is set
+ back to False and this function is called again next frame. */
+ extern int g_fPause;
+ if (g_fPause)
+ return 1;
+
+ const double frame_target = timer_get_time() + (1.0 / 60.0);
+
+ while (!time_to_quit && !time_to_reset && timer_get_time() < frame_target)
+ {
+ profiler_mark(PROFILER_EXTRA);
+
+ if (loadsave_schedule != LOADSAVE_NONE)
+ handle_loadsave();
+
+ cpu_timeslice();
+
+ extern int libpinmame_time_to_quit(void);
+ if (libpinmame_time_to_quit())
+ time_to_quit = 1;
+
+ profiler_mark(PROFILER_END);
+ }
+
+ if (time_to_reset && !time_to_quit)
+ {
+ cpu_post_run();
+ cpu_pre_run();
+ time_to_reset = 0;
+ time_fence_global_offset = -options.time_fence;
+ }
+
+ if (time_to_quit)
+ {
+ cpu_post_run();
+ return 0;
+ }
+
+ return 1;
+}
+#endif
+
/*************************************
diff --git a/src/libpinmame/libpinmame.cpp b/src/libpinmame/libpinmame.cpp
index 6966d77..755d762 100644
--- a/src/libpinmame/libpinmame.cpp
+++ b/src/libpinmame/libpinmame.cpp
@@ -1104,11 +1104,52 @@ PINMAMEAPI PINMAME_STATUS PinmameRun(const char* const p_name)
vp_init();
+#ifdef __EMSCRIPTEN__
+ // No real OS thread: Emscripten builds here are single-threaded (see
+ // vpinball-wasm's README on why pthreads/SharedArrayBuffer are out of
+ // scope), so run_game()'s own blocking call chain - which StartGame()
+ // otherwise runs on _p_gameThread - has been split in mame.c/cpuexec.c
+ // into a one-shot init (called synchronously right here) plus a step
+ // the host calls once per video frame via PinmameEmscriptenStep().
+ memset(_mechInit, 0, sizeof(_mechInit));
+ memset(_mechInfo, 0, sizeof(_mechInfo));
+
+ extern int run_game_emscripten_init(int game);
+ if (run_game_emscripten_init(gameNum) != 0)
+ {
+ OnStateChange(0);
+ return PINMAME_STATUS_GAME_NOT_FOUND;
+ }
+
+ OnStateChange(1);
+#else
_p_gameThread = new std::thread(StartGame, gameNum);
+#endif
return PINMAME_STATUS_OK;
}
+#ifdef __EMSCRIPTEN__
+/******************************************************
+ * PinmameEmscriptenStep
+ *
+ * Must be called once per host video frame (e.g. from the same
+ * requestAnimationFrame-driven callback that steps vpinball's own frame)
+ * while the emulator is running - see PinmameRun's __EMSCRIPTEN__ branch
+ * above for why this exists instead of a real thread.
+ ******************************************************/
+
+PINMAMEAPI void PinmameEmscriptenStep(void)
+{
+ if (!_isRunning)
+ return;
+
+ extern int run_game_emscripten_step(void);
+ if (!run_game_emscripten_step())
+ OnStateChange(0);
+}
+#endif
+
/******************************************************
* PinmameIsRunning
******************************************************/
@@ -1161,6 +1202,19 @@ PINMAMEAPI int PinmameIsPaused()
PINMAMEAPI void PinmameStop()
{
+#ifdef __EMSCRIPTEN__
+ // No game thread to join here (see PinmameRun's __EMSCRIPTEN__ branch) -
+ // just request the stop. The next PinmameEmscriptenStep() call notices
+ // libpinmame_time_to_quit() via cpu_run_emscripten_step(), runs the same
+ // teardown chain a real quit would, and calls OnStateChange(0) itself.
+ if (_isRunning)
+ {
+ g_fPause = 0;
+ _timeToQuit = 1;
+ }
+ return;
+#endif
+
if (!_p_gameThread) {
if (_isRunning) {
libpinmame_log_error("PinmameStop(): run state is %d but game thread handle is null; forcing stopped state.", _isRunning);
diff --git a/src/libpinmame/libpinmame.h b/src/libpinmame/libpinmame.h
index dc604df..d3aacc1 100644
--- a/src/libpinmame/libpinmame.h
+++ b/src/libpinmame/libpinmame.h
@@ -453,6 +453,11 @@ PINMAMEAPI PINMAME_STATUS PinmamePause(const int pause);
PINMAMEAPI int PinmameIsPaused();
PINMAMEAPI PINMAME_STATUS PinmameReset();
PINMAMEAPI void PinmameStop();
+#ifdef __EMSCRIPTEN__
+// Must be called once per host video frame while running - see PinmameRun's
+// __EMSCRIPTEN__ branch in libpinmame.cpp for why.
+PINMAMEAPI void PinmameEmscriptenStep(void);
+#endif
PINMAMEAPI PINMAME_HARDWARE_GEN PinmameGetHardwareGen();
PINMAMEAPI int PinmameGetSwitch(const int swNo);
PINMAMEAPI void PinmameSetSwitch(const int swNo, const int state);
diff --git a/src/mame.c b/src/mame.c
index d0aea1a..2f8c4d5 100644
--- a/src/mame.c
+++ b/src/mame.c
@@ -229,6 +229,16 @@ static void shutdown_machine(void);
static int run_machine(void);
static void run_machine_core(void);
+#if defined(LIBPINMAME) && defined(__EMSCRIPTEN__)
+/* See cpu_run_emscripten_init's comment in cpuexec.c for why these exist. */
+void cpu_run_emscripten_init(void);
+int cpu_run_emscripten_step(void);
+void run_machine_core_emscripten_init(void);
+int run_machine_core_emscripten_step(void);
+int run_machine_emscripten_init(void);
+int run_machine_emscripten_step(void);
+#endif
+
#ifdef MAME_DEBUG
static int validitychecks(void);
#endif
@@ -355,6 +365,71 @@ int run_game(int game)
return err;
}
+#if defined(LIBPINMAME) && defined(__EMSCRIPTEN__)
+/* Mirrors run_game() above, split around run_machine() the same way that's
+ split around run_machine_core(). This is the top of the whole init/step/
+ teardown chain PinmameRun()/PinmameEmscriptenStep() in libpinmame.cpp
+ drive - see cpu_run_emscripten_init's comment in cpuexec.c for why the
+ chain exists at all. Returns 0 on success, non-zero on failure (matching
+ run_game()'s own convention) - a failure here (e.g. ROM not found) means
+ no teardown call is needed, since nothing that needs undoing succeeded. */
+int run_game_emscripten_init(int game)
+{
+ begin_resource_tracking();
+
+ memset(Machine, 0, sizeof(*Machine));
+ Machine->gamedrv = gamedrv = drivers[game];
+ expand_machine_driver(gamedrv->drv, &internal_drv);
+ Machine->drv = &internal_drv;
+
+ if (init_game_options())
+ return 1;
+
+ cpu_loadsave_reset();
+ bailing = 0;
+
+ if (osd_init())
+ {
+ bail_and_print("Unable to initialize system");
+ return 1;
+ }
+
+ begin_resource_tracking();
+
+ if (init_machine())
+ {
+ bail_and_print("Unable to initialize machine emulation");
+ end_resource_tracking();
+ osd_exit();
+ return 1;
+ }
+
+ if (run_machine_emscripten_init())
+ {
+ bail_and_print("Unable to start machine emulation");
+ shutdown_machine();
+ end_resource_tracking();
+ osd_exit();
+ return 1;
+ }
+
+ return 0;
+}
+
+/* Returns 1 while still running, 0 once fully torn down. */
+int run_game_emscripten_step(void)
+{
+ if (run_machine_emscripten_step())
+ return 1;
+
+ shutdown_machine();
+ end_resource_tracking();
+ osd_exit();
+ end_resource_tracking();
+ return 0;
+}
+#endif
+
/*-------------------------------------------------
@@ -547,6 +622,71 @@ static int run_machine(void)
return res;
}
+#if defined(LIBPINMAME) && defined(__EMSCRIPTEN__)
+/* Mirrors run_machine() above, split around run_machine_core() the same way
+ that's split around cpu_run(). Returns 0 on success (matching
+ run_machine()'s own convention), non-zero on failure. */
+int run_machine_emscripten_init(void)
+{
+ if (vh_open())
+ {
+ bail_and_print("Unable to start video emulation");
+ return 1;
+ }
+
+ tilemap_init();
+
+ if (Machine->drv->video_start && (*Machine->drv->video_start)())
+ {
+ bail_and_print("Unable to start video emulation");
+ tilemap_close();
+ vh_close();
+ return 1;
+ }
+
+ if (sound_start())
+ {
+ bail_and_print("Unable to start audio emulation");
+ if (Machine->drv->video_stop)
+ (*Machine->drv->video_stop)();
+ tilemap_close();
+ vh_close();
+ return 1;
+ }
+
+ {
+ int region;
+ /* free memory regions allocated with REGIONFLAG_DISPOSE (typically gfx roms) */
+ for (region = 0; region < MAX_MEMORY_REGIONS; region++)
+ if (Machine->memory_region[region].flags & ROMREGION_DISPOSE)
+ {
+ size_t i;
+ for (i = 0; i < memory_region_length(region); i++)
+ memory_region(region)[i] = rand();
+ free(Machine->memory_region[region].base);
+ Machine->memory_region[region].base = 0;
+ }
+ }
+
+ run_machine_core_emscripten_init();
+ return 0;
+}
+
+/* Returns 1 while still running, 0 once fully torn down. */
+int run_machine_emscripten_step(void)
+{
+ if (run_machine_core_emscripten_step())
+ return 1;
+
+ sound_stop();
+ if (Machine->drv->video_stop)
+ (*Machine->drv->video_stop)();
+ tilemap_close();
+ vh_close();
+ return 0;
+}
+#endif
+
/*-------------------------------------------------
@@ -620,6 +760,60 @@ void run_machine_core(void)
}
}
+#if defined(LIBPINMAME) && defined(__EMSCRIPTEN__)
+/* Mirrors run_machine_core() above (same globals, same calls), split around
+ cpu_run() - see cpu_run_emscripten_init/_step/PinmameRun's own comment for
+ why. The disclaimer/gamewarnings/gameinfo splash screens run_machine_core()
+ shows natively are skipped entirely here: gamewarnings is already excluded
+ for LIBPINMAME builds above, and disclaimer/gameinfo are native-UI-only
+ concerns with nothing meaningful to display in an embedded/headless
+ context like this one. */
+void run_machine_core_emscripten_init(void)
+{
+ artwork_enable(0);
+ init_user_interface();
+ artwork_enable(1);
+
+ if (!gamedrv->rom)
+ options.cheat = 0;
+ if (options.cheat)
+ InitCheat();
+
+ if (Machine->drv->nvram_handler)
+ {
+ mame_file *nvram_file = mame_fopen(Machine->gamedrv->name, 0, FILETYPE_NVRAM, 0);
+ (*Machine->drv->nvram_handler)(nvram_file, 0);
+ if (nvram_file)
+ mame_fclose(nvram_file);
+ }
+
+ cpu_run_emscripten_init();
+}
+
+/* Returns 1 while still running, 0 once cpu_run's own teardown has run. */
+int run_machine_core_emscripten_step(void)
+{
+ if (cpu_run_emscripten_step())
+ return 1;
+
+ if (Machine->drv->nvram_handler)
+ {
+ mame_file *nvram_file = mame_fopen(Machine->gamedrv->name, 0, FILETYPE_NVRAM, 1);
+ if (nvram_file != NULL)
+ {
+ (*Machine->drv->nvram_handler)(nvram_file, 1);
+ mame_fclose(nvram_file);
+ }
+ }
+
+ if (options.cheat)
+ StopCheat();
+
+ save_input_port_settings();
+ return 0;
+}
+#endif
+
/*-------------------------------------------------
@@ -1,5 +1,5 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1fcf242..f9338b2 100644
index 1fcf242..745b91b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -50,6 +50,7 @@ set(_vpx_valid_combos
@@ -10,7 +10,7 @@ index 1fcf242..f9338b2 100644
DX9-windows)
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _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()
@@ -41,6 +41,7 @@ index 1fcf242..f9338b2 100644
+
+ add_executable(vpinball
+ ${VPX_STANDALONE_SOURCES}
+ ${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
+ )
+
+ target_include_directories(vpinball PUBLIC
@@ -108,9 +109,26 @@ index 1fcf242..f9338b2 100644
+ # staged directories must exist *before* linking, not after.
+ add_custom_command(TARGET vpinball PRE_LINK
+ 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 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}"
+ )
+ foreach(_shader IN LISTS VPX_GL_SHADERS)
@@ -134,7 +152,7 @@ index 1fcf242..f9338b2 100644
# iOS and Android libvpinball build
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()
@@ -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)
@@ -0,0 +1,112 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 745b91b..edb4112 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -810,8 +810,29 @@ elseif(PLATFORM STREQUAL "emscripten")
src/input/OpenPinDevHandler.cpp
)
+ # PinMAME VPX-side plugin glue (registers VPinMAME.Controller for real
+ # ROM-driven scoring/switches/solenoids) - statically linked and
+ # registered from player.cpp instead of desktop VP's dynamic /plugins
+ # folder scan, same as the built-in "vpx" plugin already is. The real
+ # emulation core it drives (libpinmame.a) is staged into
+ # VPINBALL_WASM_DEPS_DIR by vpinball-wasm's own build-deps.sh (see
+ # patches/pinmame/ for the 3 portability fixes that wasm32 build needed).
+ set(VPX_PINMAME_PLUGIN_SOURCES
+ plugins/pinmame/common.cpp
+ plugins/pinmame/PinMAMEPlugin.cpp
+ plugins/pinmame/Controller.cpp
+ plugins/pinmame/ControllerSettings.cpp
+ plugins/pinmame/Game.cpp
+ plugins/pinmame/Games.cpp
+ plugins/pinmame/GameSettings.cpp
+ plugins/pinmame/Rom.cpp
+ plugins/pinmame/Roms.cpp
+ plugins/pinmame/Settings.cpp
+ )
+
add_executable(vpinball
${VPX_STANDALONE_SOURCES}
+ ${VPX_PINMAME_PLUGIN_SOURCES}
${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
)
@@ -824,6 +845,7 @@ elseif(PLATFORM STREQUAL "emscripten")
${VPINBALL_WASM_DEPS_DIR}/include/libwinevbs/wine/include
src
plugins
+ plugins/pinmame
)
target_compile_definitions(vpinball PRIVATE
@@ -863,6 +885,7 @@ elseif(PLATFORM STREQUAL "emscripten")
freetype
freeimage
winevbs
+ pinmame
glad
)
diff --git a/src/core/player.cpp b/src/core/player.cpp
index 60c7d5f..ec9ebdd 100644
--- a/src/core/player.cpp
+++ b/src/core/player.cpp
@@ -79,6 +79,19 @@ using namespace VPX;
// leave as-is as e.g. VPM relies on this
#define WIN32_PLAYER_WND_CLASSNAME _T("VPPlayer")
+#ifdef __EMSCRIPTEN__
+// Statically-linked player plugin entry points (see the __EMSCRIPTEN__
+// branch in Player::Player below) - same declaration shape used for the
+// iOS/Android static plugin table in lib/src/VPinballLib.cpp, just declared
+// at file scope here since MSGPI_EXPORT's __attribute__((visibility(...)))
+// isn't accepted on a block-scope declaration.
+MSGPI_EXPORT void MSGPIAPI PinMAMEPluginLoad(const uint32_t sessionId, const MsgPluginAPI* api);
+MSGPI_EXPORT void MSGPIAPI PinMAMEPluginUnload();
+
+// For PinmameEmscriptenStep(), called once per frame in EmscriptenStepFrame()
+// below - see that function's own comment.
+#include "pinmame/libpinmame.h"
+#endif
Player::Player(PinTable *const table, const PlayMode playMode)
: m_ptable(table)
@@ -128,6 +141,24 @@ Player::Player(PinTable *const table, const PlayMode playMode)
#ifdef __LIBVPINBALL__
VPinballLib::VPinballLib::SetupStaticPlugins(m_pluginManager);
+#elif defined(__EMSCRIPTEN__)
+ // A browser sandbox has no dynamic-library-loading /plugins folder to
+ // scan (see the ScanPluginFolder call in the #else branch below), so
+ // player plugins have to be statically linked into this same wasm binary
+ // and registered by function pointer instead - the same technique
+ // VPinballLib::SetupStaticPlugins already uses for the iOS/Android
+ // library build (lib/src/VPinballLib.cpp), just scoped here to the one
+ // plugin vpinball-wasm actually ships: PinMAME, which is what lets
+ // table scripts get a real (non-Nothing) VPinMAME.Controller for ROM-
+ // driven scoring/switches/solenoids instead of failing on
+ // CreateObject("VPinMAME.Controller") - see patches/pinmame/ for the
+ // wasm32 portability fixes its emulation core (libpinmame) needed.
+ //
+ // Registered only, not Load()-ed here: the per-plugin enable/Load loop
+ // just below (shared with the dynamic-scan #else branch) already does
+ // that for every entry in m_pluginManager.GetPlugins(), so calling Load()
+ // here too would just load it twice.
+ m_pluginManager.RegisterPlugin("PinMAME", "PinMAME", "PinMAME", "", "", "https://github.com/vpinball/pinmame", &PinMAMEPluginLoad, &PinMAMEPluginUnload);
#else
class SDLModuleLoader final : public MsgPI::MsgModuleLoader
{
@@ -2002,6 +2033,12 @@ bool Player::EmscriptenStepFrame()
if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
return false;
+ // PinMAME (VPinMAME.Controller for ROM-driven tables) is likewise
+ // single-threaded here instead of running on its own real thread - see
+ // PinmameEmscriptenStep's own comment in libpinmame.cpp. A no-op when no
+ // ROM-based table has called Controller.Run().
+ PinmameEmscriptenStep();
+
UpdateGameLogic();
PrepareFrame();
UpdateGameLogic();
@@ -0,0 +1,60 @@
diff --git a/src/core/VPApp.cpp b/src/core/VPApp.cpp
index 7175dd2..f05a324 100644
--- a/src/core/VPApp.cpp
+++ b/src/core/VPApp.cpp
@@ -36,6 +36,10 @@
#include <libwinevbs/libwinevbs.h>
#endif
+#ifdef __EMSCRIPTEN__
+#include <emscripten.h>
+#endif
+
#include "parts/ball.h"
#include "parts/timer.h"
#include "parts/flipper.h"
@@ -273,6 +277,34 @@ int VPApp::GetLogicalNumberOfProcessors() const
return m_logicalNumberOfProcessors;
}
+#ifdef __EMSCRIPTEN__
+// VBScript's MsgBox() must return synchronously (its VARIANT result is used
+// right after the call, in the same script statement) - window.confirm()/
+// alert() are the only browser APIs that block JS execution and return a
+// value synchronously, so they're used here directly rather than routing
+// through any async/JS-callback mechanism the host page might otherwise want
+// to customize the look of (which would require Asyncify to suspend the
+// call, since single-threaded builds have no other way to block for it).
+// type is the raw VBScript "buttons" argument (MB_OK=0, MB_OKCANCEL=1,
+// MB_ABORTRETRYIGNORE=2, MB_YESNOCANCEL=3, MB_YESNO=4, MB_RETRYCANCEL=5).
+// The returned id matches vbOK(1)/vbCancel(2)/vbYes(6)/vbNo(7).
+static int EmscriptenMsgBox(const char* prompt, int type, const char* title)
+{
+ const string message = (title && title[0]) ? (string(title) + "\n\n" + prompt) : string(prompt);
+ switch (type & 0x0F)
+ {
+ case 1: // MB_OKCANCEL
+ return EM_ASM_INT({ return confirm(UTF8ToString($0)) ? 1 : 0; }, message.c_str()) ? 1 /* IDOK */ : 2 /* IDCANCEL */;
+ case 3: // MB_YESNOCANCEL - Cancel isn't distinguishable from No via confirm(), best effort
+ case 4: // MB_YESNO
+ return EM_ASM_INT({ return confirm(UTF8ToString($0)) ? 1 : 0; }, message.c_str()) ? 6 /* IDYES */ : 7 /* IDNO */;
+ default:
+ EM_ASM_({ alert(UTF8ToString($0)); }, message.c_str());
+ return 1; // IDOK
+ }
+}
+#endif
+
void VPApp::InitInstance()
{
std::filesystem::path iniFileName = m_commandLineCustomSettingsFileName;
@@ -325,6 +357,9 @@ void VPApp::InitInstance()
delete[] buffer;
}
};
+#ifdef __EMSCRIPTEN__
+ callbacks.msgbox = &EmscriptenMsgBox;
+#endif
libwinevbs_init(&callbacks);
#endif
+24
View File
@@ -133,6 +133,30 @@ cp -r "$VENDOR_DIR/libwinevbs/wine/include/"* "$DEPS_DIR/include/libwinevbs/wine
cp -r "$VENDOR_DIR/libwinevbs/atl/include/"* "$DEPS_DIR/include/libwinevbs/atl/include/"
cp -r "$VENDOR_DIR/libwinevbs/atlmfc/include/"* "$DEPS_DIR/include/libwinevbs/atlmfc/include/"
# --- libpinmame (the real ROM/hardware emulation core VPinMAME.Controller --
# --- wraps on every other platform; validated end-to-end for wasm32 by this
# --- project's own pinmame-wasm spike - see patches/pinmame/ for the 3
# --- portability fixes it needed. ARCH=wasm32 (not x86/x64/arm64/aarch64)
# --- makes its own CMakeLists.txt skip the asmjit-based ARM7 JIT backend and
# --- fall back to its portable interpreter, exactly as it already does for
# --- 32-bit arm targets - no patch needed for that part. libpinmame's
# --- CMakeLists.txt lives at cmake/libpinmame/ but its source paths are all
# --- relative to the repo root, so upstream's own CI copies it there first;
# --- mirrored here rather than patched, to stay a plain file copy diff-free.
if ! cache_check "$VENDOR_DIR/pinmame" "built-${PINMAME_SHA}"; then
echo "== Building libpinmame for wasm32 =="
cp "$VENDOR_DIR/pinmame/cmake/libpinmame/CMakeLists.txt" "$VENDOR_DIR/pinmame/CMakeLists.txt"
( cd "$VENDOR_DIR/pinmame" && emcmake cmake -DPLATFORM=linux -DARCH=wasm32 \
-DBUILD_STATIC=ON -DBUILD_SHARED=OFF -DCMAKE_BUILD_TYPE=Release -B build \
&& cmake --build build --target pinmame_static -- -j"$NUM_PROCS" )
echo "built-${PINMAME_SHA}" > "$VENDOR_DIR/pinmame/.cache-sha"
fi
cp "$VENDOR_DIR/pinmame/build/libpinmame.a" "$DEPS_DIR/lib/"
mkdir -p "$DEPS_DIR/include/pinmame"
cp "$VENDOR_DIR/pinmame/src/libpinmame/libpinmame.h" "$DEPS_DIR/include/pinmame/"
cp "$VENDOR_DIR/pinmame/src/libpinmame/PinMAMEPlugin.h" "$DEPS_DIR/include/pinmame/"
echo ""
echo "== build-deps.sh complete: $DEPS_DIR =="
ls -la "$DEPS_DIR/lib"
+15 -2
View File
@@ -40,13 +40,26 @@ EMSCRIPTEN_LINK_FLAGS=(
-sMAX_WEBGL_VERSION=2
-sALLOW_MEMORY_GROWTH=1
-sMODULARIZE=1
-sEXPORT_ES6=1
-sEXPORT_NAME=VPinballModule
-sENVIRONMENT=web
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap
-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
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
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
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 "== build.sh complete =="
+8 -1
View File
@@ -52,7 +52,12 @@ fetch_pinned() {
rm -f "/tmp/${name}-${sha}.tar.gz"
git -C "$dest" init -q
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"
}
@@ -75,9 +80,11 @@ apply_patches() {
mkdir -p "$VENDOR_DIR"
fetch_pinned "vpinball" "https://github.com/vpinball/vpinball" "$VPINBALL_SHA" "$VENDOR_DIR/vpinball"
fetch_pinned "libwinevbs" "https://github.com/vpinball/libwinevbs" "$LIBWINEVBS_SHA" "$VENDOR_DIR/libwinevbs"
fetch_pinned "pinmame" "https://github.com/vpinball/pinmame" "$PINMAME_SHA" "$VENDOR_DIR/pinmame"
apply_patches "vpinball" "$VENDOR_DIR/vpinball"
apply_patches "libwinevbs" "$VENDOR_DIR/libwinevbs"
apply_patches "pinmame" "$VENDOR_DIR/pinmame"
echo ""
echo "== Setup complete =="
+11
View File
@@ -20,6 +20,17 @@ SDL_IMAGE_SHA=bec9134a26c7d0f31b36d6083c25296e04cabff5
SDL_TTF_SHA=a1ce3670aec736ecbf0936c43f2f0cc53aa61e5b
FREEIMAGE_SHA=b1613452a0c3849d43ac877b154cf51ff9e078d3
# PinMAME (the real ROM/hardware emulation core VPinMAME.Controller wraps on
# every other platform). Mirrors vpinball's own platforms/config.sh pin
# exactly (like LIBWINEVBS_SHA/SDL_SHA above) - vpinball's plugins/pinmame/
# glue code is written against this specific libpinmame API surface (e.g.
# PinmameSetMsgAPI), so a newer/older pinmame commit than this one won't
# necessarily still match it. Validated end-to-end for wasm32 by this
# project's own pinmame-wasm spike (compiles + runs the full driver database
# under Emscripten - see patches/pinmame/ for the 3 portability fixes that
# spike needed; re-verified against this exact pinned commit).
PINMAME_SHA=23321ec7de6dfd563a1c64153bc75f26e5059b9f
# Emscripten SDK version already validated by both spikes
# (spikes/libwinevbs-wasm and spikes/sdl3-gles3-wasm).
EMSDK_VERSION=6.0.8