From 3fd4f03019ba6f76bc9cc2d272120313f83fe0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 27 Jun 2026 08:55:56 +0200 Subject: [PATCH] fix(web): integrate compiled game WASM into the web build (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build flow (`./native/web/build.sh game.ts`) never produced a working game: `build.sh` compiled the game to a throwaway `/tmp/bloom_game.html` and copied an unrelated template, so the game WASM was never loaded — `bloom_glue.js` only warned. The FFI bridge was also wrong: it manually NaN-box-converted every argument, but Perry's runtime already wraps the `ffi` namespace (`wrapFfiForI64`), delivering plain JS values and re-encoding returns, so the manual conversion double-converted. Reuse Perry's self-contained HTML as the carrier of its own correct `rt` runtime + NaN-boxing, and splice the Bloom engine into it: - bloom_glue.js: rewritten as the engine bootstrap + FFI bridge following Perry's plain-value contract. Maps bloom_* straight through, routes string/asset params to the _str/_bytes variants, wires input/audio/HiDPI canvas, and drives the rAF game loop via Perry's global callWasmClosure (runGame short-circuits to bloom_run_game on web, platform === 7). - splice_game.py (new): injects the canvas + a synchronously-created window.__bloomReady promise + the deferred bootstrap module into Perry's HTML, and gates the game's bootPerryWasm() on that promise so the game boots only after the engine + FFI are live. Anchored to the boot block so it ignores decoy `.catch(` in the runtime. - index.html: now a thin engine-only page (no-game case). - build.sh: resolve the game path up-front (was tested from the wrong cwd after cd'ing into the web crate, silently skipping compilation), compile to a temp dir, splice into dist/web/index.html, clean up. - CLAUDE.md: updated file roles + the FFI value contract. Validated end-to-end against Perry v0.5.1206 + a full wasm-pack engine build of examples/pong: produces dist/web/index.html with Perry's runtime, the embedded game WASM, the gated boot, and the Bloom canvas. --- CLAUDE.md | 8 +- native/web/bloom_glue.js | 449 ++++++++++++++++++++++++-------------- native/web/build.sh | 38 +++- native/web/index.html | 387 ++------------------------------ native/web/splice_game.py | 85 ++++++++ 5 files changed, 425 insertions(+), 542 deletions(-) create mode 100644 native/web/splice_game.py diff --git a/CLAUDE.md b/CLAUDE.md index 49adfa2..6e58c21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Physics FFI (~110 of the ~230) is generated by the `define_physics_ffi!` macro i The web target uses a two-module WASM architecture: - **Perry WASM** (game logic) imports bloom_* functions under the `"ffi"` namespace - **bloom_web.wasm** (rendering engine) compiled from `native/web/` via wasm-pack -- **JS glue** (`index.html`) bridges both modules, handles DOM events, string conversion, asset fetching, and Web Audio +- **JS glue** (`bloom_glue.js`) bridges both modules, handles DOM events, asset fetching, Web Audio, and the rAF game loop. For game builds, `build.sh` runs `splice_game.py` to inject this bootstrap into Perry's self-contained WASM HTML (which carries the `rt` runtime + NaN-boxing) and gate the game's `bootPerryWasm()` on engine readiness. Perry's runtime already decodes FFI args to plain JS values (`wrapFfiForI64`), so the bridge passes plain values straight through — no manual NaN-boxing. Key features flags in `native/shared/Cargo.toml`: - `default = ["mp3", "jolt"]` — includes minimp3 (C dep, not WASM-compatible) + Jolt physics @@ -92,5 +92,7 @@ The web crate exposes `_str` variants (accepting `&str`) and `_bytes` variants ( | `native/third_party/bloom_jolt/` | C++ shim wrapping JoltPhysics behind a C ABI | | `native/web/src/lib.rs` | Web platform: all FFI functions via wasm-bindgen | | `native/web/jolt_bridge.js` | Web physics: JoltPhysics.js implementation of FFI | -| `native/web/index.html` | JS glue: FFI bridge, input, asset loading, Web Audio | -| `native/web/build.sh` | Build script: wasm-pack + wasm-opt + assembly | +| `native/web/bloom_glue.js` | Engine bootstrap + FFI bridge: loads bloom_web, builds `__ffiImports`, input, asset loading, Web Audio, rAF game loop | +| `native/web/index.html` | Engine-only standalone page (no game); creates `__bloomReady` + loads bloom_glue.js | +| `native/web/splice_game.py` | Splices the Bloom bootstrap into Perry's self-contained WASM HTML, gating `bootPerryWasm()` on `__bloomReady` | +| `native/web/build.sh` | Build script: wasm-pack + wasm-opt + Perry compile + splice/assembly | diff --git a/native/web/bloom_glue.js b/native/web/bloom_glue.js index e0c3b58..d4aa60c 100644 --- a/native/web/bloom_glue.js +++ b/native/web/bloom_glue.js @@ -1,218 +1,339 @@ /** - * Bloom Engine — Web Glue Layer + * Bloom Engine — Web Bootstrap & FFI Bridge * - * Orchestrates loading of both WASM modules: - * 1. bloom_web.wasm (Bloom rendering engine, compiled from Rust via wasm-pack) - * 2. game.wasm (Perry-compiled game logic) + * Loaded as a deferred ES module by both the standalone template (`index.html`, + * engine-only / no game) and the build-assembled page that `build.sh` produces + * by splicing this bootstrap into Perry's self-contained WASM HTML. * - * Bridges Perry's FFI calls to Bloom's wasm-bindgen exports, handles the - * requestAnimationFrame loop, and manages cross-module callback invocation. + * Responsibilities: + * 1. Load the Bloom rendering engine WASM (`pkg/bloom_web.js`, wasm-pack). + * 2. Initialise JoltPhysics.js (best-effort). + * 3. Publish `globalThis.__ffiImports` — the `bloom_*` functions the Perry + * game WASM imports under its `"ffi"` namespace. + * 4. Wire DOM input, HiDPI canvas sizing, Web Audio, and the rAF game loop. + * 5. Resolve `window.__bloomReady` so the gated `bootPerryWasm(...)` call in + * the page can instantiate the game WASM *after* the engine + FFI are live. * - * Usage: - * - * - * Or for Perry's self-contained HTML output, set window.__bloomWasmUrl - * before bootPerryWasm is called. + * --- The FFI value contract (important) --- + * Perry's WASM runtime (`wasm_runtime.js`, embedded in the page) wraps the + * entire `ffi` namespace with `wrapFfiForI64`: it DECODES each NaN-boxed i64 + * argument to a plain JS value (number / string / handle) before calling us, + * and RE-ENCODES our plain return value. So every function below receives and + * returns ordinary JS values — there is no manual NaN-boxing to do here. A + * `bloom_draw_text(text, ...)` import arrives with `text` already a JS string. */ -// State -let bloomModule = null; // wasm-bindgen exports from bloom_web -let perryInstance = null; // Perry WASM instance -let perryMemory = null; // Perry WASM memory -let rafId = null; // requestAnimationFrame handle -let gameRunning = false; +import init, * as bloom from './pkg/bloom_web.js'; -// Game loop state -let gameCallbackHandle = null; // Perry closure handle for runGame callback -let perryClosureCall1 = null; // Reference to Perry's closure_call_1 function +let bloomModule = null; +let booted = false; + +// --- Game loop state --- +let gameCallback = null; // Perry closure handle captured from bloom_run_game +let gameRunning = false; +let rafId = null; /** - * Boot a Bloom game from two WASM modules. - * - * @param {string} bloomPkgUrl - URL to bloom_web.js (wasm-pack output) - * @param {string} gameWasmUrl - URL to the Perry-compiled game.wasm + * Idempotent engine bootstrap. Safe to call multiple times; only the first + * call performs work. Returns the resolved Bloom wasm-bindgen module. */ -export async function bootBloomGame(bloomPkgUrl, gameWasmUrl) { - // 1. Load Bloom engine WASM - bloomModule = await import(bloomPkgUrl); - await bloomModule.default(); // Initialize wasm-bindgen - - // 2. Build FFI import object mapping bloom_* names to wasm-bindgen exports. - // Perry WASM imports these under the "ffi" namespace. - const ffiImports = buildFfiImports(); - - // 3. Fetch and instantiate Perry game WASM. - // Perry's runtime JS (wasm_runtime.js) is embedded in the HTML. - // We provide our FFI imports to it. +export async function bootBloomGame() { + if (booted) return bloomModule; + booted = true; + + // 1. Load Bloom engine WASM. + await init(); + bloomModule = bloom; + + // 2. Initialise Jolt physics (WASM build of Jolt). The jolt_bridge.js module + // wasm-bindgen bundled into pkg/snippets/ owns all physics state; we reach + // it via the Rust-exported helper so bloom_physics_* talk to one instance. + // Override globalThis.__joltFactory to self-host instead of the CDN. + try { + const factory = globalThis.__joltFactory + ?? (await import('https://cdn.jsdelivr.net/npm/jolt-physics@1.0.0/+esm')).default; + await bloom.bloom_physics_init_jolt(factory); + console.log('[bloom] Jolt physics ready'); + } catch (e) { + console.warn('[bloom] Jolt init failed:', e, '- bloom_physics_* calls will be no-ops'); + } + + // 3. Publish the FFI surface for the Perry game WASM. + const ffi = buildFfiImports(); if (typeof globalThis.__ffiImports === 'undefined') { - globalThis.__ffiImports = ffiImports; + globalThis.__ffiImports = ffi; } else { - Object.assign(globalThis.__ffiImports, ffiImports); + Object.assign(globalThis.__ffiImports, ffi); } - // If Perry's bootPerryWasm is available (self-contained HTML mode), - // it will use __ffiImports. Otherwise, load game WASM directly. - if (gameWasmUrl && typeof globalThis.bootPerryWasm === 'undefined') { - console.warn('bloom_glue: Perry runtime not found. Load game via Perry HTML output.'); - } + // 4. DOM wiring (input + HiDPI canvas sizing). + setupDomBridge(); + + // 5. Hide the loading indicator, if present. + const loading = document.getElementById('loading'); + if (loading) loading.style.display = 'none'; + + console.log('[bloom] engine + FFI ready'); + return bloomModule; } /** - * Build the FFI imports object from Bloom wasm-bindgen exports. - * All bloom_* functions are mapped directly, plus bloom_run_game - * is intercepted to set up the rAF loop. + * Build the `bloom_*` FFI import object. Defaults pass straight through to the + * matching wasm-bindgen export (correct for all numeric-in/out and string-out + * functions); the overrides below handle string-in, file loading, the host + * surfaces (window/title/audio/fullscreen/cursor/storage), and the game loop. */ function buildFfiImports() { const imports = {}; - // Map all bloom_* exports - for (const [name, fn] of Object.entries(bloomModule)) { + // Default: every bloom_* export, passed plain values by Perry's wrapFfiForI64. + for (const [name, fn] of Object.entries(bloom)) { if (typeof fn === 'function' && name.startsWith('bloom_')) { imports[name] = fn; } } - // Intercept bloom_run_game to set up the rAF loop - imports['bloom_run_game'] = (callbackHandle) => { - gameCallbackHandle = callbackHandle; - gameRunning = true; - startRafLoop(); - }; + // --- Text: string params route to the _str variants --- + imports.bloom_draw_text = (text, x, y, size, r, g, b, a) => + bloom.bloom_draw_text_str(String(text), x, y, size, r, g, b, a); + imports.bloom_draw_text_ex = (font, text, x, y, size, spacing, r, g, b, a) => + bloom.bloom_draw_text_ex_str(font, String(text), x, y, size, spacing, r, g, b, a); + imports.bloom_measure_text = (text, size) => + bloom.bloom_measure_text_str(String(text), size); + imports.bloom_measure_text_ex = (font, text, size, spacing) => + bloom.bloom_measure_text_ex_str(font, String(text), size, spacing); - // Intercept bloom_window_should_close to return 1.0 on web - // so that while(!windowShouldClose()) loops exit immediately - // when runGame is being used (the rAF loop takes over) - const origShouldClose = imports['bloom_window_should_close']; - imports['bloom_window_should_close'] = () => { - // Once runGame is called, signal the while loop to exit - if (gameRunning) return f64ToI64(1.0); - return origShouldClose ? origShouldClose() : f64ToI64(0.0); + // --- Materials & post-FX: shader source strings route to _str variants --- + const materialVariants = [ + 'bloom_compile_material', 'bloom_compile_material_refractive', + 'bloom_compile_material_transparent', 'bloom_compile_material_additive', + 'bloom_compile_material_cutout', 'bloom_compile_material_instanced', + 'bloom_add_post_pass', 'bloom_set_post_pass', + ]; + for (const name of materialVariants) { + const strFn = bloom[name + '_str']; + if (strFn) imports[name] = (source) => strFn(String(source)); + } + // compile_material_from_file: no web filesystem — fetch the source, compile it. + imports.bloom_compile_material_from_file = (path, _bucketKind) => { + const src = syncFetchText(String(path)); + return src != null ? bloom.bloom_compile_material_str(src) : 0; }; - return imports; -} - -/** - * Start the requestAnimationFrame game loop. - * Each frame: begin_drawing → invoke game callback → end_drawing. - */ -function startRafLoop() { - function frame() { - if (!gameRunning) return; + // --- Asset loading: fetch the file synchronously, hand bytes to _bytes --- + const byteLoaders = { + bloom_load_texture: 'bloom_load_texture_bytes', + bloom_load_font: 'bloom_load_font_bytes', + bloom_load_sound: 'bloom_load_sound_bytes', + bloom_load_music: 'bloom_load_music_bytes', + bloom_load_model: 'bloom_load_model_bytes', + bloom_load_model_animation: 'bloom_load_model_animation_bytes', + bloom_load_image: 'bloom_load_image_bytes', + }; + for (const [name, bytesName] of Object.entries(byteLoaders)) { + const bytesFn = bloom[bytesName]; + if (!bytesFn) continue; + imports[name] = (path) => { + const data = syncFetchBytes(String(path)); + return data ? bytesFn(data) : 0; + }; + } - bloomModule.bloom_begin_drawing(); + // --- Window / title --- + imports.bloom_init_window = (w, h, title, fullscreen) => { + document.title = String(title) || 'Bloom Engine'; + // Web export ignores the title slot (_title: f64); pass 0 for it. + bloom.bloom_init_window(w, h, 0, fullscreen); + }; + imports.bloom_set_window_title = (title) => { document.title = String(title); }; - // Invoke the game's callback with delta time - if (gameCallbackHandle !== null) { - const dt = bloomModule.bloom_get_delta_time(); - invokePerryCallback(gameCallbackHandle, dt); + // --- Web Audio --- + let audioContext = null; + let audioProcessor = null; + imports.bloom_init_audio = () => { + try { + audioContext = new AudioContext({ sampleRate: 44100 }); + const bufSize = 4096; + audioProcessor = audioContext.createScriptProcessor(bufSize, 0, 2); + audioProcessor.onaudioprocess = (e) => { + const left = e.outputBuffer.getChannelData(0); + const right = e.outputBuffer.getChannelData(1); + const interleaved = new Float32Array(left.length * 2); + bloom.bloom_audio_mix(interleaved); + for (let i = 0; i < left.length; i++) { + left[i] = interleaved[i * 2]; + right[i] = interleaved[i * 2 + 1]; + } + }; + audioProcessor.connect(audioContext.destination); + } catch (e) { + console.warn('[bloom] Web Audio init failed:', e); } + }; + imports.bloom_close_audio = () => { + if (audioProcessor) { audioProcessor.disconnect(); audioProcessor = null; } + if (audioContext) { audioContext.close(); audioContext = null; } + }; - bloomModule.bloom_end_drawing(); + // --- Fullscreen / cursor --- + imports.bloom_toggle_fullscreen = () => { + const canvas = document.getElementById('bloom-canvas'); + if (!document.fullscreenElement) canvas?.requestFullscreen?.().catch(() => {}); + else document.exitFullscreen().catch(() => {}); + }; + imports.bloom_disable_cursor = () => { + document.getElementById('bloom-canvas')?.requestPointerLock?.(); + bloom.bloom_disable_cursor(); + }; + imports.bloom_enable_cursor = () => { + document.exitPointerLock?.(); + bloom.bloom_enable_cursor(); + }; - rafId = requestAnimationFrame(frame); - } + // --- File I/O via localStorage (no real filesystem on web) --- + const LS_PREFIX = 'bloom_fs:'; + imports.bloom_write_file = (path, data) => { + try { localStorage.setItem(LS_PREFIX + String(path), String(data)); return 1; } + catch { return 0; } + }; + imports.bloom_file_exists = (path) => + localStorage.getItem(LS_PREFIX + String(path)) !== null ? 1 : 0; + imports.bloom_read_file = (path) => { + const v = localStorage.getItem(LS_PREFIX + String(path)); + return v === null ? '' : v; // plain string; Perry re-encodes via wrapFfiForI64 + }; - rafId = requestAnimationFrame(frame); + // --- Game loop --- + // runGame() on web (bloom_get_platform() === 7) just hands its update closure + // to bloom_run_game and returns; the blocking native loop is never entered. + // We capture the closure and drive it from requestAnimationFrame. + imports.bloom_run_game = (callback) => { + gameCallback = callback; + if (!gameRunning) { + gameRunning = true; + startRafLoop(); + } + }; + + return imports; } /** - * Invoke a Perry closure/function handle. + * Drive the captured Perry game closure once per animation frame: + * begin_drawing → callback(dt) → end_drawing. * - * Perry closures are stored in the JS handle store and invoked via - * the indirect function table. We use the mem_dispatch closure_call_1 - * function from Perry's runtime. + * The closure is invoked through Perry's `callWasmClosure`, a global helper its + * runtime exposes that resolves the closure's function-table index + captures + * against the live game WASM instance. By the time the first frame runs, the + * runtime classic + diff --git a/native/web/splice_game.py b/native/web/splice_game.py new file mode 100644 index 0000000..1b4e60d --- /dev/null +++ b/native/web/splice_game.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Splice the Bloom engine bootstrap into Perry's self-contained WASM HTML. + +Perry emits a complete HTML page: a classic + +''' + + +def splice(html: str) -> str: + if PERRY_ROOT not in html: + raise SystemExit( + 'splice_game.py: could not find perry-root div in Perry HTML — ' + 'output format may have changed; aborting so the build fails loudly.' + ) + if BOOT_MARKER not in html: + raise SystemExit( + 'splice_game.py: could not find the bootPerryWasm boot block — ' + 'output format may have changed; aborting.' + ) + + # 1. Inject the Bloom shell right after Perry's root div. + html = html.replace(PERRY_ROOT, PERRY_ROOT + BLOOM_SHELL, 1) + + # 2. Gate the boot call. Operate only on the tail beginning at the boot + # marker so we never touch a coincidental `bootPerryWasm("` / `").catch(` + # inside the large runtime script (the definition reads + # `bootPerryWasm(wasmBase64`, not `bootPerryWasm("`). + idx = html.rindex(BOOT_MARKER) + head, tail = html[:idx], html[idx:] + if BOOT_CALL not in tail or BOOT_CATCH not in tail: + raise SystemExit('splice_game.py: unexpected boot block shape; aborting.') + tail = tail.replace(BOOT_CALL, 'window.__bloomReady.then(() => bootPerryWasm("', 1) + tail = tail.replace(BOOT_CATCH, '")).catch(', 1) + return head + tail + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit('Usage: splice_game.py ') + with open(sys.argv[1], 'r', encoding='utf-8') as f: + html = f.read() + with open(sys.argv[2], 'w', encoding='utf-8') as f: + f.write(splice(html)) + + +if __name__ == '__main__': + main()