commit 0a638356897e1228e49e629203b26de597feed18 Author: Sebastian Krüger Date: Sat Aug 22 14:30:14 2026 +0200 Scaffold vpinball-wasm: Emscripten port of Visual Pinball 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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0d8d25a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,128 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + name: Build wasm engine + 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: Verify build output + run: | + test -f dist/vpinball.wasm + test -f dist/vpinball.js + echo "Build artifacts present:" + ls -la dist/ + + - name: Build npm wrapper + run: | + npm install --no-save typescript + npx tsc -p package/tsconfig.json + + - uses: https://github.com/actions/upload-artifact@v4 + with: + name: vpinball-wasm-dist + path: dist/ + + publish: + name: Publish to npm registry + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + + - name: Install bison + run: | + sudo apt-get update + sudo apt-get install -y bison + + - name: Cache emsdk + uses: https://github.com/actions/cache@v4 + with: + path: emsdk + key: emsdk-${{ hashFiles('scripts/versions.sh') }} + + - name: Cache wasm32 dependency builds + uses: https://github.com/actions/cache@v4 + with: + path: build/deps-wasm32 + key: deps-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/**') }} + + - name: Cache libwinevbs build + uses: https://github.com/actions/cache@v4 + with: + path: vendor/libwinevbs/build + key: libwinevbs-wasm32-${{ hashFiles('scripts/versions.sh', 'patches/libwinevbs/**') }} + + - name: Setup (fetch + patch vendor sources) + run: ./scripts/setup.sh + + - name: Build wasm32 dependencies + run: | + source emsdk/emsdk_env.sh + ./scripts/build-deps.sh + + - name: Build vpinball + run: | + source emsdk/emsdk_env.sh + ./scripts/build.sh + + - name: Build npm wrapper + run: | + npm install --no-save typescript + npx tsc -p package/tsconfig.json + + - name: Set package version from the tag + run: npm pkg set version="${GITHUB_REF_NAME#v}" + + # Scoped to this one registry host+path (via publishConfig.registry in + # package.json) rather than actions/setup-node's registry-url, which + # would set it as the *default* registry for every install. + - name: Configure registry auth for publish + run: npm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN" + env: + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + + - name: Publish to Gitea npm registry + run: npm publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a51e3c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/build/ +/dist/ +/node_modules/ +/emsdk +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bfca1cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,33 @@ +vpinball-wasm - WebAssembly port of Visual Pinball +==================================================== + +This repository's own original content (build scripts, CMake integration +patches, the npm wrapper under package/, CI configuration, and documentation) +is provided as-is with no additional restrictions beyond those imposed by the +upstream projects it builds against. + +This project compiles and redistributes source from two upstream projects, +each under their own license: + +1. Visual Pinball (https://github.com/vpinball/vpinball), fetched by + scripts/setup.sh into vendor/vpinball/. Visual Pinball is under a MIXED + license: it has been migrating file-by-file from a legacy "old MAME"-like + license to GPLv3+ since October 2020. Each file under GPLv3+ is marked + "// license:GPLv3+" at its top; any file without that marking remains + under the legacy license. See vendor/vpinball/LICENSE (present after + running setup.sh) for the full, authoritative text. Do not redistribute + built artifacts derived from this project without reading that file. + +2. libwinevbs (https://github.com/vpinball/libwinevbs), fetched by + scripts/setup.sh into vendor/libwinevbs/. Predominantly LGPL-2.1 + (Wine-derived), with a small number of ATL header stubs of less certain + provenance - see vendor/libwinevbs/README.md's own License section. + +The small source patches this project applies to both projects (see +patches/) are released under the same license as the file they modify. + +Because the final built .wasm/.js artifact statically links code from both +upstream projects, its distribution terms are governed by their licenses, +not solely by this repository's own (comparatively permissive) original +content. If you are unsure what this means for your use case, consult the +upstream LICENSE files directly rather than relying on this summary. diff --git a/README.md b/README.md new file mode 100644 index 0000000..414f434 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# vpinball-wasm + +**Visual Pinball's real engine, compiled to WebAssembly — real `.vpx` tables, real VBScript table logic, real WebGL2 rendering, in the browser.** + +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 + +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: + +- Loads a real `.vpx` file (the bundled default table) and parses it completely (OLE/BIFF container, all game items, images, sounds metadata). +- Initializes SDL3's Emscripten audio backend and creates a real window via SDL3's Emscripten video driver. +- Compiles **all of vpinball's real, unmodified `.glfx` shaders** (UI, Basic, Ball, DMD, Flasher, Light, Framebuffer — 80 shaders total) against WebGL2/GLES3. +- Computes environment map radiance (HDR/IBL, via FreeImage decoding the table's `.exr` asset) and runs the static pre-render pass (including reflection probes). +- Initializes the real physics engine (octree construction) and starts the real VBScript scripting engine (libwinevbs). +- Reaches `Player::Player@832 Startup done` / `Unpausing Game` — i.e., initialization completes successfully end to end. + +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. + +## Why a source-level port, not a reimplementation + +Projects like [`vpx-js`](https://github.com/vpdb/vpx-js) reimplement Visual Pinball's physics and a VBScript-to-JavaScript transpiler from scratch in TypeScript. That approach has to independently re-derive correct behavior for every physics quirk and every VBScript language feature — and in `vpx-js`'s case, its own test suite documents that it never achieved compatibility with `core.vbs`, the shared script library nearly all real tables depend on. + +This project instead compiles the actual C++ engine and the actual VBScript interpreter (`libwinevbs`, extracted from Wine, the same interpreter real tables already run against on macOS/Linux/iOS/Android today) to WebAssembly. Table compatibility and scripting correctness come from code that's already correct, not from independently re-deriving it. + +## Architecture + +``` + .vpx file (OLE/BIFF) → vpinball's own loader (POLE-based, unmodified) + │ + ┌──────────────────────┴───────────────────────┐ + │ │ + Physics engine Rendering + (unmodified C++) SDL3 → WebGL2/GLES3 (glad) + │ real .glfx shaders + │ │ + VBScript table logic ←────────────────────── libwinevbs + (real Wine-derived interpreter, compiled to wasm32) + │ + emscripten_set_main_loop + (frame stepping - in progress, see Roadmap) +``` + +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. + +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. +- SDL3/WebGL2 spike: proves SDL3's Emscripten backend creates a real WebGL2 context and GLSL ES 3.00 rendering works. +- Feasibility reports covering the native engine, `vpx-js`, and `libwinevbs` specifically. + +## Roadmap + +**Proven working (independent spikes, before this project's own build existed):** +- Real VBScript execution under wasm32 (libwinevbs). +- Real WebGL2/GLES3 rendering under Emscripten (SDL3). + +**Proven working (in this project's own build, in a real browser):** +- `.vpx` file loading and parsing. +- SDL3 audio/video/window initialization under Emscripten. +- Compilation of all of vpinball's real, unmodified `.glfx` shaders against WebGL2. +- Environment map / HDR radiance computation. +- Physics engine initialization (octree). +- VBScript engine startup. +- Static scene pre-render pass, including reflection probes. + +**The current blocker (next milestone):** +- Rewrite `player.cpp`'s game loop dispatch to add an `__EMSCRIPTEN__` branch driven by `emscripten_set_main_loop()`, based on the existing (currently BGFX-only) `CallbackSteppedGameLoop()` "step one frame" function, instead of the blocking `FramePacingGameLoop()`/`GPUQueueStuffingGameLoop()` loops the non-BGFX/GL path uses today. This is real engineering work, not a flag flip — `WinMain`'s control flow needs to reach "loop registered, return now" without blocking first. + +**Explicitly out of scope for v1** (browser-sandbox constraints, not a technical dead end — could be revisited later): +- All plugins (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent features, PUP, AltSound). Excluded via a single CMake guard; zero core-engine impact. +- BGFX renderer path (multi-threaded design doesn't fit a single-threaded wasm32 v1). This project uses vpinball's existing `RENDERER=GL` (glad/GLES) path instead. +- Raw HID device input (`OpenPinDevHandler` / hidapi) — no browser equivalent for real-hardware nudge/plunger boards. +- Multi-threading (no `-pthread`/`SharedArrayBuffer` in v1 — `ThreadPool`'s one-off parallel work, like parallel `.vpx` item deserialization, runs synchronously instead; see `patches/vpinball/0002-*`). + +**Further follow-up work once the main loop is fixed:** +- Browser file-loading UX (drag-and-drop / file picker for user-supplied `.vpx` files beyond the bundled demo table). +- Input mapping (keyboard/gamepad through SDL3's Emscripten backend). +- Binary size / performance tuning (this build is unoptimized `-O1`-equivalent; a real `-O2`/`-Os` release pass, dead-code stripping, and only-needed-SDL3-subsystem linking are all still ahead). +- A browser-based (Puppeteer) CI smoke test, replacing today's compile-only CI check. + +## Build + +Requires: `bison` ≥ 3.8.2, `curl`, `git`, `cmake` ≥ 3.25, `python3` (for the dev server), a POSIX shell. Emscripten itself is installed automatically by `setup.sh`. Expect a multi-gigabyte `emsdk/` + `build/` footprint and a first build in the tens of minutes (subsequent builds are much faster, especially with CI caching). + +```bash +./scripts/setup.sh # installs emsdk + bison check, fetches vpinball + libwinevbs at pinned commits, applies patches/ +source emsdk/emsdk_env.sh +./scripts/build-deps.sh # builds SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs for wasm32 +./scripts/build.sh # configures + builds vpinball itself -> dist/vpinball.{js,wasm,data} +./scripts/dev-server.sh # serves dist/ locally for manual testing +``` + +`scripts/build.sh --debug` produces a build with assertions and runtime-exit-on-return enabled, useful for diagnosing engine startup issues (this is exactly how the current game-loop blocker above was diagnosed). + +## npm usage + +```ts +import { loadPinball } from '@valknar/vpinball-wasm'; + +const pinball = await loadPinball({ canvas: document.querySelector('canvas') }); +pinball.start(); +``` + +The published package name/registry (`@valknar/vpinball-wasm` on this project's Gitea npm registry) is a placeholder pending the first real release — see `package.json`. Runtime lifecycle control (`start`/`stop`/`dispose`) depends on the main-loop rewrite above being completed; today the module boots and initializes but doesn't yet expose a controllable running loop from JS. + +## Known limitations + +- Single-threaded only (no pthreads/SharedArrayBuffer) — see Roadmap. +- No plugin ecosystem (PinMAME/DOF/B2S/DMD/etc.) — see Roadmap. +- No raw hardware input (real cabinet nudge/plunger boards). +- Not yet performance/size-tuned — current unoptimized build produces a ~51MB asset bundle (mostly vpinball's own `src/assets/` textures/EXR files) and a ~1.2MB `.wasm`. + +## 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. + +## Attribution + +- [Visual Pinball](https://github.com/vpinball/vpinball) — the engine this project ports. +- [libwinevbs](https://github.com/vpinball/libwinevbs) — the real VBScript interpreter (Wine-derived), compiled here to wasm32. +- [Wine](https://www.winehq.org/) — original source of the VBScript/OLE Automation engine libwinevbs extracts and packages. diff --git a/examples/basic/index.html b/examples/basic/index.html new file mode 100644 index 0000000..560cacb --- /dev/null +++ b/examples/basic/index.html @@ -0,0 +1,26 @@ + + + + + + vpinball-wasm example + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b32a57a --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@valknar/vpinball-wasm", + "version": "0.0.0", + "description": "Visual Pinball's engine compiled to WebAssembly - real .vpx tables, real VBScript, WebGL2 rendering, in the browser", + "license": "SEE LICENSE IN LICENSE", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/", + "package/assets/", + "README.md", + "LICENSE" + ], + "scripts": { + "build:wrapper": "tsc -p package/tsconfig.json" + }, + "publishConfig": { + "registry": "https://dev.pivoine.art/api/packages/valknar/npm/" + }, + "repository": { + "type": "git", + "url": "https://dev.pivoine.art/valknar/vpinball-wasm.git" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/package/src/index.ts b/package/src/index.ts new file mode 100644 index 0000000..59ab48a --- /dev/null +++ b/package/src/index.ts @@ -0,0 +1,59 @@ +import type { LoadPinballOptions, PinballInstance } from './types.js'; + +export type { LoadPinballOptions, PinballInstance } from './types.js'; + +/** + * Instantiates the WebAssembly build of Visual Pinball against the given + * canvas. + * + * NOTE (current milestone): this wrapper boots the Emscripten module and + * exposes lifecycle control (start/stop/dispose). Runtime table loading + * (`loadTable`) mounts the given bytes into the module's virtual filesystem, + * but wiring the native engine to actually pick up a runtime-loaded table + * (as opposed to the table baked in at build time) is tracked as a follow-up + * milestone in the README's roadmap - see "Browser file-loading UX". + */ +export async function loadPinball(options: LoadPinballOptions): Promise { + const baseUrl = options.baseUrl ?? '.'; + + // dist/vpinball.js is built with -sMODULARIZE=1 -sEXPORT_NAME=VPinballModule, + // so importing it yields a factory function, not a module with side effects. + const factory = (await import(/* @vite-ignore */ `${baseUrl}/vpinball.js`)).default as ( + moduleArgs: Record + ) => Promise; + + const module = await factory({ + canvas: options.canvas, + locateFile: (path: string) => `${baseUrl}/${path}`, + }); + + if (options.tableData) { + mountTable(module, options.tableData); + } + + return { + loadTable(vpxBytes: ArrayBuffer) { + mountTable(module, vpxBytes); + }, + start() { + module.ccall?.('vpinball_wasm_start', null, [], []); + }, + stop() { + module.ccall?.('vpinball_wasm_stop', null, [], []); + }, + dispose() { + module.ccall?.('vpinball_wasm_dispose', null, [], []); + }, + }; +} + +function mountTable(module: EmscriptenModule, vpxBytes: ArrayBuffer): void { + module.FS.writeFile('/table.vpx', new Uint8Array(vpxBytes)); +} + +interface EmscriptenModule { + FS: { + writeFile(path: string, data: Uint8Array): void; + }; + ccall?: (name: string, returnType: string | null, argTypes: string[], args: unknown[]) => unknown; +} diff --git a/package/src/types.ts b/package/src/types.ts new file mode 100644 index 0000000..4ced512 --- /dev/null +++ b/package/src/types.ts @@ -0,0 +1,30 @@ +/** + * Public TypeScript surface for @valknar/vpinball-wasm. + * + * This wraps the raw Emscripten-generated module (dist/vpinball.js) so + * consumers don't need to know about ccall/cwrap/FS internals directly. + */ + +export interface LoadPinballOptions { + /** Canvas the engine renders into via SDL3's WebGL2 backend. */ + canvas: HTMLCanvasElement; + /** + * Raw bytes of a .vpx table file to load on startup. If omitted, the + * bundled default table (package/assets/test000-default-table.vpx) is + * used instead. + */ + tableData?: ArrayBuffer; + /** Base URL to fetch dist/vpinball.wasm (and .data, if present) from. */ + baseUrl?: string; +} + +export interface PinballInstance { + /** Load a different .vpx table at runtime, replacing the current one. */ + loadTable(vpxBytes: ArrayBuffer): void; + /** Start (or resume) the simulation's main loop. */ + start(): void; + /** Pause the simulation's main loop. */ + stop(): void; + /** Tear down the instance and free its WebAssembly memory. */ + dispose(): void; +} diff --git a/package/tsconfig.json b/package/tsconfig.json new file mode 100644 index 0000000..0263d17 --- /dev/null +++ b/package/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "../dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/patches/libwinevbs/0001-winnt-wasm32-context-stub.patch b/patches/libwinevbs/0001-winnt-wasm32-context-stub.patch new file mode 100644 index 0000000..e29a925 --- /dev/null +++ b/patches/libwinevbs/0001-winnt-wasm32-context-stub.patch @@ -0,0 +1,34 @@ +diff --git a/wine/include/winnt.h b/wine/include/winnt.h +index 477bd87..d9357fd 100644 +--- a/wine/include/winnt.h ++++ b/wine/include/winnt.h +@@ -2043,6 +2043,29 @@ typedef ARM64_NT_CONTEXT CONTEXT, *PCONTEXT; + + #endif /* __aarch64__ */ + ++#if defined(__LIBWINEVBS__) && defined(__EMSCRIPTEN__) ++/* libwinevbs-only: wasm32 has no real CPU context / SEH unwinding to describe. ++ * This library never performs structured exception handling or stack ++ * unwinding (no real threads, no SEH usage) - these types only need to exist ++ * so the unconditional NTSYSAPI declarations below type-check. */ ++#define CONTEXT_FULL 0x1 ++ ++typedef struct _RUNTIME_FUNCTION ++{ ++ DWORD dummy; ++} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION; ++ ++typedef struct _CONTEXT ++{ ++ DWORD ContextFlags; ++} CONTEXT, *PCONTEXT; ++ ++typedef struct _KNONVOLATILE_CONTEXT_POINTERS ++{ ++ DWORD dummy; ++} KNONVOLATILE_CONTEXT_POINTERS, *PKNONVOLATILE_CONTEXT_POINTERS; ++#endif /* __LIBWINEVBS__ && __EMSCRIPTEN__ */ ++ + #if !defined(CONTEXT_FULL) && !defined(RC_INVOKED) + #error You need to define a CONTEXT for your CPU + #endif diff --git a/patches/vpinball/0001-emscripten-cmake-target.patch b/patches/vpinball/0001-emscripten-cmake-target.patch new file mode 100644 index 0000000..c4706d1 --- /dev/null +++ b/patches/vpinball/0001-emscripten-cmake-target.patch @@ -0,0 +1,271 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 1fcf242..f9338b2 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -50,6 +50,7 @@ set(_vpx_valid_combos + GL-windows + GL-macos + GL-linux ++ GL-emscripten + DX9-windows) + if(NOT "${RENDERER}-${PLATFORM}" IN_LIST _vpx_valid_combos) + string(REPLACE ";" "\n " _vpx_available "${_vpx_valid_combos}") +@@ -784,6 +785,123 @@ elseif(PLATFORM STREQUAL "linux") + endif() + endif() + ++elseif(PLATFORM STREQUAL "emscripten") ++ # Static wasm32 dependency libs/headers staged by vpinball-wasm's ++ # scripts/build-deps.sh (SDL3/SDL3_image/SDL3_ttf/FreeImage/libwinevbs ++ # built via emcmake). No RPATH concept applies to a wasm binary. ++ if(NOT DEFINED VPINBALL_WASM_DEPS_DIR) ++ set(VPINBALL_WASM_DEPS_DIR "${CMAKE_SOURCE_DIR}/../build/deps-wasm32") ++ endif() ++ ++ if(RENDERER STREQUAL "GL") ++ add_library(glad ++ third-party/include/glad/src/gles2.c ++ ) ++ set_target_properties(glad PROPERTIES LINKER_LANGUAGE C) ++ target_include_directories(glad PUBLIC ++ third-party/include/glad/include ++ ) ++ endif() ++ ++ # No raw HID device access in a browser sandbox (real-hardware nudge/plunger ++ # boards) - hidapi-hidraw is not linked for this platform, so exclude its ++ # one consumer. ++ list(REMOVE_ITEM VPX_STANDALONE_SOURCES ++ src/input/OpenPinDevHandler.cpp ++ ) ++ ++ add_executable(vpinball ++ ${VPX_STANDALONE_SOURCES} ++ ) ++ ++ target_include_directories(vpinball PUBLIC ++ ${CMAKE_SOURCE_DIR}/third-party/include ++ ${CMAKE_SOURCE_DIR} ++ ${VPINBALL_WASM_DEPS_DIR}/include ++ ${VPINBALL_WASM_DEPS_DIR}/include/libwinevbs/atl/include ++ ${VPINBALL_WASM_DEPS_DIR}/include/libwinevbs/atlmfc/include ++ ${VPINBALL_WASM_DEPS_DIR}/include/libwinevbs/wine/include ++ src ++ plugins ++ ) ++ ++ target_compile_definitions(vpinball PRIVATE ++ __STANDALONE__ ++ ++ __LIBWINEVBS__ ++ __WINESRC__ ++ WINE_UNICODE_NATIVE ++ ++ "__forceinline=__attribute__((always_inline)) inline" ++ ) ++ ++ target_compile_definitions(vpinball PRIVATE ++ ENABLE_OPENGL ++ __OPENGLES__ ++ ) ++ ++ target_compile_options(vpinball PUBLIC ++ -fdenormal-fp-math=preserve-sign ++ -freciprocal-math ++ -fassociative-math ++ -fapprox-func ++ -fno-math-errno ++ -fno-trapping-math ++ -ffp-contract=off ++ -Wno-shorten-64-to-32 ++ ) ++ ++ target_link_directories(vpinball PUBLIC ++ "${VPINBALL_WASM_DEPS_DIR}/lib" ++ ) ++ ++ target_link_libraries(vpinball PUBLIC ++ SDL3 ++ SDL3_image ++ SDL3_ttf ++ freetype ++ freeimage ++ winevbs ++ glad ++ ) ++ ++ set_target_properties(vpinball PROPERTIES ++ RUNTIME_OUTPUT_NAME "vpinball" ++ SUFFIX ".js" ++ ) ++ ++ set(RESOURCES_DIR "$") ++ set(SHADER_DIR_NAME "shaders-${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_REV}") ++ set(SHADER_DIR "${RESOURCES_DIR}/${SHADER_DIR_NAME}") ++ ++ # PRE_LINK (not POST_BUILD): the --preload-file linker flags below run ++ # emscripten's file_packager as part of the link step itself, so the ++ # staged directories must exist *before* linking, not after. ++ add_custom_command(TARGET vpinball PRE_LINK ++ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/src/assets" "${RESOURCES_DIR}/assets" ++ COMMAND "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_SOURCE_DIR}/scripts" "${RESOURCES_DIR}/scripts" ++ COMMAND "${CMAKE_COMMAND}" -E make_directory "${RESOURCES_DIR}/tables" ++ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/tests/assets/test000-default-table.vpx" "${RESOURCES_DIR}/tables/default.vpx" ++ COMMAND "${CMAKE_COMMAND}" -E make_directory "${SHADER_DIR}" ++ ) ++ foreach(_shader IN LISTS VPX_GL_SHADERS) ++ add_custom_command(TARGET vpinball PRE_LINK ++ COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/src/shaders/hlsl_glsl/${_shader}" "${SHADER_DIR}" ++ ) ++ endforeach() ++ ++ # Embed the staged assets/scripts/shaders/default table into the wasm ++ # build's virtual filesystem so the engine can open them with ordinary ++ # fopen()/std::ifstream calls, unmodified, exactly as it does natively. ++ # Mount points are relative (matching how the native build locates these ++ # directories next to the executable, i.e. relative to cwd "/" in MEMFS). ++ target_link_options(vpinball PRIVATE ++ "SHELL:--preload-file ${RESOURCES_DIR}/assets@assets" ++ "SHELL:--preload-file ${RESOURCES_DIR}/scripts@scripts" ++ "SHELL:--preload-file ${RESOURCES_DIR}/tables@tables" ++ "SHELL:--preload-file ${SHADER_DIR}@${SHADER_DIR_NAME}" ++ ) ++ + # iOS and Android libvpinball build + + elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM STREQUAL "android") +@@ -957,4 +1075,9 @@ elseif(PLATFORM STREQUAL "ios" OR PLATFORM STREQUAL "ios-simulator" OR PLATFORM + + endif() + +-include("${CMAKE_SOURCE_DIR}/make/CMakeLists_plugins.txt") ++# vpinball-wasm: plugins are all optional/native-hardware-integration-scoped ++# (PinMAME, DOF, B2S, FlexDMD, Inspector, FFmpeg-dependent, etc.) and don't ++# map to a browser sandbox for this project's v1 - see README's roadmap. ++if(NOT PLATFORM STREQUAL "emscripten") ++ include("${CMAKE_SOURCE_DIR}/make/CMakeLists_plugins.txt") ++endif() +diff --git a/src/core/def.h b/src/core/def.h +index 7335929..eb2c0b4 100644 +--- a/src/core/def.h ++++ b/src/core/def.h +@@ -337,6 +337,9 @@ static const string platform_bits[2] = { "32"s, "64"s }; + #elif defined(__ANDROID__) // leave here, as it also defines linux + #define GET_PLATFORM_OS_ENUM 1 + #define GET_PLATFORM_OS "android" ++#elif defined(__EMSCRIPTEN__) // leave here, as it also defines linux on some toolchains ++ #define GET_PLATFORM_OS_ENUM 6 ++ #define GET_PLATFORM_OS "emscripten" + #elif (defined(__linux) || defined(__linux__)) + #define GET_PLATFORM_OS_ENUM 2 + #define GET_PLATFORM_OS "linux" +@@ -352,7 +355,7 @@ static const string platform_bits[2] = { "32"s, "64"s }; + #define GET_PLATFORM_OS "macos" + #endif + #endif +-static const string platform_os[6] = { "windows"s, "android"s, "linux"s, "ios"s, "tvos"s, "macos"s }; ++static const string platform_os[7] = { "windows"s, "android"s, "linux"s, "ios"s, "tvos"s, "macos"s, "emscripten"s }; + + #if defined(ENABLE_BGFX) + #define GET_PLATFORM_RENDERER_ENUM 2 +diff --git a/src/core/main.cpp b/src/core/main.cpp +index dafe6ee..0015268 100644 +--- a/src/core/main.cpp ++++ b/src/core/main.cpp +@@ -228,13 +228,14 @@ extern "C" int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE /*hPrevInstance*/, + return retval; + } + +-#if defined(__STANDALONE__) && ((defined(__linux__) && !defined(__ANDROID__)) || defined(__MINGW32__)) ++#if defined(__STANDALONE__) && ((defined(__linux__) && !defined(__ANDROID__)) || defined(__MINGW32__) || defined(__EMSCRIPTEN__)) + extern int g_argc; + extern const char **g_argv; + int main(int argc, const char** argv) { + #ifdef __MINGW32__ + signal(SIGINT, OnSignalHandler); +-#else ++#elif !defined(__EMSCRIPTEN__) ++ // No meaningful SIGINT/process-signal equivalent in a browser sandbox. + struct sigaction sigIntHandler; + sigIntHandler.sa_handler = OnSignalHandler; + sigemptyset(&sigIntHandler.sa_mask); +diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp +index 540b822..1af6bd8 100644 +--- a/src/input/InputManager.cpp ++++ b/src/input/InputManager.cpp +@@ -20,7 +20,8 @@ + + #include "input/SDLInputHandler.h" + +-#ifndef __LIBVPINBALL__ ++#if !defined(__LIBVPINBALL__) && !defined(__EMSCRIPTEN__) ++ // No raw HID device access in a browser sandbox. + #include "input/OpenPinDevHandler.h" + #endif + +@@ -82,7 +83,7 @@ InputManager::InputManager(Player* player) + // Initialize device handlers + m_inputHandlers.push_back(std::make_unique(*this)); + m_sdlHandler = static_cast(m_inputHandlers.back().get()); +- #ifndef __LIBVPINBALL__ ++ #if !defined(__LIBVPINBALL__) && !defined(__EMSCRIPTEN__) + m_inputHandlers.push_back(std::make_unique(*this)); + #endif + +diff --git a/src/physics/kdtree.cpp b/src/physics/kdtree.cpp +index 3f262a9..10ac63e 100644 +--- a/src/physics/kdtree.cpp ++++ b/src/physics/kdtree.cpp +@@ -421,7 +421,7 @@ void HitKDNode::HitTestBall(const HitKD* hitoct, const HitBall* const pball, Col + if (pball->m_hitBBox.right >= vcenter) + m_children[1].HitTestBall(hitoct, pball, coll); + } +- else f (axis == 1) ++ else if (axis == 1) + { + const float vcenter = (m_rectbounds.top+m_rectbounds.bottom)*0.5f; + if (pball->m_hitBBox.top <= vcenter) +diff --git a/standalone/vpinball_standalone_i.h b/standalone/vpinball_standalone_i.h +index 29898aa..3bac681 100644 +--- a/standalone/vpinball_standalone_i.h ++++ b/standalone/vpinball_standalone_i.h +@@ -3351,8 +3351,19 @@ ITableGlobal : public IDispatch + virtual HRESULT STDMETHODCALLTYPE put_ShowCursor( + VARIANT_BOOL show) = 0; + ++ // vpinball-wasm: mirrors the #ifdef _WIN64 split already used by ++ // ScriptGlobalTable's own declaration/implementation of this method - ++ // this generated interface header previously hardcoded the 64-bit ++ // (SIZE_T) overload unconditionally, which made ScriptGlobalTable ++ // abstract (unable to override this pure virtual) on 32-bit targets ++ // like wasm32. ++#ifdef _WIN64 + virtual HRESULT STDMETHODCALLTYPE get_GetPlayerHWnd( + SIZE_T *pVal) = 0; ++#else ++ virtual HRESULT STDMETHODCALLTYPE get_GetPlayerHWnd( ++ LONG *pVal) = 0; ++#endif + + virtual HRESULT STDMETHODCALLTYPE StopSound( + BSTR Sound) = 0; +diff --git a/standalone/vpinball_standalone_i_proxy.cpp b/standalone/vpinball_standalone_i_proxy.cpp +index 72a2d2e..2fe1b73 100644 +--- a/standalone/vpinball_standalone_i_proxy.cpp ++++ b/standalone/vpinball_standalone_i_proxy.cpp +@@ -3043,7 +3043,15 @@ STDMETHODIMP ScriptGlobalTable::Invoke(DISPID dispIdMember, REFIID /*riid*/, LCI + if (wFlags & DISPATCH_PROPERTYGET) { + // line 724: [propget, id(14), helpstring("property GetPlayerHWnd")] HRESULT GetPlayerHWnd([out, retval] SIZE_T *pVal); + V_VT(&res) = VT_UI4; ++ // vpinball-wasm: this generated proxy hardcodes the _WIN64 (SIZE_T) ++ // overload of get_GetPlayerHWnd; on a genuine 32-bit target (wasm32) ++ // the interface instead declares the LONG overload (see ++ // ScriptGlobalTable.h's #ifdef _WIN64), so match it here too. ++#ifdef _WIN64 + hres = get_GetPlayerHWnd((SIZE_T*)&V_UI4(&res)); ++#else ++ hres = get_GetPlayerHWnd((LONG*)&V_UI4(&res)); ++#endif + } + break; + } diff --git a/patches/vpinball/0002-threadpool-single-threaded-emscripten.patch b/patches/vpinball/0002-threadpool-single-threaded-emscripten.patch new file mode 100644 index 0000000..dd3ad3b --- /dev/null +++ b/patches/vpinball/0002-threadpool-single-threaded-emscripten.patch @@ -0,0 +1,47 @@ +--- a/third-party/include/ThreadPool.h ++++ b/third-party/include/ThreadPool.h +@@ -110,9 +110,20 @@ + : pool_size(threads) + , in_flight(0) + { ++#ifndef __EMSCRIPTEN__ + const std::unique_lock lock(this->queue_mutex); + for (std::size_t i = 0; i != threads; ++i) + start_worker(i, lock); ++#else ++ // vpinball-wasm: a single-threaded Emscripten build (no -pthread / ++ // SharedArrayBuffer for v1) cannot construct real std::thread workers - ++ // std::thread's constructor throws at runtime otherwise ("thread ++ // constructor failed"). Run zero real workers; enqueue() below executes ++ // tasks synchronously and inline instead, matching the porting plan's ++ // "run ThreadPool work serially" decision for one-off parallel work ++ // (parallel .vpx item deserialization at load, env-map baking, etc.). ++ (void)pool_size; ++#endif + } + + // add new work item to the pool +@@ -139,6 +150,15 @@ + + std::future res = task->get_future(); + ++#ifdef __EMSCRIPTEN__ ++ // vpinball-wasm: no real worker threads exist (see the constructor) - ++ // run the task synchronously inline so callers still get a valid, ++ // already-ready future, exactly as if it had run on a worker. ++ (void)std::atomic_fetch_add_explicit(&in_flight, std::size_t(1), std::memory_order_relaxed); ++ (*task)(); ++ (void)std::atomic_fetch_sub_explicit(&in_flight, std::size_t(1), std::memory_order_acq_rel); ++ return res; ++#else + std::unique_lock lock(queue_mutex); + if (tasks.size () >= max_queue_size) + // wait for the queue to empty or be stopped +@@ -160,6 +180,7 @@ + condition_consumers.notify_one(); + + return res; ++#endif + } + + diff --git a/scripts/build-deps.sh b/scripts/build-deps.sh new file mode 100755 index 0000000..231ad28 --- /dev/null +++ b/scripts/build-deps.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Builds the wasm32 static-library dependency set vpinball needs: +# SDL3, SDL3_image, SDL3_ttf (+freetype), FreeImage, libwinevbs. +# +# Modeled on vendor/vpinball/platforms/linux-x64/external.sh's own build +# sections, with `cmake` swapped for `emcmake cmake` and shared libs swapped +# for static (a single wasm binary has no runtime dynamic loader). Codec/font +# backends are scoped down from vpinball's native default to what's realistic +# for browser-loaded .vpx textures (PNG/JPEG/BMP/etc. via SDL3_image's +# built-in decoders + stb, no libpng/libjpeg/libwebp/libtiff/libjxl/libavif +# needed at all; SDL3_ttf uses vendored freetype only, no harfbuzz/plutosvg) +# - this avoids pulling in several hundred MB of vendored codec submodules +# (AV1/AVIF in particular) that real VPX tables don't exercise. +# +# Requires: scripts/setup.sh has already run (vendor/ populated+patched, +# emsdk installed). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/versions.sh" + +VENDOR_DIR="$ROOT_DIR/vendor" +DEPS_DIR="$ROOT_DIR/build/deps-wasm32" +WORK_DIR="$ROOT_DIR/build/deps-src" +NUM_PROCS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + +if [ ! -d "$VENDOR_DIR/vpinball" ] || [ ! -d "$VENDOR_DIR/libwinevbs" ]; then + echo "ERROR: vendor/ not populated. Run scripts/setup.sh first." >&2 + exit 1 +fi +if ! command -v emcc >/dev/null 2>&1; then + echo "ERROR: emcc not on PATH. Run: source $ROOT_DIR/emsdk/emsdk_env.sh" >&2 + exit 1 +fi + +mkdir -p "$DEPS_DIR/lib" "$DEPS_DIR/include" "$WORK_DIR" + +cache_check() { [ "$(cat "$1/.cache-sha" 2>/dev/null || true)" = "$2" ]; } + +# --- SDL3 core ------------------------------------------------------------- +if ! cache_check "$WORK_DIR/SDL3/SDL" "$SDL_SHA"; then + echo "== Building SDL3 core for wasm32 ==" + rm -rf "$WORK_DIR/SDL3/SDL" + mkdir -p "$WORK_DIR/SDL3" + cd "$WORK_DIR/SDL3" + curl -sL "https://github.com/libsdl-org/SDL/archive/${SDL_SHA}.tar.gz" | tar xz + mv "SDL-${SDL_SHA}" SDL + ( cd SDL && emcmake cmake -DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TEST_LIBRARY=OFF \ + -DCMAKE_BUILD_TYPE=Release -B build && cmake --build build -- -j"$NUM_PROCS" ) + echo "$SDL_SHA" > "$WORK_DIR/SDL3/SDL/.cache-sha" + cd "$ROOT_DIR" +fi + +# --- SDL3_image (built-in decoders + stb only, no vendored codec libs) ---- +if ! cache_check "$WORK_DIR/SDL3/SDL_image" "$SDL_IMAGE_SHA"; then + echo "== Building SDL3_image for wasm32 ==" + rm -rf "$WORK_DIR/SDL3/SDL_image" + cd "$WORK_DIR/SDL3" + curl -sL "https://github.com/libsdl-org/SDL_image/archive/${SDL_IMAGE_SHA}.tar.gz" | tar xz + mv "SDL_image-${SDL_IMAGE_SHA}" SDL_image + ( cd SDL_image && emcmake cmake \ + -DBUILD_SHARED_LIBS=OFF -DSDLIMAGE_SAMPLES=OFF -DSDLIMAGE_VENDORED=OFF \ + -DSDLIMAGE_AVIF=OFF -DSDLIMAGE_WEBP=OFF -DSDLIMAGE_JXL=OFF -DSDLIMAGE_TIF=OFF \ + -DSDLIMAGE_PNG_LIBPNG=OFF \ + -DSDL3_DIR=../SDL/build -DCMAKE_BUILD_TYPE=Release -B build \ + && cmake --build build -- -j"$NUM_PROCS" ) + echo "$SDL_IMAGE_SHA" > "$WORK_DIR/SDL3/SDL_image/.cache-sha" + cd "$ROOT_DIR" +fi + +# --- SDL3_ttf (vendored freetype only, no harfbuzz/plutosvg) -------------- +if ! cache_check "$WORK_DIR/SDL3/SDL_ttf" "$SDL_TTF_SHA"; then + echo "== Building SDL3_ttf for wasm32 ==" + rm -rf "$WORK_DIR/SDL3/SDL_ttf" + cd "$WORK_DIR/SDL3" + curl -sL "https://github.com/libsdl-org/SDL_ttf/archive/${SDL_TTF_SHA}.tar.gz" | tar xz + mv "SDL_ttf-${SDL_TTF_SHA}" SDL_ttf + ( cd SDL_ttf && git clone --filter=blob:none --depth 1 \ + https://github.com/libsdl-org/freetype.git external/freetype \ + && emcmake cmake \ + -DBUILD_SHARED_LIBS=OFF -DSDLTTF_SAMPLES=OFF -DSDLTTF_VENDORED=ON \ + -DSDLTTF_HARFBUZZ=OFF -DSDLTTF_PLUTOSVG=OFF \ + -DSDL3_DIR=../SDL/build -DCMAKE_BUILD_TYPE=Release -B build \ + && cmake --build build -- -j"$NUM_PROCS" ) + echo "$SDL_TTF_SHA" > "$WORK_DIR/SDL3/SDL_ttf/.cache-sha" + cd "$ROOT_DIR" +fi + +cp "$WORK_DIR"/SDL3/SDL/build/libSDL3.a "$DEPS_DIR/lib/" +cp "$WORK_DIR"/SDL3/SDL_image/build/libSDL3_image.a "$DEPS_DIR/lib/" +cp "$WORK_DIR"/SDL3/SDL_ttf/build/libSDL3_ttf.a "$DEPS_DIR/lib/" +cp "$WORK_DIR"/SDL3/SDL_ttf/build/external/freetype/libfreetype.a "$DEPS_DIR/lib/" +cp -r "$WORK_DIR/SDL3/SDL/include/SDL3" "$DEPS_DIR/include/" +cp -r "$WORK_DIR/SDL3/SDL_image/include/SDL3_image" "$DEPS_DIR/include/" +cp -r "$WORK_DIR/SDL3/SDL_ttf/include/SDL3_ttf" "$DEPS_DIR/include/" + +# --- FreeImage (bundles all its codecs as vendored source, no submodules) - +if ! cache_check "$WORK_DIR/freeimage" "$FREEIMAGE_SHA"; then + echo "== Building FreeImage for wasm32 ==" + rm -rf "$WORK_DIR/freeimage" + mkdir -p "$WORK_DIR/freeimage" + cd "$WORK_DIR/freeimage" + curl -sL "https://github.com/toxieainc/freeimage/archive/${FREEIMAGE_SHA}.tar.gz" | tar xz + mv "freeimage-${FREEIMAGE_SHA}" freeimage + # PLATFORM/ARCH here are freeimage's own bookkeeping vars (like + # libwinevbs's), not a toolchain gate - emcmake supplies the real wasm32 + # toolchain, so any placeholder PLATFORM value that isn't specially + # branched on (macos/ios/tvos/android) works unmodified. + ( cd freeimage && emcmake cmake -DPLATFORM=linux -DARCH=wasm32 -DBUILD_STATIC=ON \ + -DBUILD_SHARED=OFF -DCMAKE_BUILD_TYPE=Release -B build && cmake --build build -- -j"$NUM_PROCS" ) + echo "$FREEIMAGE_SHA" > "$WORK_DIR/freeimage/.cache-sha" + cd "$ROOT_DIR" +fi + +cp "$WORK_DIR"/freeimage/freeimage/build/libfreeimage.a "$DEPS_DIR/lib/" +cp "$WORK_DIR/freeimage/freeimage/Source/FreeImage.h" "$DEPS_DIR/include/" + +# --- libwinevbs (its own CMakeLists.txt needs no patch beyond the winnt.h -- +# --- CONTEXT stub already applied by setup.sh; PLATFORM/ARCH are likewise -- +# --- just its own bookkeeping, same as FreeImage above) -------------------- +if ! cache_check "$VENDOR_DIR/libwinevbs" "built-${LIBWINEVBS_SHA}"; then + echo "== Building libwinevbs for wasm32 ==" + ( cd "$VENDOR_DIR/libwinevbs" && emcmake cmake -DPLATFORM=emscripten -DARCH=wasm32 \ + -DBUILD_STATIC=ON -DBUILD_SHARED=OFF -DCMAKE_BUILD_TYPE=Release -B build \ + && cmake --build build -- -j"$NUM_PROCS" ) + echo "built-${LIBWINEVBS_SHA}" > "$VENDOR_DIR/libwinevbs/.cache-sha" +fi + +cp "$VENDOR_DIR"/libwinevbs/build/libwinevbs.a "$DEPS_DIR/lib/" +mkdir -p "$DEPS_DIR/include/libwinevbs/wine/include" "$DEPS_DIR/include/libwinevbs/atl/include" "$DEPS_DIR/include/libwinevbs/atlmfc/include" +cp "$VENDOR_DIR/libwinevbs/include/libwinevbs.h" "$DEPS_DIR/include/libwinevbs/" +cp -r "$VENDOR_DIR/libwinevbs/wine/include/"* "$DEPS_DIR/include/libwinevbs/wine/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/" + +echo "" +echo "== build-deps.sh complete: $DEPS_DIR ==" +ls -la "$DEPS_DIR/lib" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..0e387e7 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Configures and builds vpinball itself for the new GL-emscripten CMake +# target, producing dist/vpinball.{js,wasm}. +# +# Requires: scripts/setup.sh and scripts/build-deps.sh have already run. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/versions.sh" + +VPINBALL_DIR="$ROOT_DIR/vendor/vpinball" +DEPS_DIR="$ROOT_DIR/build/deps-wasm32" +BUILD_DIR="$ROOT_DIR/build/vpinball-wasm" +DIST_DIR="$ROOT_DIR/dist" + +BUILD_TYPE="Release" +if [ "${1:-}" = "--debug" ]; then + BUILD_TYPE="Debug" +fi + +if [ ! -d "$VPINBALL_DIR" ]; then + echo "ERROR: vendor/vpinball missing. Run scripts/setup.sh first." >&2 + exit 1 +fi +if [ ! -d "$DEPS_DIR/lib" ] || [ -z "$(ls -A "$DEPS_DIR/lib" 2>/dev/null)" ]; then + echo "ERROR: build/deps-wasm32 missing/empty. Run scripts/build-deps.sh first." >&2 + exit 1 +fi +if ! command -v emcc >/dev/null 2>&1; then + echo "ERROR: emcc not on PATH. Run: source $ROOT_DIR/emsdk/emsdk_env.sh" >&2 + exit 1 +fi + +NUM_PROCS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + +EMSCRIPTEN_LINK_FLAGS=( + -sUSE_WEBGL2=1 + -sFULL_ES3=1 + -sMIN_WEBGL_VERSION=2 + -sMAX_WEBGL_VERSION=2 + -sALLOW_MEMORY_GROWTH=1 + -sMODULARIZE=1 + -sEXPORT_NAME=VPinballModule + -sENVIRONMENT=web + -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap + -sFORCE_FILESYSTEM=1 +) +if [ "$BUILD_TYPE" = "Debug" ]; then + EMSCRIPTEN_LINK_FLAGS+=(-sASSERTIONS=1 -sEXIT_RUNTIME=1) +fi + +emcmake cmake \ + -S "$VPINBALL_DIR" \ + -B "$BUILD_DIR" \ + -DRENDERER=GL \ + -DPLATFORM=emscripten \ + -DARCH=wasm32 \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DVPINBALL_WASM_DEPS_DIR="$DEPS_DIR" \ + -DCMAKE_EXE_LINKER_FLAGS="${EMSCRIPTEN_LINK_FLAGS[*]}" + +cmake --build "$BUILD_DIR" -- -j"$NUM_PROCS" + +mkdir -p "$DIST_DIR" +cp "$BUILD_DIR"/vpinball.js "$DIST_DIR/" 2>/dev/null || true +cp "$BUILD_DIR"/vpinball.wasm "$DIST_DIR/" 2>/dev/null || true +cp "$BUILD_DIR"/vpinball.data "$DIST_DIR/" 2>/dev/null || true + +mkdir -p "$ROOT_DIR/package/assets" +cp "$VPINBALL_DIR/tests/assets/test000-default-table.vpx" "$ROOT_DIR/package/assets/default.vpx" 2>/dev/null || true + +echo "" +echo "== build.sh complete ==" +ls -la "$DIST_DIR" diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..cfeca54 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Removes build artifacts. Pass --all to also remove the emsdk install +# (slow to reinstall - kept by default across ordinary cleans). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +rm -rf "$ROOT_DIR/vendor" "$ROOT_DIR/build" "$ROOT_DIR/dist" +echo "Removed vendor/, build/, dist/" + +if [ "${1:-}" = "--all" ]; then + rm -rf "$ROOT_DIR/emsdk" + echo "Removed emsdk/" +fi diff --git a/scripts/dev-server.sh b/scripts/dev-server.sh new file mode 100755 index 0000000..814620b --- /dev/null +++ b/scripts/dev-server.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Serves dist/ locally for manual browser testing. No COOP/COEP headers are +# needed since this build is single-threaded (no pthreads/SharedArrayBuffer). +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" +PORT="${1:-8080}" + +if [ ! -f "$DIST_DIR/index.html" ] && [ ! -f "$DIST_DIR/vpinball.js" ]; then + echo "ERROR: dist/ is empty. Run scripts/build.sh first." >&2 + exit 1 +fi + +echo "Serving $DIST_DIR at http://localhost:$PORT/" +cd "$DIST_DIR" && python3 -m http.server "$PORT" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..a67a22f --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# One-time (or cold-cache) environment bring-up: +# - installs/activates a pinned Emscripten SDK +# - checks for bison +# - fetches vpinball + libwinevbs at their pinned commits into vendor/ +# - applies this project's patches to both +# +# Safe to re-run: skips work that's already done at the pinned versions. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT_DIR/scripts/versions.sh" + +VENDOR_DIR="$ROOT_DIR/vendor" +EMSDK_DIR="$ROOT_DIR/emsdk" + +echo "== Checking bison ==" +if ! command -v bison >/dev/null 2>&1; then + echo "ERROR: bison not found on PATH. Install bison >= ${BISON_MIN_VERSION} and re-run." >&2 + exit 1 +fi +echo "Found: $(bison --version | head -1)" + +echo "== Setting up Emscripten SDK ${EMSDK_VERSION} ==" +if [ ! -d "$EMSDK_DIR" ]; then + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" +fi +( + cd "$EMSDK_DIR" + if [ "$(cat .installed-version 2>/dev/null || true)" != "$EMSDK_VERSION" ]; then + ./emsdk install "$EMSDK_VERSION" + ./emsdk activate "$EMSDK_VERSION" + echo "$EMSDK_VERSION" > .installed-version + fi +) +echo "emsdk ready at $EMSDK_DIR (source $EMSDK_DIR/emsdk_env.sh before building)" + +fetch_pinned() { + local name="$1" url="$2" sha="$3" dest="$4" + local sentinel="$dest/.vpinball-wasm-sha" + + if [ -f "$sentinel" ] && [ "$(cat "$sentinel")" = "$sha" ]; then + echo "$name already at pinned commit $sha, skipping fetch." + return + fi + + echo "== Fetching $name @ $sha ==" + rm -rf "$dest" + mkdir -p "$dest" + curl -sL "$url/archive/${sha}.tar.gz" -o "/tmp/${name}-${sha}.tar.gz" + tar xzf "/tmp/${name}-${sha}.tar.gz" -C "$dest" --strip-components=1 + rm -f "/tmp/${name}-${sha}.tar.gz" + git -C "$dest" init -q + git -C "$dest" add -A + git -C "$dest" commit -q -m "vendor: $name @ $sha" --author="vpinball-wasm setup " + echo "$sha" > "$sentinel" +} + +apply_patches() { + local name="$1" dest="$2" + local patch_dir="$ROOT_DIR/patches/$name" + [ -d "$patch_dir" ] || return 0 + + for patch in "$patch_dir"/*.patch; do + [ -e "$patch" ] || continue + echo "== Applying $(basename "$patch") to $name ==" + if git -C "$dest" apply --reverse --check "$patch" 2>/dev/null; then + echo " already applied, skipping." + else + git -C "$dest" apply "$patch" + fi + done +} + +mkdir -p "$VENDOR_DIR" +fetch_pinned "vpinball" "https://github.com/vpinball/vpinball" "$VPINBALL_SHA" "$VENDOR_DIR/vpinball" +fetch_pinned "libwinevbs" "https://github.com/vpinball/libwinevbs" "$LIBWINEVBS_SHA" "$VENDOR_DIR/libwinevbs" + +apply_patches "vpinball" "$VENDOR_DIR/vpinball" +apply_patches "libwinevbs" "$VENDOR_DIR/libwinevbs" + +echo "" +echo "== Setup complete ==" +echo "Next: source $EMSDK_DIR/emsdk_env.sh && ./scripts/build-deps.sh" diff --git a/scripts/versions.sh b/scripts/versions.sh new file mode 100755 index 0000000..8bd3f7c --- /dev/null +++ b/scripts/versions.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Single source of truth for every pinned version/commit this project builds +# against. Sourced by every other script in this directory and used to derive +# CI cache keys. Keep these in sync with the upstream vpinball checkout's own +# platforms/config.sh - do not drift silently. + +# Pinned to the exact commit already researched/verified in +# /home/valknar/Projekte/vpinball-rust/repos/vpinball (HEAD as of 2026-08-21). +VPINBALL_SHA=195f214dd4849b8ef6113fd377258b78d0dec3bf + +# Pinned to the exact commit already validated end-to-end by the +# libwinevbs-wasm spike (compiles + runs real VBScript under Emscripten). +# This matches vpinball's own platforms/config.sh LIBWINEVBS_SHA exactly. +LIBWINEVBS_SHA=bcc790e58d394b282c327feca2a7c921ca022e8d + +# Mirrors vpinball's platforms/config.sh pins, so this project's wasm build +# stays close to what every other vpinball platform actually ships. +SDL_SHA=147a8ee32dbf9ac02f3794964490687b6bbda1bc +SDL_IMAGE_SHA=bec9134a26c7d0f31b36d6083c25296e04cabff5 +SDL_TTF_SHA=a1ce3670aec736ecbf0936c43f2f0cc53aa61e5b +FREEIMAGE_SHA=b1613452a0c3849d43ac877b154cf51ff9e078d3 + +# Emscripten SDK version already validated by both spikes +# (spikes/libwinevbs-wasm and spikes/sdl3-gles3-wasm). +EMSDK_VERSION=6.0.8 + +# Matches libwinevbs's `find_package(BISON 3.8.2 REQUIRED)`. +BISON_MIN_VERSION=3.8.2