Scaffold vpinball-wasm: Emscripten port of Visual Pinball
CI / Build wasm engine (push) Failing after 3m30s
CI / Publish to npm registry (push) Skipped

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.
This commit is contained in:
2026-08-22 14:30:14 +02:00
commit 0a63835689
18 changed files with 1151 additions and 0 deletions
+138
View File
@@ -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"
+74
View File
@@ -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"
+14
View File
@@ -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
+16
View File
@@ -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"
+84
View File
@@ -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 <noreply@localhost>"
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"
+28
View File
@@ -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