Tested pinball.loadRom() against a correctly-matched ROM zip for a real
community table (Heavy Metal Meltdown, Bally 1987): PinMAME loads the ROM,
and its own video/audio subsystems come up and run with no errors
(osd_create_display: 60.00 fps; real INT16/44100Hz audio format), not just
Controller object creation succeeding as before. Updates the README to move
this from "not yet validated" to confirmed, narrowing the remaining gap to
on-screen DMD/backglass output and hands-on switch/solenoid/scoring
behavior during actual play, which nobody has watched yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
Statically links the real PinMAME emulation core (libpinmame) instead of
leaving VPinMAME.Controller creation to fail, which was crashing table
scripts on ROM-based tables before they could spawn a ball. PinMAME's own
run_game()->cpu_run() loop is split into a one-shot init, a per-frame step,
and a one-shot teardown (patches/pinmame/0004) so it runs cooperatively on
the same frame callback as vpinball's own loop instead of on a real
std::thread, which hard-aborts under Emscripten's single-threaded runtime -
three smaller wasm32 portability fixes to libpinmame itself round out the
patch set (0001-0003). Adds pinball.loadRom() to supply a ROM zip, written
to the table-relative pinmame/roms/ path vpinball's own plugin already
checks. Confirmed against a real community ROM-based table: Controller
creation and game identification succeed, and a missing ROM now fails
cleanly instead of crashing the page - actual ROM-driven gameplay is still
unconfirmed since no ROM was available (or sought out) to test with.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoSarxLgY33Kax5UNXcafZ
Reverts the earlier unscoping: an unscoped package on a Gitea npm
registry has no `.npmrc` mapping a consumer can use other than pinning
the exact tarball URL, since Gitea's registry doesn't proxy npmjs.org
and only scoped packages support the standard `@scope:registry=`
config. Scoping restores that: consumers add one `@valknar:registry=`
line and depend on a normal semver range.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hQoM3jJT1Lx7CMTciMzvD
Unscoped, matching this project's other Gitea-published package
(triggershell) - the registry is already scoped to the right owner via
publishConfig.registry's URL path (.../valknar/npm/), so an npm scope
on the package name itself is redundant. The old @valknar/vpinball-wasm
package published under the previous name is being removed manually.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The separate "publish" job had needs: build but never reused any of
build's output - it re-ran the entire checkout/cache/setup/build-deps/
build/wrapper pipeline from scratch, so every tagged release did two
full independent builds for no benefit. Gitea Actions doesn't support
upload-artifact@v4+/download-artifact@v4+ (GHESNotSupportedError),
which rules out the usual "build uploads, publish downloads" pattern
anyway, so the simplest fix is to run the build pipeline exactly once
and gate the three publish-only steps (version bump from tag, registry
auth, npm publish) behind `if: startsWith(github.ref, 'refs/tags/')`
at the step level instead of duplicating everything in a second job.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
actions/upload-artifact@v4+ refuses to run on Gitea (it's detected as
GHES, and v4's new backend API explicitly isn't supported there -
GHESNotSupportedError). Rather than pin back to the last GHES-compatible
v3.2.2, just remove the step: the publish job doesn't consume this
artifact - it does its own independent checkout+build - so it was only
ever a convenience for manually downloading dist/ from a CI run, not
something the pipeline depends on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via Gitea's own documentation (about.gitea.com's actions-cache
tutorial): Gitea Actions doesn't support the built-in hashFiles()
expression function GitHub Actions provides - it silently evaluates to
an empty string rather than erroring, so all three cache keys
(emsdk-${{hashFiles(...)}}, etc.) were actually just the constant
strings "emsdk-", "deps-wasm32-", "libwinevbs-wasm32-" on every run,
regardless of whether scripts/versions.sh or patches/ actually changed -
this is why caching "didn't work properly": correctness-wise it's worse
than no caching, since a stale cache from before a version/patch bump
would keep being reused indefinitely instead of invalidating.
Replaced with a plain sha256sum-based step that hashes the same inputs
by hand and exposes them via GITHUB_OUTPUT - no dependency on Gitea
gaining hashFiles() support, and verified locally to produce distinct,
non-empty hashes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The CI runner's base image doesn't ship cmake (build failed with
"cmake executable not found on PATH" during emcmake), and only bison
was ever explicitly installed - curl and python3 happened to work by
luck of the current runner image, not because the workflow guarantees
them. Install the full README-documented toolchain explicitly in both
the build and publish jobs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scripts/setup.sh's fetch_pinned() creates a fresh git repo per vendored
dependency and commits it with --author set, but --author alone doesn't
satisfy git's separate committer-identity requirement - it works on a
dev machine with ~/.gitconfig already set, but fails outright in a
clean CI container with no git identity anywhere (confirmed: Gitea
Actions failed at exactly this step with "Committer identity unknown").
Fixed by scoping user.name/user.email via -c flags to just this commit
invocation, verified to succeed even with HOME pointed at an empty
directory and no inherited git env vars.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Promotes vpinball_wasm_eval_script from a one-off validation hack to a
supported PinballInstance.evalScript() method (runs arbitrary VBScript
against the live table via the interpreter's own debug-console entry
point) - useful standalone, and it's what confirms the DMD script API
rides the same execution path already proven by real flipper input.
Rewrites the README's Status/Roadmap/Known limitations to reflect
tonight's actual, hands-on-confirmed state: keyboard input, audio, and
VBScript-driven gameplay all verified end-to-end (not just code
review); two more real engine bugs found and fixed (GL back buffer
sized in logical instead of device pixels; back buffer never resynced
on resize, breaking fullscreen); the default table swap and why;
B2S backglass explicitly assessed and declined for now, with the
concrete reasons (no plugin subsystem wired for this build, no test
table to validate against) rather than left as a vague TODO.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Window::OnResized() (called for every SDL window-resize event, including
entering/leaving browser fullscreen) updated the window's own tracked
pixel size but never propagated it to the window's back buffer render
target, whose stale size kept driving glViewport() - so toggling
fullscreen resized the canvas but rendering stayed pinned to the old,
smaller viewport. Mirrors the resize handling the BGFX backend already
does explicitly elsewhere in RenderDevice.cpp.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The OpenGL back-buffer render target was created with the window's
logical/CSS size instead of its device-pixel size, so on any browser
tab with devicePixelRatio != 1 the GL viewport only covered a fraction
of the canvas's actual backing buffer (rendering anchored bottom-left,
matching GL's viewport origin) - confirmed against a live repro and
fixed to match the convention already used by the BGFX backend
elsewhere in the same file.
Also swap the bundled default table from test000-default-table.vpx
(upstream's rendering/component regression-test fixture, which has no
gameplay script) to exampleTable.vpx - a genuine playable demo table
with working flippers, slingshots, bumpers, targets and a plunger -
confirmed keyboard input and audio now work end-to-end through it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
package/src/index.ts: fixes a real pre-existing bug (start()/loadTable()
never actually called vpinball_wasm_start with a table path argument),
adds byte-level download progress (onProgress) and requestFullscreen().
package/src/touch-controls.ts: new on-screen virtual flipper/plunger/start
button overlay that synthesizes the actual default keyboard scancodes
(SDL_SCANCODE_LSHIFT/RSHIFT/RETURN, read from InputManager.cpp) as real
KeyboardEvents dispatched on window, matching SDL3's Emscripten keyboard
target - keeps the native input path completely unmodified.
examples/basic/index.html: a real demo page - progress bar, a user-gesture
"Start" button (required for both audio autoplay and fullscreen), a file
picker for self-contained .vpx uploads, and the touch overlay shown on
touch-capable devices.
scripts/build.sh: two real bugs found and fixed via actual browser testing:
- MODULARIZE=1 without EXPORT_ES6=1 produces a classic script, not an ES
module with a default export - `await import(...)).default` was always
undefined. Fixes with -sEXPORT_ES6=1.
- --closure 1 silently stripped FS.writeFile/readFile/mkdir down to just
low-level node ops, breaking runtime table loading with no compile-time
warning. Dropped until root-caused; -O2 alone is kept.
Verified end-to-end in Chrome: full load->progress->start flow with no
errors, fullscreen actually engaging (document.fullscreenElement true),
and a file-picker-selected table loading correctly (LoadGameFromFilename
/tables/uploaded.vpx in the boot log, followed by a normal render).
README updated to reflect what's now validated vs. still open (real-device
input/audio confirmation remains the main open item).
Phase A (the hard blocker): the engine now plays a real table live in
the browser via a real per-frame game loop, not just a static render.
Two real, previously-unknown upstream bugs found and fixed along the way:
- RenderDevice::WaitForVSync() unconditionally spawned a real std::thread
every frame, even on __STANDALONE__ builds - a hard crash under
Emscripten's single-threaded runtime (0003).
- The desktop game loop is a blocking native while loop with manual
uSleep throttling, incompatible with a single-threaded WASM main
thread. Adds Player::EmscriptenStepFrame() (one frame, no internal
loop) driven by emscripten_set_main_loop, plus new JS-callable
lifecycle entry points (vpinball_wasm_start/stop/dispose in the new
src/core/EmscriptenBridge.cpp) that bypass the desktop main()/WinMain()
chain entirely, since that chain assumes the process runs exactly one
table to completion then exits (0004).
Verified end-to-end in Chrome: real .vpx load, real shader compilation,
real physics/script engine init, a real running frame loop (observed
advancing), and a clean stop() -> ~Player() teardown mid-session with
no crash or hang.
Phase B: trims the preloaded asset payload from ~51MB to ~11MB by
excluding editor-only bundled example tables and a Monaco code editor
never used by the player runtime, and adds real link-time optimization
(-O2 --closure 1) and --use-preload-cache for repeat visits.
Build scripts, CMake/source patches, npm packaging, and a Gitea CI
workflow to compile the real Visual Pinball engine (SDL3 + WebGL2 +
libwinevbs for real VBScript) to WebAssembly.
The patches are validated end-to-end: the patched engine boots in a
real browser, loads a real .vpx table, compiles its real GLSL shaders,
computes environment map radiance, initializes physics, and starts the
VBScript engine, before hanging on the one deliberately-deferred piece
of work (game loop rewrite around emscripten_set_main_loop), documented
in the README's roadmap.