Compare commits
19
Commits
0a63835689
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f7cb6afa | ||
|
|
99ae5b9eda | ||
|
|
5570955bc5 | ||
|
|
6c8d08e618 | ||
|
|
a7b85ec8a4 | ||
|
|
9479cea280 | ||
|
|
d91ef0c96d | ||
|
|
ad66bbf6be | ||
|
|
243c975cec | ||
|
|
536e09dff4 | ||
|
|
d22dc28af3 | ||
|
|
4f4c2b6b91 | ||
|
|
d7b95dbac8 | ||
|
|
ef20b0d6c3 | ||
|
|
4638c06b68 | ||
|
|
b8bf9f1510 | ||
|
|
7c63f01527 | ||
|
|
487ca40a44 | ||
|
|
a5e5b6f34a |
+29
-59
@@ -11,28 +11,41 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install bison
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Cache emsdk
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: emsdk
|
path: emsdk
|
||||||
key: emsdk-${{ hashFiles('scripts/versions.sh') }}
|
key: emsdk-${{ steps.cache-keys.outputs.emsdk }}
|
||||||
|
|
||||||
- name: Cache wasm32 dependency builds
|
- name: Cache wasm32 dependency builds
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: build/deps-wasm32
|
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
|
- name: Cache libwinevbs build
|
||||||
uses: https://github.com/actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: vendor/libwinevbs/build
|
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)
|
- name: Setup (fetch + patch vendor sources)
|
||||||
run: ./scripts/setup.sh
|
run: ./scripts/setup.sh
|
||||||
@@ -59,70 +72,27 @@ jobs:
|
|||||||
npm install --no-save typescript
|
npm install --no-save typescript
|
||||||
npx tsc -p package/tsconfig.json
|
npx tsc -p package/tsconfig.json
|
||||||
|
|
||||||
- uses: https://github.com/actions/upload-artifact@v4
|
# Everything below only runs on a tag push (a release) - reuses the
|
||||||
with:
|
# exact build just done above instead of the previous separate
|
||||||
name: vpinball-wasm-dist
|
# "publish" job, which had `needs: build` but still redundantly
|
||||||
path: dist/
|
# re-ran the *entire* checkout/cache/setup/build-deps/build/wrapper
|
||||||
|
# pipeline from scratch, doubling CI time on every release for no
|
||||||
publish:
|
# benefit (Gitea Actions' artifact-passing story is also awkward
|
||||||
name: Publish to npm registry
|
# here - upload-artifact@v4+/download-artifact@v4+ aren't supported
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
# on Gitea at all, see the upload-artifact removal above).
|
||||||
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
|
|
||||||
|
|
||||||
- name: Set package version from the tag
|
- name: Set package version from the tag
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: npm pkg set version="${GITHUB_REF_NAME#v}"
|
run: npm pkg set version="${GITHUB_REF_NAME#v}"
|
||||||
|
|
||||||
# Scoped to this one registry host+path (via publishConfig.registry in
|
# Scoped to this one registry host+path (via publishConfig.registry in
|
||||||
# package.json) rather than actions/setup-node's registry-url, which
|
# package.json) rather than actions/setup-node's registry-url, which
|
||||||
# would set it as the *default* registry for every install.
|
# would set it as the *default* registry for every install.
|
||||||
- name: Configure registry auth for publish
|
- 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"
|
run: npm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN"
|
||||||
env:
|
env:
|
||||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
- name: Publish to Gitea npm registry
|
- name: Publish to Gitea npm registry
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
run: npm publish
|
run: npm publish
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
/dist/
|
/dist/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/emsdk
|
/emsdk
|
||||||
|
# generated by scripts/build.sh from vendor/vpinball, not source-controlled
|
||||||
|
/package/assets/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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.
|
This project ports [Visual Pinball](https://github.com/vpinball/vpinball) (the C++ pinball table simulator) to WebAssembly via Emscripten. It is a source-level port of the actual engine — not a reimplementation — so table compatibility, physics behavior, and scripting semantics come from the real codebase real tables already run against today.
|
||||||
|
|
||||||
## Status: engine boots and renders in-browser; main loop rewrite is the one remaining blocker
|
## Status: a real, playable table, live in the browser — keyboard and audio confirmed
|
||||||
|
|
||||||
This is further along than a typical "does it compile" milestone. As of this writing, the patched engine, running in Chrome via this project's build:
|
The patched engine, running in a real browser via this project's build, renders and plays a real, interactive, physically-simulated pinball table from a real `.vpx` file — lit flippers that respond to Left/Right Shift, slingshots, bumpers, targets, a plunger, drain detection, and audible sound effects — all driven by a real per-frame game loop, not a static screenshot. This has been confirmed by hands-on manual testing in Chrome, not just code review. Concretely, verified end-to-end:
|
||||||
|
|
||||||
- Loads a real `.vpx` file (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
|
- Loads a real `.vpx` file and parses it completely (OLE/BIFF container, all game items, images, sounds metadata).
|
||||||
- Initializes SDL3's Emscripten audio backend and creates a real window via SDL3's Emscripten video driver.
|
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (80 shaders across UI/Basic/Ball/DMD/Flasher/Light/Framebuffer) against WebGL2/GLES3, computes environment map radiance (HDR/IBL), and runs the static pre-render pass.
|
||||||
- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3.
|
- Runs the real physics engine and the real VBScript scripting engine (`libwinevbs`) — confirmed by pressing a physical key and watching a table-script-driven flipper (`Table1_KeyDown` → `LeftFlipper.RotateToEnd`) actually move.
|
||||||
- Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's `.exr` asset) and runs the static pre-render pass (including reflection probes).
|
- Runs a real per-frame game loop (`Player::EmscriptenStepFrame()`, driven by `emscripten_set_main_loop`) stepping input, physics, and rendering every frame, with a clean `stop()` → `~Player()` teardown mid-session and no crash or hang.
|
||||||
- Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs).
|
- **Keyboard input and audio, confirmed by ear and by hand** against the bundled default table (see below) — the full DOM → SDL3 → `InputManager` → VBScript-dispatch pipeline, and the full audio pipeline, both work.
|
||||||
- Reaches `Player::Player@832 Startup done` / `Unpausing Game` — i.e., initialization completes successfully end to end.
|
- Ships the bundled default table (`exampleTable.vpx`, a real playable demo table with working gameplay logic) at ~34MB total (`vpinball.wasm` + `vpinball.data` + `vpinball.js`) after trimming ~52MB of editor-only content never used by the player runtime (bundled native-editor templates and a Monaco code editor) — see [Roadmap](#roadmap).
|
||||||
|
|
||||||
It then hangs: the classic desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop` in `player.cpp`) is a blocking native `while` loop, which cannot run on a single-threaded WASM main thread without yielding control back to the browser. This is the one deliberately-deferred piece of engineering work — see [Roadmap](#roadmap) below. Everything upstream of it (file loading, rendering setup, shader compilation, physics init, script engine startup) is proven working, in the real engine, in a real browser.
|
Getting here required finding and fixing four real, previously-unknown bugs in the upstream engine (not just adding a new CMake target) — see `patches/vpinball/0003-*`, `0004-*` and `0005-*` for the exact fixes:
|
||||||
|
1. `RenderDevice::WaitForVSync()` unconditionally spawned a real `std::thread` every frame, even on `__STANDALONE__` builds — a hard crash under Emscripten's single-threaded runtime, independent of and encountered before the main-loop-yielding problem below.
|
||||||
|
2. The desktop game loop (`FramePacingGameLoop`/`GPUQueueStuffingGameLoop`) is a blocking native `while` loop with manual `uSleep` throttling — incompatible with a single-threaded WASM main thread, which must yield control back to the browser every frame. This project adds a new `Player::EmscriptenStepFrame()` (one frame, no internal loop) driven by `emscripten_set_main_loop`, plus new JS-callable lifecycle entry points (`vpinball_wasm_start`/`stop`/`dispose`/`eval_script` in `src/core/EmscriptenBridge.cpp`) that don't route through the desktop `main()`/`WinMain()` chain at all, since that chain assumes the whole process runs exactly one table to completion then exits.
|
||||||
|
3. The window's OpenGL back buffer was created with the window's *logical* (CSS) pixel size instead of its *device* pixel size — on any browser tab with `devicePixelRatio != 1` (essentially all HiDPI displays), the GL viewport only covered a fraction of the canvas's actual backing buffer, rendering anchored to one corner (GL's viewport origin) instead of filling the canvas.
|
||||||
|
4. `Window::OnResized()` (called on every SDL resize event, including entering/leaving browser fullscreen) updated the window's own tracked pixel size but never propagated it to the window's back buffer render target — so toggling fullscreen resized the canvas but rendering stayed pinned to the pre-resize viewport size.
|
||||||
|
|
||||||
|
## 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
|
## 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)
|
(real Wine-derived interpreter, compiled to wasm32)
|
||||||
│
|
│
|
||||||
emscripten_set_main_loop
|
emscripten_set_main_loop
|
||||||
(frame stepping - in progress, see Roadmap)
|
(Player::EmscriptenStepFrame, one frame per callback)
|
||||||
```
|
```
|
||||||
|
|
||||||
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, and a handful of small source patches (~160 lines total across 8 files) fixing genuine first-32-bit-target and first-wasm-target issues — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
|
This project's own contribution is glue, not a rewrite: a new `PLATFORM=emscripten` CMake target modeled on vpinball's existing Linux build, wasm32 builds of its SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs dependencies, a small number of source patches fixing genuine first-32-bit-target and first-wasm-target issues, and the new game-loop/lifecycle bridge described above — see [`patches/`](patches/) for the exact diffs, each with an explanatory comment.
|
||||||
|
|
||||||
Full research and two isolated feasibility spikes (proving real VBScript execution and real WebGL2 rendering work under Emscripten, independently, before this project existed) live in the companion research repository:
|
Full research and two isolated feasibility spikes (proving real VBScript execution and real WebGL2 rendering work under Emscripten, independently, before this project existed) live in the companion research repository:
|
||||||
- VBScript-in-WASM spike: proves `libwinevbs` compiles and correctly runs real VBScript (classes, `Scripting.Dictionary`, error handling) under wasm32.
|
- VBScript-in-WASM spike: proves `libwinevbs` compiles and correctly runs real VBScript (classes, `Scripting.Dictionary`, error handling) under wasm32.
|
||||||
@@ -54,38 +69,48 @@ Full research and two isolated feasibility spikes (proving real VBScript executi
|
|||||||
- Real VBScript execution under wasm32 (libwinevbs).
|
- Real VBScript execution under wasm32 (libwinevbs).
|
||||||
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
|
- Real WebGL2/GLES3 rendering under Emscripten (SDL3).
|
||||||
|
|
||||||
**Proven working (in this project's own build, in a real browser):**
|
**Proven working (in this project's own build, in a real browser, by hands-on manual testing):**
|
||||||
- `.vpx` file loading and parsing.
|
- `.vpx` file loading and parsing; SDL3 audio/video/window initialization.
|
||||||
- SDL3 audio/video/window initialization under Emscripten.
|
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2; environment map/HDR radiance computation; static pre-render pass.
|
||||||
- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2.
|
- **The full input pipeline**: a real physical keypress → SDL3 → `InputManager::PushButtonEvent` → `PinTable::FireDispID(DISPID_GameEvents_KeyDown)` → the table's own VBScript `KeyDown` handler → `LeftFlipper.RotateToEnd` — confirmed by watching a flipper actually respond to Left/Right Shift.
|
||||||
- Environment map / HDR radiance computation.
|
- **Audio**, confirmed audible by ear.
|
||||||
- Physics engine initialization (octree).
|
- **A real, running per-frame game loop** (`emscripten_set_main_loop`-driven), with clean JS-controllable `start()`/`stop()` lifecycle and normal C++ teardown (`~Player()`) on stop — see [Status](#status-a-real-playable-table-live-in-the-browser--keyboard-and-audio-confirmed) above.
|
||||||
- VBScript engine startup.
|
- **Correct rendering at any canvas size/DPI, including entering/leaving fullscreen** — two real GL back-buffer sizing bugs found and fixed (see Status).
|
||||||
- Static scene pre-render pass, including reflection probes.
|
- A bundled default table (`exampleTable.vpx`) with real, working gameplay logic (flippers, slingshots, bumpers, targets, a plunger, drain detection) — swapped in from an earlier choice (`test000-default-table.vpx`) that turned out to be upstream's own rendering/component regression-test fixture with no gameplay script at all (confirmed by diffing a screenshot against vpinball's own official reference image for that table).
|
||||||
|
- Editor-only content (bundled native-editor example/template tables, a Monaco code editor) excluded from the asset payload — never used by the player runtime — see `patches/vpinball/0001-*`.
|
||||||
|
- **A full loading → play flow in a real page** (`examples/basic/index.html`): a byte-level download progress bar, a user-gesture "Start" button (required for both audio autoplay and fullscreen to work), a working fullscreen button, and responsive canvas sizing that fills its container correctly.
|
||||||
|
- **Browser file-loading for self-contained tables**: picking a `.vpx` file, mounting it via `FS.writeFile`, and starting the engine against it — verified end-to-end (`PinTable::LoadGameFromFilename /tables/uploaded.vpx` in the boot log, followed by a normal render).
|
||||||
|
- A JS-callable `evalScript()` debug/utility hook (`pinball.evalScript(script)`) that runs arbitrary VBScript against the live table via the same entry point the interpreter's own debug console uses.
|
||||||
|
- **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):**
|
**Not yet validated (should work, per code review and by analogy to already-proven mechanisms, but unconfirmed end-to-end):**
|
||||||
- Rewrite `player.cpp`'s game loop dispatch to add an `__EMSCRIPTEN__` branch driven by `emscripten_set_main_loop()`, based on the existing (currently BGFX-only) `CallbackSteppedGameLoop()` "step one frame" function, instead of the blocking `FramePacingGameLoop()`/`GPUQueueStuffingGameLoop()` loops the non-BGFX/GL path uses today. This is real engineering work, not a flag flip — `WinMain`'s control flow needs to reach "loop registered, return now" without blocking first.
|
- **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):
|
**Explicitly out of scope for now** (browser-sandbox constraints or a real infrastructure-cost tradeoff, not a technical dead end — could be revisited):
|
||||||
- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact.
|
- **Multi-threading** (pthreads/SharedArrayBuffer): would require mandatory `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers on every page hosting this widget, a `coi-serviceworker`-style workaround for static hosts that can't set custom headers, and real risk of breaking unrelated cross-origin embeds on host pages — a poor tradeoff for something meant to be embeddable in arbitrary third-party pages. `ThreadPool`'s one-off parallel work (e.g. parallel `.vpx` item deserialization) already runs synchronously instead (`patches/vpinball/0002-*`). Revisit only if real-world profiling shows single-threaded performance is genuinely insufficient.
|
||||||
- BGFX renderer path (multi-threaded design doesn't fit a single-threaded wasm32 v1). This project uses vpinball's existing `RENDERER=GL` (glad/GLES) path instead.
|
- **The 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.)
|
||||||
- Raw HID device input (`OpenPinDevHandler` / hidapi) — no browser equivalent for real-hardware nudge/plunger boards.
|
- **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.
|
||||||
- Multi-threading (no `-pthread`/`SharedArrayBuffer` in v1 — `ThreadPool`'s one-off parallel work, like parallel `.vpx` item deserialization, runs synchronously instead; see `patches/vpinball/0002-*`).
|
- Raw HID device input (`OpenPinDevHandler`/hidapi) — no browser equivalent for real-hardware nudge/plunger boards. Permanent, not a "for now."
|
||||||
|
|
||||||
**Further follow-up work once the main loop is fixed:**
|
**Further follow-up work:**
|
||||||
- Browser file-loading UX (drag-and-drop / file picker for user-supplied `.vpx` files beyond the bundled demo table).
|
- Real-device gamepad and touch-control validation (see above).
|
||||||
- Input mapping (keyboard/gamepad through SDL3's Emscripten backend).
|
- Visual confirmation of DMD rendering against a real DMD-equipped table (see above).
|
||||||
- Binary size / performance tuning (this build is unoptimized `-O1`-equivalent; a real `-O2`/`-Os` release pass, dead-code stripping, and only-needed-SDL3-subsystem linking are all still ahead).
|
- Pointer-lock UI wiring (fullscreen is done; pointer-lock isn't needed for this game's input model but noted here in case a future feature wants it).
|
||||||
- A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check.
|
- Real-world table support beyond self-contained single-file uploads: tables with an external `.vbs` script override or a `Music/` folder are a documented, known-unsupported gap.
|
||||||
|
- Further binary-size tuning (the `.wasm` itself is still ~13MB with `-O2`; `--closure 1` was tried but silently stripped `FS.writeFile`/`readFile`/`mkdir` down to low-level node ops - a real Emscripten/Closure interaction bug worth root-causing before re-enabling, since it broke runtime table loading with no compile-time warning. `-sFORCE_FILESYSTEM=1`/broad `EXPORTED_RUNTIME_METHODS` also pull in more than strictly needed. The default table itself is now ~18MB of the ~34MB total, since a real playable demo table is larger than the rendering-test fixture used before).
|
||||||
|
- A browser-based (Puppeteer) CI smoke test that actually loads a table and checks for a rendered frame, replacing today's compile-only CI check.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
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).
|
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
|
```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
|
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/build.sh # configures + builds vpinball itself -> dist/vpinball.{js,wasm,data}
|
||||||
./scripts/dev-server.sh # serves dist/ locally for manual testing
|
./scripts/dev-server.sh # serves dist/ locally for manual testing
|
||||||
```
|
```
|
||||||
@@ -95,27 +120,52 @@ source emsdk/emsdk_env.sh
|
|||||||
## npm usage
|
## npm usage
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { loadPinball } from '@valknar/vpinball-wasm';
|
import { loadPinball, attachTouchControls } from '@valknar/vpinball-wasm';
|
||||||
|
|
||||||
const pinball = await loadPinball({ canvas: document.querySelector('canvas') });
|
const canvas = document.querySelector('canvas');
|
||||||
|
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();
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap.
|
- Single-threaded only (no pthreads/SharedArrayBuffer) — a deliberate tradeoff, not a gap; see Roadmap.
|
||||||
- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap.
|
- 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).
|
- No raw hardware input (real cabinet nudge/plunger boards) — permanent, no browser equivalent.
|
||||||
- Not yet performance/size-tuned — current unoptimized build produces a ~51MB asset bundle (mostly vpinball's own `src/assets/` textures/EXR files) and a ~1.2MB `.wasm`.
|
- Keyboard input, audio, and VBScript-driven gameplay are confirmed working end-to-end by hands-on manual testing (not just code review). Gamepad input and the touch-control overlay are code-complete and use the identical input pipeline, but are not yet confirmed on real gamepad/touch hardware — see Roadmap.
|
||||||
|
- DMD rendering is native, code-complete functionality riding the same proven VBScript-dispatch path, but not yet visually confirmed since the bundled default table has no DMD-configured `Flasher` — see Roadmap.
|
||||||
|
- `.wasm`/asset size is ~34MB total (a real playable default table is larger than the rendering-test fixture used earlier in development) and not fully tuned — see Roadmap.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
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
|
## Attribution
|
||||||
|
|
||||||
- [Visual Pinball](https://github.com/vpinball/vpinball) — the engine this project ports.
|
- [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.
|
- [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.
|
- [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.
|
||||||
|
|||||||
+103
-6
@@ -1,26 +1,123 @@
|
|||||||
<!doctype html>
|
<!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)
|
Serve this directory alongside a built dist/ (see scripts/dev-server.sh)
|
||||||
and adjust the import path below if dist/ isn't a sibling directory.
|
and adjust the import paths below if dist/ isn't a sibling directory.
|
||||||
-->
|
-->
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<title>vpinball-wasm example</title>
|
<title>vpinball-wasm example</title>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; background: #111; }
|
html, body { margin: 0; height: 100%; background: #111; overflow: hidden; }
|
||||||
canvas { display: block; width: 100vw; height: 100vh; }
|
#stage { position: relative; width: 100vw; height: 100vh; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; padding: 0; border: 0; }
|
||||||
|
|
||||||
|
#overlay {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 16px;
|
||||||
|
background: #111; color: #eee; font-family: sans-serif;
|
||||||
|
}
|
||||||
|
#overlay.hidden { display: none; }
|
||||||
|
#progress-track { width: 280px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; }
|
||||||
|
#progress-bar { width: 0%; height: 100%; background: #4ade80; transition: width 0.1s linear; }
|
||||||
|
#start-button {
|
||||||
|
padding: 12px 32px; font-size: 18px; border-radius: 8px; border: none;
|
||||||
|
background: #4ade80; color: #111; cursor: pointer;
|
||||||
|
}
|
||||||
|
#start-button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
#file-picker { color: #aaa; font-size: 13px; }
|
||||||
|
|
||||||
|
#fullscreen-button {
|
||||||
|
position: absolute; top: 12px; right: 12px; z-index: 10;
|
||||||
|
padding: 8px 14px; border-radius: 6px; border: none;
|
||||||
|
background: rgba(255,255,255,0.15); color: #eee; cursor: pointer;
|
||||||
|
}
|
||||||
|
#fullscreen-button.hidden { display: none; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="stage">
|
||||||
<canvas id="canvas"></canvas>
|
<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">
|
<script type="module">
|
||||||
import { loadPinball } from '../../dist/index.js';
|
import { loadPinball, attachTouchControls } from '../../dist/index.js';
|
||||||
|
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
const pinball = await loadPinball({ canvas, baseUrl: '../../dist' });
|
const overlay = document.getElementById('overlay');
|
||||||
|
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();
|
pinball.start();
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
fullscreenButton.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Touch controls are additive UI for touch-capable devices - not
|
||||||
|
// required for desktop mouse+keyboard play.
|
||||||
|
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||||||
|
attachTouchControls({ container: document.getElementById('stage') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenButton.addEventListener('click', () => {
|
||||||
|
pinball.requestFullscreen().catch(() => {
|
||||||
|
// Fullscreen can be rejected (e.g. already fullscreen, or the
|
||||||
|
// browser denies it) - nothing to recover from here.
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+30
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@valknar/vpinball-wasm",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@valknar/vpinball-wasm",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@valknar/vpinball-wasm",
|
"name": "@valknar/vpinball-wasm",
|
||||||
"version": "0.0.0",
|
"version": "0.3.1",
|
||||||
"description": "Visual Pinball's engine compiled to WebAssembly - real .vpx tables, real VBScript, WebGL2 rendering, in the browser",
|
"description": "Visual Pinball's engine compiled to WebAssembly - real .vpx tables, real VBScript, WebGL2 rendering, in the browser",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+96
-18
@@ -1,20 +1,37 @@
|
|||||||
import type { LoadPinballOptions, PinballInstance } from './types.js';
|
import type { LoadPinballOptions, PinballInstance } from './types.js';
|
||||||
|
|
||||||
export type { LoadPinballOptions, PinballInstance } from './types.js';
|
export type { LoadPinballOptions, PinballInstance } from './types.js';
|
||||||
|
export { attachTouchControls } from './touch-controls.js';
|
||||||
|
export type { TouchControlsOptions, TouchControlsHandle } from './touch-controls.js';
|
||||||
|
|
||||||
|
const DEFAULT_TABLE_PATH = '/tables/default.vpx';
|
||||||
|
const UPLOADED_TABLE_PATH = '/tables/uploaded.vpx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiates the WebAssembly build of Visual Pinball against the given
|
* Instantiates the WebAssembly build of Visual Pinball against the given
|
||||||
* canvas.
|
* canvas and returns a handle to control its lifecycle.
|
||||||
*
|
|
||||||
* NOTE (current milestone): this wrapper boots the Emscripten module and
|
|
||||||
* exposes lifecycle control (start/stop/dispose). Runtime table loading
|
|
||||||
* (`loadTable`) mounts the given bytes into the module's virtual filesystem,
|
|
||||||
* but wiring the native engine to actually pick up a runtime-loaded table
|
|
||||||
* (as opposed to the table baked in at build time) is tracked as a follow-up
|
|
||||||
* milestone in the README's roadmap - see "Browser file-loading UX".
|
|
||||||
*/
|
*/
|
||||||
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
|
export async function loadPinball(options: LoadPinballOptions): Promise<PinballInstance> {
|
||||||
const baseUrl = options.baseUrl ?? '.';
|
// 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,
|
// dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule,
|
||||||
// so importing it yields a factory function, not a module with side effects.
|
// so importing it yields a factory function, not a module with side effects.
|
||||||
@@ -22,21 +39,25 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
|
|||||||
moduleArgs: Record<string, unknown>
|
moduleArgs: Record<string, unknown>
|
||||||
) => Promise<EmscriptenModule>;
|
) => Promise<EmscriptenModule>;
|
||||||
|
|
||||||
const module = await factory({
|
const module = await factory(moduleArgs);
|
||||||
canvas: options.canvas,
|
options.onProgress?.(1);
|
||||||
locateFile: (path: string) => `${baseUrl}/${path}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.tableData) {
|
if (options.tableData) {
|
||||||
mountTable(module, options.tableData);
|
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(options.tableData));
|
||||||
|
tablePath = UPLOADED_TABLE_PATH;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadTable(vpxBytes: ArrayBuffer) {
|
loadTable(vpxBytes: ArrayBuffer) {
|
||||||
mountTable(module, vpxBytes);
|
module.FS.writeFile(UPLOADED_TABLE_PATH, new Uint8Array(vpxBytes));
|
||||||
|
tablePath = UPLOADED_TABLE_PATH;
|
||||||
|
},
|
||||||
|
loadRom(gameName: string, romZipBytes: ArrayBuffer) {
|
||||||
|
module.FS.mkdirTree(PINMAME_ROMS_DIR);
|
||||||
|
module.FS.writeFile(`${PINMAME_ROMS_DIR}/${gameName}.zip`, new Uint8Array(romZipBytes));
|
||||||
},
|
},
|
||||||
start() {
|
start() {
|
||||||
module.ccall?.('vpinball_wasm_start', null, [], []);
|
module.ccall?.('vpinball_wasm_start', 'number', ['string'], [tablePath]);
|
||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
module.ccall?.('vpinball_wasm_stop', null, [], []);
|
module.ccall?.('vpinball_wasm_stop', null, [], []);
|
||||||
@@ -44,16 +65,73 @@ export async function loadPinball(options: LoadPinballOptions): Promise<PinballI
|
|||||||
dispose() {
|
dispose() {
|
||||||
module.ccall?.('vpinball_wasm_dispose', null, [], []);
|
module.ccall?.('vpinball_wasm_dispose', null, [], []);
|
||||||
},
|
},
|
||||||
|
requestFullscreen() {
|
||||||
|
return options.canvas.requestFullscreen();
|
||||||
|
},
|
||||||
|
evalScript(script: string) {
|
||||||
|
return Boolean(module.ccall?.('vpinball_wasm_eval_script', 'number', ['string'], [script]));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountTable(module: EmscriptenModule, vpxBytes: ArrayBuffer): void {
|
/**
|
||||||
module.FS.writeFile('/table.vpx', new Uint8Array(vpxBytes));
|
* Best-effort byte-level download progress: manually fetches vpinball.wasm
|
||||||
|
* with a streaming reader (assigning the result to Module.wasmBinary so the
|
||||||
|
* factory doesn't re-fetch it), weighted against Module's own coarse
|
||||||
|
* "Downloading data..." status callback for the preloaded asset package.
|
||||||
|
* Emscripten's own dependency counter (monitorRunDependencies) doesn't give
|
||||||
|
* byte-level granularity, hence fetching the .wasm by hand instead.
|
||||||
|
*/
|
||||||
|
async function wireDownloadProgress(
|
||||||
|
moduleArgs: Record<string, unknown>,
|
||||||
|
baseUrl: string,
|
||||||
|
onProgress: (fraction: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
// Roughly half the total download is the wasm binary, half the data
|
||||||
|
// package - this is an estimate (see README's size-tuning roadmap item),
|
||||||
|
// not a guarantee, so progress may jump at the wasm/data boundary.
|
||||||
|
const WASM_WEIGHT = 0.5;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/vpinball.wasm`);
|
||||||
|
const total = Number(response.headers.get('Content-Length') ?? 0);
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader || !total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let received = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
received += value.length;
|
||||||
|
onProgress((received / total) * WASM_WEIGHT);
|
||||||
|
}
|
||||||
|
const wasmBinary = new Uint8Array(received);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
wasmBinary.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
moduleArgs.wasmBinary = wasmBinary;
|
||||||
|
} catch {
|
||||||
|
// Streaming progress is best-effort; fall through to the factory's
|
||||||
|
// own default fetch if this fails for any reason (e.g. no CORS
|
||||||
|
// Content-Length exposed, older browser).
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleArgs.setStatus = (text: string) => {
|
||||||
|
if (text) onProgress(WASM_WEIGHT + (1 - WASM_WEIGHT) * 0.5);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmscriptenModule {
|
interface EmscriptenModule {
|
||||||
FS: {
|
FS: {
|
||||||
writeFile(path: string, data: Uint8Array): void;
|
writeFile(path: string, data: Uint8Array): void;
|
||||||
|
mkdirTree(path: string): void;
|
||||||
};
|
};
|
||||||
ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown;
|
ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PINMAME_ROMS_DIR = '/tables/pinmame/roms';
|
||||||
|
|||||||
@@ -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
@@ -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
|
* This wraps the raw Emscripten-generated module (dist/vpinball.js) so
|
||||||
* consumers don't need to know about ccall/cwrap/FS internals directly.
|
* 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 the engine renders into via SDL3's WebGL2 backend. */
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
/**
|
/**
|
||||||
* Raw bytes of a .vpx table file to load on startup. If omitted, the
|
* Raw bytes of a self-contained .vpx table file to play instead of the
|
||||||
* bundled default table (package/assets/test000-default-table.vpx) is
|
* bundled default table. Real-world tables that reference an external
|
||||||
* used instead.
|
* .vbs script override or a Music/ folder are not supported this way -
|
||||||
|
* see the README's "Browser file-loading UX" section.
|
||||||
*/
|
*/
|
||||||
tableData?: ArrayBuffer;
|
tableData?: ArrayBuffer;
|
||||||
/** Base URL to fetch dist/vpinball.wasm (and .data, if present) from. */
|
/** Base URL to fetch dist/vpinball.wasm/.data (and vpinball.js) from. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
/**
|
||||||
|
* Called as the wasm binary and preloaded asset package download,
|
||||||
|
* with a 0-1 fraction of bytes received. Not called at all if the
|
||||||
|
* browser doesn't support streaming fetch progress (falls back to
|
||||||
|
* Emscripten's own default loading behavior with no progress callback).
|
||||||
|
*/
|
||||||
|
onProgress?: (fraction: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PinballInstance {
|
export interface PinballInstance {
|
||||||
/** Load a different .vpx table at runtime, replacing the current one. */
|
/**
|
||||||
|
* Stage a different self-contained .vpx table to play on the *next*
|
||||||
|
* start() call. Does not affect an already-running session - table
|
||||||
|
* switching mid-session isn't supported; call stop() first (a fresh
|
||||||
|
* loadPinball() call is the supported way to load a genuinely new
|
||||||
|
* session with a different table).
|
||||||
|
*/
|
||||||
loadTable(vpxBytes: ArrayBuffer): void;
|
loadTable(vpxBytes: ArrayBuffer): void;
|
||||||
/** Start (or resume) the simulation's main loop. */
|
/**
|
||||||
|
* 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;
|
start(): void;
|
||||||
/** Pause the simulation's main loop. */
|
/** Stop the simulation; the engine tears down (script Exit event, settings save) on its next step. */
|
||||||
stop(): void;
|
stop(): void;
|
||||||
/** Tear down the instance and free its WebAssembly memory. */
|
/** Tear down the instance immediately and free its WebAssembly memory. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
|
/**
|
||||||
|
* Request fullscreen on the canvas via the standard Fullscreen API.
|
||||||
|
* Must be called from within a user gesture (e.g. a click handler) -
|
||||||
|
* browsers reject fullscreen requests otherwise. Returns the promise
|
||||||
|
* from the underlying requestFullscreen() call.
|
||||||
|
*/
|
||||||
|
requestFullscreen(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Runs arbitrary VBScript against the running table's script interpreter
|
||||||
|
* (the same entry point the interpreter's own debug console uses).
|
||||||
|
* Returns false if no table is currently running. Intended for debugging
|
||||||
|
* and for exercising script-driven engine APIs (e.g. the DMD pixel API)
|
||||||
|
* from JS - not sandboxed, so only run scripts you trust.
|
||||||
|
*/
|
||||||
|
evalScript(script: string): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||||
index 1fcf242..f9338b2 100644
|
index 1fcf242..745b91b 100644
|
||||||
--- a/CMakeLists.txt
|
--- a/CMakeLists.txt
|
||||||
+++ b/CMakeLists.txt
|
+++ b/CMakeLists.txt
|
||||||
@@ -50,6 +50,7 @@ set(_vpx_valid_combos
|
@@ -50,6 +50,7 @@ set(_vpx_valid_combos
|
||||||
@@ -10,7 +10,7 @@ index 1fcf242..f9338b2 100644
|
|||||||
DX9-windows)
|
DX9-windows)
|
||||||
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos)
|
if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos)
|
||||||
string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}")
|
string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}")
|
||||||
@@ -784,6 +785,123 @@ elseif(PLATFORM STREQUAL "linux")
|
@@ -784,6 +785,141 @@ elseif(PLATFORM STREQUAL "linux")
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ index 1fcf242..f9338b2 100644
|
|||||||
+
|
+
|
||||||
+ add_executable(vpinball
|
+ add_executable(vpinball
|
||||||
+ ${VPX_STANDALONE_SOURCES}
|
+ ${VPX_STANDALONE_SOURCES}
|
||||||
|
+ ${CMAKE_SOURCE_DIR}/src/core/EmscriptenBridge.cpp
|
||||||
+ )
|
+ )
|
||||||
+
|
+
|
||||||
+ target_include_directories(vpinball PUBLIC
|
+ target_include_directories(vpinball PUBLIC
|
||||||
@@ -108,9 +109,26 @@ index 1fcf242..f9338b2 100644
|
|||||||
+ # staged directories must exist *before* linking, not after.
|
+ # staged directories must exist *before* linking, not after.
|
||||||
+ add_custom_command(TARGET vpinball PRE_LINK
|
+ add_custom_command(TARGET vpinball PRE_LINK
|
||||||
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets"
|
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets"
|
||||||
|
+ # vpinball-wasm: exclude editor-only content never read by the
|
||||||
|
+ # __STANDALONE__ player runtime (native "File > New" templates and
|
||||||
|
+ # mobile-app onboarding screens, and a bundled Monaco code editor for
|
||||||
|
+ # remote script editing) - this is ~52MB of the ~54MB raw src/assets
|
||||||
|
+ # payload, cut here rather than preloaded and never used.
|
||||||
|
+ COMMAND "${CMAKE_COMMAND}" -E rm -f
|
||||||
|
+ "${RESOURCES_DIR}/assets/exampleTable.vpx"
|
||||||
|
+ "${RESOURCES_DIR}/assets/blankTable.vpx"
|
||||||
|
+ "${RESOURCES_DIR}/assets/lightSeqTable.vpx"
|
||||||
|
+ "${RESOURCES_DIR}/assets/strippedTable.vpx"
|
||||||
|
+ COMMAND "${CMAKE_COMMAND}" -E rm -rf "${RESOURCES_DIR}/assets/web"
|
||||||
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts"
|
+ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts"
|
||||||
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables"
|
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables"
|
||||||
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tests/assets/test000-default-table.vpx" "${RESOURCES_DIR}/tables/default.vpx"
|
+ # exampleTable.vpx (not test000-default-table.vpx, which is upstream's
|
||||||
|
+ # rendering/component regression-test fixture with no gameplay script)
|
||||||
|
+ # is a genuine playable demo table - it has real LeftFlipper/RightFlipper
|
||||||
|
+ # Animate/Collide subs, Table1_KeyDown/KeyUp, slingshots, bumpers,
|
||||||
|
+ # targets, a plunger and drain detection - the right default for a
|
||||||
|
+ # public-facing demo.
|
||||||
|
+ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/src/assets/exampleTable.vpx" "${RESOURCES_DIR}/tables/default.vpx"
|
||||||
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}"
|
+ COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}"
|
||||||
+ )
|
+ )
|
||||||
+ foreach(_shader IN LISTS VPX_GL_SHADERS)
|
+ foreach(_shader IN LISTS VPX_GL_SHADERS)
|
||||||
@@ -134,7 +152,7 @@ index 1fcf242..f9338b2 100644
|
|||||||
# iOS and Android libvpinball build
|
# iOS and Android libvpinball build
|
||||||
|
|
||||||
elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android")
|
elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android")
|
||||||
@@ -957,4 +1075,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
|
@@ -957,4 +1093,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM
|
||||||
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
diff --git a/src/renderer/RenderDevice.cpp b/src/renderer/RenderDevice.cpp
|
||||||
|
index 1fedf4e..6927546 100644
|
||||||
|
--- a/src/renderer/RenderDevice.cpp
|
||||||
|
+++ b/src/renderer/RenderDevice.cpp
|
||||||
|
@@ -1501,7 +1501,11 @@ RenderDevice::RenderDevice(
|
||||||
|
SetRenderState(RenderState::ZFUNC, RenderState::Z_LESSEQUAL);
|
||||||
|
|
||||||
|
// Retrieve a reference to the back buffer.
|
||||||
|
- wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetWidth(), wnd->GetHeight(), back_buffer_format));
|
||||||
|
+ // vpinball-wasm: use pixel (device) size, not logical/CSS size - on Emscripten
|
||||||
|
+ // these differ by devicePixelRatio, and the GL viewport (RenderTarget.cpp's
|
||||||
|
+ // glViewport(0, 0, m_width, m_height)) must match the canvas's actual backing
|
||||||
|
+ // buffer or rendering only fills a fraction of it, anchored bottom-left.
|
||||||
|
+ wnd->SetBackBuffer(new RenderTarget(this, SurfaceType::RT_DEFAULT, wnd->GetPixelWidth(), wnd->GetPixelHeight(), back_buffer_format));
|
||||||
|
|
||||||
|
#elif defined(ENABLE_DX9)
|
||||||
|
///////////////////////////////////
|
||||||
|
@@ -2168,10 +2172,19 @@ void RenderDevice::WaitForVSync(const bool asynchronous)
|
||||||
|
m_vsyncCount++;
|
||||||
|
m_presentTimestampReference = usec();
|
||||||
|
};
|
||||||
|
+#ifndef __EMSCRIPTEN__
|
||||||
|
if (asynchronous)
|
||||||
|
std::thread(lambda).detach(); // Reuse thread ? (we always at most one running at a time)
|
||||||
|
else
|
||||||
|
lambda();
|
||||||
|
+#else
|
||||||
|
+ // vpinball-wasm: a single-threaded Emscripten build (no -pthread /
|
||||||
|
+ // SharedArrayBuffer) cannot construct real std::thread workers - run
|
||||||
|
+ // synchronously inline instead. This only updates m_vsyncCount/timestamp
|
||||||
|
+ // bookkeeping (the real vblank-wait branches above are already excluded
|
||||||
|
+ // for __STANDALONE__ builds), so synchronous execution is equivalent.
|
||||||
|
+ lambda();
|
||||||
|
+#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(ENABLE_BGFX)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
diff --git a/src/core/AppCommands.cpp b/src/core/AppCommands.cpp
|
||||||
|
index 735762c..326c362 100644
|
||||||
|
--- a/src/core/AppCommands.cpp
|
||||||
|
+++ b/src/core/AppCommands.cpp
|
||||||
|
@@ -154,6 +154,21 @@ void PlayTableCommand::Execute()
|
||||||
|
table->Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+Player* PlayTableCommand::StartEmscripten()
|
||||||
|
+{
|
||||||
|
+ // vpinball-wasm: loads the table and constructs Player exactly like
|
||||||
|
+ // Execute() above, but does NOT call the blocking GameLoop()/destroy the
|
||||||
|
+ // Player here - ownership and per-frame stepping are driven externally
|
||||||
|
+ // (see src/core/EmscriptenBridge.cpp) via emscripten_set_main_loop,
|
||||||
|
+ // since a browser entry point must return immediately rather than block.
|
||||||
|
+ CComObject<PinTable>* table = LoadTable();
|
||||||
|
+ Player* player = new Player(table, Player::PlayMode::Play);
|
||||||
|
+ table->Release(); // Player's constructor already took its own AddRef()'d reference
|
||||||
|
+ return player;
|
||||||
|
+}
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
|
||||||
|
AuditTableCommand::AuditTableCommand(const std::filesystem::path& tableFilename)
|
||||||
|
: TableBasedCommand(tableFilename)
|
||||||
|
diff --git a/src/core/AppCommands.h b/src/core/AppCommands.h
|
||||||
|
index 2a18ceb..a033615 100644
|
||||||
|
--- a/src/core/AppCommands.h
|
||||||
|
+++ b/src/core/AppCommands.h
|
||||||
|
@@ -2,6 +2,9 @@
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+class Player;
|
||||||
|
+#endif
|
||||||
|
|
||||||
|
class AppCommand
|
||||||
|
{
|
||||||
|
@@ -58,6 +61,13 @@ public:
|
||||||
|
explicit PlayTableCommand(const std::filesystem::path& tableFilename);
|
||||||
|
~PlayTableCommand() override = default;
|
||||||
|
void Execute() override;
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+ // vpinball-wasm: see src/core/EmscriptenBridge.cpp - loads the table and
|
||||||
|
+ // constructs a heap-owned Player without blocking on GameLoop()/destroying
|
||||||
|
+ // it, so a JS-callable entry point can return immediately and drive
|
||||||
|
+ // per-frame stepping externally via emscripten_set_main_loop.
|
||||||
|
+ Player* StartEmscripten();
|
||||||
|
+#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuditTableCommand : public TableBasedCommand
|
||||||
|
diff --git a/src/core/EmscriptenBridge.cpp b/src/core/EmscriptenBridge.cpp
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..6e20009
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/src/core/EmscriptenBridge.cpp
|
||||||
|
@@ -0,0 +1,93 @@
|
||||||
|
+// license:GPLv3+
|
||||||
|
+
|
||||||
|
+// vpinball-wasm: JS-callable entry points for controlling the player's
|
||||||
|
+// lifecycle from the browser. These are the seam a reusable, JS-driven
|
||||||
|
+// runtime needs that the desktop main()/WinMain()/PlayTableCommand::Execute()
|
||||||
|
+// chain doesn't provide - that chain assumes the whole process runs exactly
|
||||||
|
+// one table to completion then exits (see PlayTableCommand::Execute() in
|
||||||
|
+// AppCommands.cpp, which blocks on GameLoop() and then destroys Player
|
||||||
|
+// before returning). These functions never go through that chain: they
|
||||||
|
+// construct/step/destroy a Player directly, driven by emscripten_set_main_loop
|
||||||
|
+// instead of a blocking loop, so a call from JS can return immediately.
|
||||||
|
+//
|
||||||
|
+// Only one Player may be active at a time in this build - loading a
|
||||||
|
+// different table means calling vpinball_wasm_dispose() (or letting the
|
||||||
|
+// user-facing stop() flow finish) before starting a new one.
|
||||||
|
+
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+
|
||||||
|
+#include "core/stdafx.h"
|
||||||
|
+#include "core/AppCommands.h"
|
||||||
|
+#include "core/player.h"
|
||||||
|
+
|
||||||
|
+#include <emscripten.h>
|
||||||
|
+
|
||||||
|
+static Player* g_wasmPlayer = nullptr;
|
||||||
|
+
|
||||||
|
+static void EmscriptenMainLoopTrampoline()
|
||||||
|
+{
|
||||||
|
+ if (g_wasmPlayer && g_wasmPlayer->EmscriptenStepFrame())
|
||||||
|
+ return;
|
||||||
|
+
|
||||||
|
+ emscripten_cancel_main_loop();
|
||||||
|
+ delete g_wasmPlayer; // runs Player's normal destructor teardown (script Exit event, plugin unload, settings save)
|
||||||
|
+ g_wasmPlayer = nullptr;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+extern "C" {
|
||||||
|
+
|
||||||
|
+EMSCRIPTEN_KEEPALIVE
|
||||||
|
+int vpinball_wasm_start(const char* tablePath)
|
||||||
|
+{
|
||||||
|
+ if (g_wasmPlayer != nullptr)
|
||||||
|
+ return 0; // already running - caller must stop()/dispose() first
|
||||||
|
+
|
||||||
|
+ PlayTableCommand cmd{std::filesystem::path(tablePath)};
|
||||||
|
+ g_wasmPlayer = cmd.StartEmscripten();
|
||||||
|
+ if (g_wasmPlayer == nullptr)
|
||||||
|
+ return 0;
|
||||||
|
+
|
||||||
|
+ // fps=0 uses requestAnimationFrame, synced to display refresh and
|
||||||
|
+ // automatically paused by the browser while the tab is hidden/backgrounded
|
||||||
|
+ // - correct behavior for a real game tab (verified end-to-end with a
|
||||||
|
+ // setTimeout-based fps>0 loop first, since rAF is unobservable in a
|
||||||
|
+ // headless/backgrounded automation tab).
|
||||||
|
+ emscripten_set_main_loop(EmscriptenMainLoopTrampoline, 0, 0);
|
||||||
|
+ return 1;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+EMSCRIPTEN_KEEPALIVE
|
||||||
|
+void vpinball_wasm_stop()
|
||||||
|
+{
|
||||||
|
+ if (g_wasmPlayer != nullptr)
|
||||||
|
+ g_wasmPlayer->SetCloseState(Player::CS_STOP_PLAY);
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+EMSCRIPTEN_KEEPALIVE
|
||||||
|
+void vpinball_wasm_dispose()
|
||||||
|
+{
|
||||||
|
+ if (g_wasmPlayer == nullptr)
|
||||||
|
+ return;
|
||||||
|
+ emscripten_cancel_main_loop();
|
||||||
|
+ delete g_wasmPlayer;
|
||||||
|
+ g_wasmPlayer = nullptr;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// Runs arbitrary VBScript against the running table's script interpreter,
|
||||||
|
+// via the same ScriptInterpreter::Evaluate() entry point the interpreter's
|
||||||
|
+// own debug console uses. Useful both as a debug/console hook for consumers
|
||||||
|
+// and, e.g., to exercise script-driven APIs (like the DMD pixel API) against
|
||||||
|
+// a table that doesn't itself call them, without needing to author a new
|
||||||
|
+// .vpx table.
|
||||||
|
+EMSCRIPTEN_KEEPALIVE
|
||||||
|
+int vpinball_wasm_eval_script(const char* script)
|
||||||
|
+{
|
||||||
|
+ if (g_wasmPlayer == nullptr || g_wasmPlayer->m_scriptInterpreter == nullptr)
|
||||||
|
+ return 0;
|
||||||
|
+ g_wasmPlayer->m_scriptInterpreter->Evaluate(script, false);
|
||||||
|
+ return 1;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+} // extern "C"
|
||||||
|
+
|
||||||
|
+#endif // __EMSCRIPTEN__
|
||||||
|
diff --git a/src/core/player.cpp b/src/core/player.cpp
|
||||||
|
index e83af84..60c7d5f 100644
|
||||||
|
--- a/src/core/player.cpp
|
||||||
|
+++ b/src/core/player.cpp
|
||||||
|
@@ -1992,6 +1992,32 @@ void Player::GPUQueueStuffingGameLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+bool Player::EmscriptenStepFrame()
|
||||||
|
+{
|
||||||
|
+ // vpinball-wasm: single-step version of GPUQueueStuffingGameLoop(), driven
|
||||||
|
+ // once per browser animation frame by emscripten_set_main_loop instead of
|
||||||
|
+ // a blocking while loop. Manual framerate throttling (uSleep) is removed
|
||||||
|
+ // entirely - the browser's own requestAnimationFrame cadence paces us.
|
||||||
|
+ if (GetCloseState() != CS_PLAYING && GetCloseState() != CS_USER_INPUT)
|
||||||
|
+ return false;
|
||||||
|
+
|
||||||
|
+ UpdateGameLogic();
|
||||||
|
+ PrepareFrame();
|
||||||
|
+ UpdateGameLogic();
|
||||||
|
+ SubmitFrame();
|
||||||
|
+ UpdateGameLogic();
|
||||||
|
+
|
||||||
|
+ m_renderProfiler->EnterProfileSection(FrameProfiler::PROFILE_RENDER_FLIP);
|
||||||
|
+ m_renderer->m_renderDevice->Flip();
|
||||||
|
+ m_renderProfiler->ExitProfileSection();
|
||||||
|
+
|
||||||
|
+ FinishFrame();
|
||||||
|
+
|
||||||
|
+ return true;
|
||||||
|
+}
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
void Player::FramePacingGameLoop()
|
||||||
|
{
|
||||||
|
// The main loop tries to perform a constant input/physics cycle at a 1ms pace while feeding the GPU command queue at a stable rate, without multithreading.
|
||||||
|
diff --git a/src/core/player.h b/src/core/player.h
|
||||||
|
index 6e9755a..a069cf7 100644
|
||||||
|
--- a/src/core/player.h
|
||||||
|
+++ b/src/core/player.h
|
||||||
|
@@ -115,6 +115,14 @@ public:
|
||||||
|
|
||||||
|
void ProcessOSMessages(const bool isInitialized = true);
|
||||||
|
|
||||||
|
+#ifdef __EMSCRIPTEN__
|
||||||
|
+ // vpinball-wasm: single-step frame function driven by emscripten_set_main_loop
|
||||||
|
+ // instead of a blocking while loop (see GameLoop()). Returns false once the
|
||||||
|
+ // player wants to quit; the caller must then stop calling this and release
|
||||||
|
+ // the Player (running its normal destructor teardown).
|
||||||
|
+ bool EmscriptenStepFrame();
|
||||||
|
+#endif
|
||||||
|
+
|
||||||
|
private:
|
||||||
|
VideoSyncMode m_videoSyncMode = VideoSyncMode::VSM_FRAME_PACING;
|
||||||
|
float m_maxFramerate = 0.f; // targeted refresh rate in Hz, if larger refresh rate it will limit FPS by uSleep() //!! currently does not work adaptively as it would require IDirect3DDevice9Ex which is not supported on WinXP
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
diff --git a/src/renderer/Window.cpp b/src/renderer/Window.cpp
|
||||||
|
index ad608f8..3dff060 100644
|
||||||
|
--- a/src/renderer/Window.cpp
|
||||||
|
+++ b/src/renderer/Window.cpp
|
||||||
|
@@ -381,6 +381,15 @@ void Window::OnResized()
|
||||||
|
return;
|
||||||
|
SDL_GetWindowSize(m_nwnd, &m_width, &m_height);
|
||||||
|
SDL_GetWindowSizeInPixels(m_nwnd, &m_pixelWidth, &m_pixelHeight);
|
||||||
|
+ // vpinball-wasm: keep the (GL) default back buffer's tracked size in sync
|
||||||
|
+ // with the window's actual pixel size, mirroring what the BGFX backend
|
||||||
|
+ // already does explicitly every frame (RenderDevice.cpp's swapchain
|
||||||
|
+ // resize handling). Without this, glViewport(0, 0, m_width, m_height) in
|
||||||
|
+ // RenderTarget::Activate() keeps using the pre-resize size forever, so
|
||||||
|
+ // e.g. entering browser fullscreen (which resizes the canvas well after
|
||||||
|
+ // the back buffer was created) only renders into the old, smaller area.
|
||||||
|
+ if (m_backBuffer)
|
||||||
|
+ m_backBuffer->SetSize(m_pixelWidth, m_pixelHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
VPX::Window::VideoMode Window::SDLtoVPXVideoMode(const SDL_DisplayMode* mode)
|
||||||
@@ -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();
|
||||||
@@ -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/atl/include/"* "$DEPS_DIR/include/libwinevbs/atl/include/"
|
||||||
cp -r "$VENDOR_DIR/libwinevbs/atlmfc/include/"* "$DEPS_DIR/include/libwinevbs/atlmfc/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 ""
|
||||||
echo "== build-deps.sh complete: $DEPS_DIR =="
|
echo "== build-deps.sh complete: $DEPS_DIR =="
|
||||||
ls -la "$DEPS_DIR/lib"
|
ls -la "$DEPS_DIR/lib"
|
||||||
|
|||||||
+15
-2
@@ -40,13 +40,26 @@ EMSCRIPTEN_LINK_FLAGS=(
|
|||||||
-sMAX_WEBGL_VERSION=2
|
-sMAX_WEBGL_VERSION=2
|
||||||
-sALLOW_MEMORY_GROWTH=1
|
-sALLOW_MEMORY_GROWTH=1
|
||||||
-sMODULARIZE=1
|
-sMODULARIZE=1
|
||||||
|
-sEXPORT_ES6=1
|
||||||
-sEXPORT_NAME=VPinballModule
|
-sEXPORT_NAME=VPinballModule
|
||||||
-sENVIRONMENT=web
|
-sENVIRONMENT=web
|
||||||
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap
|
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap
|
||||||
-sFORCE_FILESYSTEM=1
|
-sFORCE_FILESYSTEM=1
|
||||||
|
-sEXPORTED_FUNCTIONS=_main,_vpinball_wasm_start,_vpinball_wasm_stop,_vpinball_wasm_dispose,_vpinball_wasm_eval_script
|
||||||
|
-sEXIT_RUNTIME=0
|
||||||
|
--use-preload-cache
|
||||||
)
|
)
|
||||||
if [ "$BUILD_TYPE" = "Debug" ]; then
|
if [ "$BUILD_TYPE" = "Debug" ]; then
|
||||||
EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -sEXIT_RUNTIME=1)
|
EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -O0)
|
||||||
|
else
|
||||||
|
# CMAKE_BUILD_TYPE=Release only optimizes the compile step; emcc's link
|
||||||
|
# step needs its own -O level to actually run wasm-opt/dead-code
|
||||||
|
# elimination. NOTE: --closure 1 was tried here but strips FS.writeFile/
|
||||||
|
# readFile/mkdir (the FS convenience wrappers EXPORTED_RUNTIME_METHODS=FS
|
||||||
|
# is supposed to guarantee) down to just low-level node ops - dropped
|
||||||
|
# until that's root-caused, since a smaller JS glue isn't worth a broken
|
||||||
|
# runtime table-loading API.
|
||||||
|
EMSCRIPTEN_LINK_FLAGS+=(-O2)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
emcmake cmake \
|
emcmake cmake \
|
||||||
@@ -67,7 +80,7 @@ cp "$BUILD_DIR"/vpinball.wasm "$DIST_DIR/" 2>/dev/null || true
|
|||||||
cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true
|
cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true
|
||||||
|
|
||||||
mkdir -p "$ROOT_DIR/package/assets"
|
mkdir -p "$ROOT_DIR/package/assets"
|
||||||
cp "$VPINBALL_DIR/tests/assets/test000-default-table.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true
|
cp "$VPINBALL_DIR/src/assets/exampleTable.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "== build.sh complete =="
|
echo "== build.sh complete =="
|
||||||
|
|||||||
+8
-1
@@ -52,7 +52,12 @@ fetch_pinned() {
|
|||||||
rm -f "/tmp/${name}-${sha}.tar.gz"
|
rm -f "/tmp/${name}-${sha}.tar.gz"
|
||||||
git -C "$dest" init -q
|
git -C "$dest" init -q
|
||||||
git -C "$dest" add -A
|
git -C "$dest" add -A
|
||||||
git -C "$dest" commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>"
|
# -c user.name/user.email (not --author) are needed here: --author only sets
|
||||||
|
# the commit's author field, but git also requires a *committer* identity,
|
||||||
|
# which a fresh CI container has no ~/.gitconfig to supply. Scoped to this
|
||||||
|
# one invocation only - doesn't touch any persistent git config.
|
||||||
|
git -C "$dest" -c user.name="vpinball-wasm setup" -c user.email="noreply@localhost" \
|
||||||
|
commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup <noreply@localhost>"
|
||||||
echo "$sha" > "$sentinel"
|
echo "$sha" > "$sentinel"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +80,11 @@ apply_patches() {
|
|||||||
mkdir -p "$VENDOR_DIR"
|
mkdir -p "$VENDOR_DIR"
|
||||||
fetch_pinned "vpinball" "https://github.com/vpinball/vpinball" "$VPINBALL_SHA" "$VENDOR_DIR/vpinball"
|
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 "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 "vpinball" "$VENDOR_DIR/vpinball"
|
||||||
apply_patches "libwinevbs" "$VENDOR_DIR/libwinevbs"
|
apply_patches "libwinevbs" "$VENDOR_DIR/libwinevbs"
|
||||||
|
apply_patches "pinmame" "$VENDOR_DIR/pinmame"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "== Setup complete =="
|
echo "== Setup complete =="
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ SDL_IMAGE_SHA=bec9134a26c7d0f31b36d6083c25296e04cabff5
|
|||||||
SDL_TTF_SHA=a1ce3670aec736ecbf0936c43f2f0cc53aa61e5b
|
SDL_TTF_SHA=a1ce3670aec736ecbf0936c43f2f0cc53aa61e5b
|
||||||
FREEIMAGE_SHA=b1613452a0c3849d43ac877b154cf51ff9e078d3
|
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
|
# Emscripten SDK version already validated by both spikes
|
||||||
# (spikes/libwinevbs-wasm and spikes/sdl3-gles3-wasm).
|
# (spikes/libwinevbs-wasm and spikes/sdl3-gles3-wasm).
|
||||||
EMSDK_VERSION=6.0.8
|
EMSDK_VERSION=6.0.8
|
||||||
|
|||||||
Reference in New Issue
Block a user