From 1c147aedff4176b45562842e75a6cc6771c269d7 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 06:34:09 +0200 Subject: [PATCH 1/2] Require real Perry game startup and frame capture in browser CI --- .github/workflows/test.yml | 25 ++- docs/evidence/compiled-web-startup-v1.md | 36 +++++ docs/windows-engine-plan.md | 3 + scripts/ci-check.sh | 6 + tools/ci/compile_web_game.py | 89 +++++++++++ tools/ci/compiled_web_smoke.py | 188 +++++++++++++++++++++++ tools/ci/fixtures/compiled-web.ts | 29 ++++ tools/ci/test_compiled_web_smoke.py | 23 +++ 8 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/compiled-web-startup-v1.md create mode 100644 tools/ci/compile_web_game.py create mode 100644 tools/ci/compiled_web_smoke.py create mode 100644 tools/ci/fixtures/compiled-web.ts create mode 100644 tools/ci/test_compiled_web_smoke.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86baf11..3b75130 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -297,6 +297,19 @@ jobs: CARGO_BUILD_JOBS: '2' run: python tools/ci/setup_windows_perry.py --out "$env:RUNNER_TEMP/bloom-perry" --github-env + - name: Compile real browser startup fixture + shell: pwsh + run: python tools/ci/compile_web_game.py + + - name: Retain compiled browser fixture + uses: actions/upload-artifact@v4 + with: + name: bloom-compiled-web-game-${{ github.run_id }} + path: target/ci/compiled-web-game + overwrite: true + if-no-files-found: error + retention-days: 1 + - name: Windows / installed native startup shell: pwsh env: @@ -461,17 +474,27 @@ jobs: # image, so validate the exact Linux-built package in macOS Chrome, which has # a working hosted WebGPU presentation path. browser-smoke: - needs: build-web + needs: [build-web, build-windows] runs-on: macos-14 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Download qualified web package uses: actions/download-artifact@v4 with: name: bloom-web-package-${{ github.run_id }} path: native/web/pkg + - name: Download compiled browser fixture + uses: actions/download-artifact@v4 + with: + name: bloom-compiled-web-game-${{ github.run_id }} + path: target/ci/compiled-web-game + - name: web / browser-smoke run: ./scripts/ci-check.sh --web --component browser-smoke diff --git a/docs/evidence/compiled-web-startup-v1.md b/docs/evidence/compiled-web-startup-v1.md new file mode 100644 index 0000000..9f202a1 --- /dev/null +++ b/docs/evidence/compiled-web-startup-v1.md @@ -0,0 +1,36 @@ +# Compiled-game browser startup acceptance + +The existing browser gate calls the engine WASM directly from JavaScript. It +qualifies rendering and temporal reconstruction but cannot catch a trap in +Perry's game entry, FFI calls or closure dispatch as reported in #74. + +The new gate compiles a real TypeScript game and an intentional startup-failure +control with the pinned Perry 0.5.1220 toolchain already prepared by Windows CI. +Both pages use the production splicer and bootstrap. Their compiler, entry, +embedded game WASM and assembled HTML hashes are retained. The browser job uses +the Linux-built engine package and the compiled pages at the same source commit +in hosted macOS Chrome, which has a working WebGPU presentation path. + +The game initializes the window, selects direct-2D mode, draws a white square +on black for eight frames, requests a stop and records exactly one cleanup. +Acceptance requires the actual game progress and cleanup markers, no script +errors, and all 16,384 expected pixels in a 128x128 browser screenshot. The +monitor observes errors and adapter identity; it does not draw the test image +or call the game's update itself. + +The failure control records entry into its intentional fault and then throws. +It must produce a script error without successful frame or cleanup markers. +An unrelated infrastructure failure cannot satisfy that control. Unit controls +also reject missing progress, duplicate cleanup and an error-free fault marker. + +The browser job now waits for both the engine build and the Windows fixture +compiler. This reuses the already qualified compiler instead of introducing a +second platform compiler/runtime download. Existing JavaScript-driven renderer +and temporal checks remain required and unchanged. + +Both actual pages compile and splice locally, and acceptance-state controls +pass. Local browser execution is unavailable in this session; hosted execution +is the required rendering proof. No compiled-game browser result is claimed +until that check passes. Browser physics, the complete example runtime matrix, +visible native presentation, fixed-update lifecycle, one-command creation and +named hardware qualification remain separate. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index f9fcc8f..a507d6f 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -107,6 +107,9 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json edge-triggered pause input. [Native cleanup and Pong replay evidence](evidence/windows-game-cleanup-v1.md) also verifies the corrected palette names and all 20 example links. The same Pong source completes the real web build; its browser frame remains unproven. + A [compiled-game browser gate](evidence/compiled-web-startup-v1.md) now prepares + real Perry startup and failure-control pages for hosted Chrome acceptance, + including exact pixels and one cleanup. Its hosted result is still required. Browser starter rendering, visible native presentation, shared lifecycle, general long-path support remain open. diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index def0a66..340cc48 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -283,6 +283,9 @@ run_component() { tools/ci/test_web_smoke.py \ tools/ci/compile_examples.py \ tools/ci/setup_windows_perry.py \ + tools/ci/compile_web_game.py \ + tools/ci/compiled_web_smoke.py \ + tools/ci/test_compiled_web_smoke.py \ tools/ci/test_compile_examples.py "$python_cmd" -m unittest \ tools/quality/test_run.py \ @@ -295,6 +298,7 @@ run_component() { tools/quality/test_vsm_motion_corpus.py \ tools/ci/test_web_smoke.py \ tools/ci/test_compile_examples.py \ + tools/ci/test_compiled_web_smoke.py \ -v hr "visual metric and fault-engine tests" cargo test --release --manifest-path tools/bloom-diff/Cargo.toml @@ -330,6 +334,8 @@ run_component() { browser-smoke) hr "Bloom WebGPU real-browser known-frame smoke" "$python_cmd" tools/ci/web_smoke.py + hr "Perry compiled-game startup, frame, cleanup and failure control" + "$python_cmd" tools/ci/compiled_web_smoke.py ;; target-check) cross_crate="${BLOOM_CROSS_CRATE:-}" diff --git a/tools/ci/compile_web_game.py b/tools/ci/compile_web_game.py new file mode 100644 index 0000000..da2fe6e --- /dev/null +++ b/tools/ci/compile_web_game.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Compile actual Perry startup and failure-control pages for browser acceptance.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[2] +VERSION = "perry 0.5.1220" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", default=os.environ.get("BLOOM_PERRY") or shutil.which("perry")) + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/compiled-web-game") + args = parser.parse_args() + if not args.perry: + parser.error("Perry 0.5.1220 is required; prepare the pinned example toolchain") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + entries = ROOT / "target/ci/compiled-web-entries" + entries.mkdir(parents=True, exist_ok=True) + fixture = (ROOT / "tools/ci/fixtures/compiled-web.ts").read_text(encoding="utf-8") + marker = "const BLOOM_SMOKE_FAIL_STARTUP = false;" + assert fixture.count(marker) == 1 + report = {"schema": "bloom-compiled-web-game-v1", "status": "running", "commands": [], "pages": [], + "source_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()} + + def save(): + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + def run(name, command): + start = time.monotonic() + with (out / f"{name}.log").open("wb") as log: + result = subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=120) + report["commands"].append({"name": name, "command": command, "exit_code": result.returncode, + "duration_seconds": round(time.monotonic() - start, 3)}) + save() + if result.returncode: + raise RuntimeError(f"{name} exited {result.returncode}; see {out / (name + '.log')}") + + save() + try: + run("perry-version", [args.perry, "--version"]) + report["compiler_version"] = (out / "perry-version.log").read_text(encoding="utf-8").strip() + if report["compiler_version"] != VERSION: + raise RuntimeError(f"expected {VERSION}; found {report['compiler_version']}") + report["compiler_sha256"] = hashlib.sha256(Path(args.perry).read_bytes()).hexdigest() + for name, fails in [("game", False), ("trap-control", True)]: + source = fixture.replace(marker, "const BLOOM_SMOKE_FAIL_STARTUP = true;") if fails else fixture + entry = entries / f"{name}.ts" + entry.write_text(source, encoding="utf-8", newline="\n") + (out / f"{name}.ts").write_bytes(entry.read_bytes()) + raw, page = out / f"{name}.perry.html", out / f"{name}.html" + raw.unlink(missing_ok=True) + page.unlink(missing_ok=True) + run(name + "-compile", [args.perry, "compile", str(entry), "--target", "wasm", "-o", str(raw)]) + html = raw.read_text(encoding="utf-8") + match = re.search(r'window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"', html) + if match is None: + raise RuntimeError(f"{name}: Perry output has no embedded game WASM") + wasm = base64.b64decode(match[1], validate=True) + if not wasm.startswith(b"\0asm"): + raise RuntimeError(f"{name}: invalid game WASM header") + run(name + "-splice", ["node", str(ROOT / "native/web/splice_game.cjs"), str(raw), str(page)]) + report["pages"].append({"name": name, "entry_sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), + "game_wasm_sha256": hashlib.sha256(wasm).hexdigest(), "game_wasm_bytes": len(wasm), + "html_sha256": hashlib.sha256(page.read_bytes()).hexdigest(), + "expected_startup_failure": fails}) + report["status"] = "pass" + print("PASS: compiled and spliced actual Perry startup and failure-control pages") + return 0 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: + report.update(status="fail", error=str(exc)) + print(f"FAIL: {exc}") + return 1 + finally: + save() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/compiled_web_smoke.py b/tools/ci/compiled_web_smoke.py new file mode 100644 index 0000000..bad065c --- /dev/null +++ b/tools/ci/compiled_web_smoke.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""CI acceptance for a real Perry-compiled game, its frame, cleanup and trap control.""" + +import argparse +import base64 +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import check_frame +from tools.ci.web_smoke import QuietHandler, DevTools, browser_path, devtools_target, free_local_port + +MONITOR = """ +(() => { +globalThis.__compiledGameErrors = []; +const describe = (value) => String(value?.stack || value).slice(0, 16384); +const record = (value) => { if (__compiledGameErrors.length < 32) __compiledGameErrors.push(describe(value)); }; +const originalError = console.error; +console.error = (...args) => { record(args.map(describe).join(' ')); originalError.apply(console, args); }; +addEventListener('error', (event) => record(event.error || event.message || 'script load error')); +addEventListener('unhandledrejection', (event) => record(event.reason)); +globalThis.__joltFactory = async () => { throw new Error('physics omitted in compiled render smoke'); }; +if (globalThis.GPU) { + const request = GPU.prototype.requestAdapter; + GPU.prototype.requestAdapter = async function(...args) { + const adapter = await request.apply(this, args); + globalThis.__compiledGameAdapter = adapter; + return adapter; + }; +} +})(); +""" + + +def evaluate(devtools, expression): + result = devtools.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": True}) + if result.get("exceptionDetails"): + raise RuntimeError(f"browser evaluation failed: {result['exceptionDetails']}") + return result.get("result", {}).get("value") + + +def validate_state(name, state): + if name == "game": + if state["errors"] or state["frames"] != "8" or state["cleanups"] != "1": + raise RuntimeError(f"compiled game failed startup/frame/cleanup acceptance: {state}") + elif state["expectedFault"] != "BLOOM_EXPECTED_STARTUP_FAILURE" or not state["errors"] or state["frames"] is not None or state["cleanups"] is not None: + raise RuntimeError(f"intentional compiled startup failure was not rejected: {state}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--browser") + parser.add_argument("--game", type=Path, default=ROOT / "target/ci/compiled-web-game") + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/web-smoke/compiled-game") + parser.add_argument("--timeout", type=float, default=90) + args = parser.parse_args() + if args.timeout <= 0: + parser.error("timeout must be positive") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + (out / "compiled-game.png").unlink(missing_ok=True) + report = {"schema": "bloom-compiled-web-smoke-v1", "status": "running", "cases": [], "failures": []} + def save(): + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + save() + process = devtools = server = thread = None + temp_parent = Path(tempfile.gettempdir()).resolve() + temporary = Path(tempfile.mkdtemp(prefix="bloom-compiled-web-", dir=temp_parent)).resolve() + try: + compiler_report = json.loads((args.game / "result.json").read_text(encoding="utf-8")) + if compiler_report.get("status") != "pass" or compiler_report.get("compiler_version") != "perry 0.5.1220": + raise RuntimeError("compiled-game artifact did not pass with the required Perry version") + current_source = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + if compiler_report.get("source_commit") != current_source: + raise RuntimeError("compiled game and browser engine checkout have different source commits") + report["compiled_game"] = compiler_report + engine_package = ROOT / "native/web/pkg" + engine_wasm = engine_package / "bloom_web_bg.wasm" + report["engine_wasm_sha256"] = hashlib.sha256(engine_wasm.read_bytes()).hexdigest() + site = temporary / "site" + site.mkdir() + shutil.copytree(engine_package, site / "pkg") + for name in ("bloom_glue.js", "game_loop.mjs", "jolt_bridge.js"): + shutil.copyfile(ROOT / "native/web" / name, site / name) + for name in ("game", "trap-control"): + page = args.game / f"{name}.html" + identity = next(p for p in compiler_report["pages"] if p["name"] == name) + if hashlib.sha256(page.read_bytes()).hexdigest() != identity["html_sha256"]: + raise RuntimeError(f"{name}: compiled page hash differs from its compiler receipt") + shutil.copyfile(page, site / page.name) + browser = browser_path(args.browser) + if browser is None: + raise RuntimeError("Chrome/Chromium is required for compiled-game acceptance") + handler = lambda *a, **kw: QuietHandler(*a, directory=str(site), **kw) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + debug_port = free_local_port() + command = [browser, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", + "--disable-gpu-sandbox", "--enable-unsafe-webgpu", "--ignore-gpu-blocklist", + "--remote-allow-origins=*", f"--remote-debugging-port={debug_port}", + "--window-size=320,240", f"--user-data-dir={temporary / 'profile'}", "about:blank"] + if sys.platform.startswith("linux"): + command[1:1] = ["--use-webgpu-adapter=swiftshader", "--use-gpu-in-tests", "--enable-accelerated-2d-canvas"] + report["browser_command"] = command + with (out / "browser.stdout.log").open("wb") as stdout, (out / "browser.stderr.log").open("wb") as stderr: + process = subprocess.Popen(command, stdout=stdout, stderr=stderr) + target = devtools_target(debug_port, "about:blank", time.monotonic() + args.timeout) + if target is None: + raise RuntimeError("browser did not expose the owned test page") + devtools = DevTools(target) + devtools.call("Page.enable") + devtools.call("Runtime.enable") + devtools.call("Emulation.setDeviceMetricsOverride", {"width": 128, "height": 128, "deviceScaleFactor": 1, "mobile": False}) + devtools.call("Page.addScriptToEvaluateOnNewDocument", {"source": MONITOR}) + for name in ("game", "trap-control"): + if name != "game": + evaluate(devtools, "localStorage.clear()") + url = f"http://127.0.0.1:{server.server_port}/{name}.html" + devtools.call("Page.navigate", {"url": url}) + started = time.monotonic() + state = None + while time.monotonic() - started < args.timeout: + state = evaluate(devtools, "(() => { if (location.href !== " + json.dumps(url) + + " || !Array.isArray(globalThis.__compiledGameErrors)) return null; return ({" + """ + frames: localStorage.getItem('bloom_fs:compiled-web-frames'), + cleanups: localStorage.getItem('bloom_fs:compiled-web-cleanups'), + expectedFault: localStorage.getItem('bloom_fs:compiled-web-expected-fault'), + errors: globalThis.__compiledGameErrors || [], + }); })()""") + if isinstance(state, dict) and (state["errors"] or state["cleanups"] is not None): + break + time.sleep(0.1) + if not isinstance(state, dict): + raise RuntimeError(f"{name}: no game state before timeout") + case = {"name": name, "state": state, "duration_seconds": round(time.monotonic() - started, 3)} + report["cases"].append(case) + save() + validate_state(name, state) + if name == "game": + evaluate(devtools, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))") + capture = devtools.call("Page.captureScreenshot", {"format": "png", "fromSurface": True}) + frame = out / "compiled-game.png" + frame.write_bytes(base64.b64decode(capture["data"])) + case["frame"] = check_frame(frame) + case["adapter"] = evaluate(devtools, "(() => { const i = globalThis.__compiledGameAdapter?.info; return i ? { vendor:i.vendor, architecture:i.architecture, device:i.device, description:i.description } : null; })()") + case["status"] = "pass" + save() + report["status"] = "pass" + print("PASS: actual Perry game rendered its exact frame and cleaned up; startup trap control rejected") + return 0 + except (OSError, ValueError, RuntimeError, KeyError, TypeError, StopIteration, subprocess.SubprocessError) as exc: + report["status"] = "fail" + report["failures"].append(str(exc)) + print(f"FAIL: {exc}") + return 1 + finally: + if devtools is not None: + devtools.close() + if process is not None: + process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + save() + if temporary.parent != temp_parent or temporary.is_symlink() or temporary.is_junction(): + raise RuntimeError(f"refusing cleanup outside owned temporary directory: {temporary}") + shutil.rmtree(temporary) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/fixtures/compiled-web.ts b/tools/ci/fixtures/compiled-web.ts new file mode 100644 index 0000000..9ce9ed4 --- /dev/null +++ b/tools/ci/fixtures/compiled-web.ts @@ -0,0 +1,29 @@ +import { + initWindow, runGame, clearBackground, closeWindow, setDirect2DMode, + setTargetFPS, writeFile, +} from "@bloomengine/engine/core"; +import { drawRect } from "@bloomengine/engine/shapes"; + +const BLOOM_SMOKE_FAIL_STARTUP = false; +if (BLOOM_SMOKE_FAIL_STARTUP) { + writeFile("compiled-web-expected-fault", "BLOOM_EXPECTED_STARTUP_FAILURE"); + throw new Error("BLOOM_EXPECTED_STARTUP_FAILURE"); +} + +initWindow(128, 128, "Bloom compiled web startup"); +setTargetFPS(60); +setDirect2DMode(true); +let frames = 0; +let cleanups = 0; +runGame((_dt) => { + clearBackground({ r: 0, g: 0, b: 0, a: 255 }); + drawRect(32, 32, 64, 64, { r: 255, g: 255, b: 255, a: 255 }); + frames = frames + 1; + if (frames === 8) { + writeFile("compiled-web-frames", frames.toString()); + closeWindow(); + } +}, () => { + cleanups = cleanups + 1; + writeFile("compiled-web-cleanups", cleanups.toString()); +}); diff --git a/tools/ci/test_compiled_web_smoke.py b/tools/ci/test_compiled_web_smoke.py new file mode 100644 index 0000000..6baa15b --- /dev/null +++ b/tools/ci/test_compiled_web_smoke.py @@ -0,0 +1,23 @@ +import unittest + +from tools.ci.compiled_web_smoke import validate_state + + +class CompiledGameAcceptanceTests(unittest.TestCase): + def test_success_requires_render_progress_and_exactly_one_cleanup(self): + state = {"frames": "8", "cleanups": "1", "expectedFault": None, "errors": []} + validate_state("game", state) + for change in ({"frames": None}, {"cleanups": None}, {"cleanups": "2"}, {"errors": ["WASM Error: unreachable"]}): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_state("game", {**state, **change}) + + def test_failure_control_requires_entering_its_fault_and_an_actual_error(self): + state = {"frames": None, "cleanups": None, "expectedFault": "BLOOM_EXPECTED_STARTUP_FAILURE", "errors": ["WASM Error: unreachable"]} + validate_state("trap-control", state) + for change in ({"expectedFault": None}, {"errors": []}, {"frames": "8"}, {"cleanups": "1"}): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_state("trap-control", {**state, **change}) + + +if __name__ == "__main__": + unittest.main() From 9e6f4ab82f50db97d66385adf4c67f4e08fdae07 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 07:16:49 +0200 Subject: [PATCH 2/2] Validate compiled game imports and retain actual startup fault controls --- .github/workflows/test.yml | 3 + docs/evidence/compiled-web-startup-v1.md | 38 +++++++- docs/windows-engine-plan.md | 114 ++++++++--------------- tools/ci/compile_web_game.py | 31 ++++++ tools/ci/compiled_web_smoke.py | 40 +++++++- tools/ci/fixtures/compiled-web.ts | 4 +- tools/ci/test_compiled_web_smoke.py | 15 ++- 7 files changed, 164 insertions(+), 81 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b75130..9db07f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,9 @@ jobs: # physical DX12/DXC qualification remains a separate requirement. "WGPU_BACKEND=dx12" >> $env:GITHUB_ENV "WGPU_DX12_COMPILER=fxc" >> $env:GITHUB_ENV + # A later FXC run also crashed under concurrent unit tests. Keep + # every test and assertion, but serialize this hosted Windows lane. + "RUST_TEST_THREADS=1" >> $env:GITHUB_ENV - name: quick / shared-tests # Retain adapter identities and capability skips even when Rust reports diff --git a/docs/evidence/compiled-web-startup-v1.md b/docs/evidence/compiled-web-startup-v1.md index 9f202a1..8bdf5cd 100644 --- a/docs/evidence/compiled-web-startup-v1.md +++ b/docs/evidence/compiled-web-startup-v1.md @@ -18,8 +18,9 @@ errors, and all 16,384 expected pixels in a 128x128 browser screenshot. The monitor observes errors and adapter identity; it does not draw the test image or call the game's update itself. -The failure control records entry into its intentional fault and then throws. -It must produce a script error without successful frame or cleanup markers. +The failure control records entry through a real compiled `writeFile` call. +The monitor then injects a `WebAssembly.RuntimeError` at that FFI boundary. +It must produce that specific script error without successful frame or cleanup markers. An unrelated infrastructure failure cannot satisfy that control. Unit controls also reject missing progress, duplicate cleanup and an error-free fault marker. @@ -34,3 +35,36 @@ is the required rendering proof. No compiled-game browser result is claimed until that check passes. Browser physics, the complete example runtime matrix, visible native presentation, fixed-update lifecycle, one-command creation and named hardware qualification remain separate. + +The initial hosted Windows shared-test job at `1c147ae` exits with an access +violation despite the explicit FXC setting. That process failure is retained; +the earlier compiler workaround does not eliminate the instability. The next +CI attempt serializes the Windows Rust test harness, retaining all tests and +assertions. A local serial DX12/FXC library run at the same source passes 489 +tests with one existing ignored test in 264.59 test seconds (333.515 seconds +including compilation). Selectable helpers use WARP; other helpers can use the +Radeon adapter. This local result does not prove every test on WARP or identify +the hosted crash's cause. The hosted serial result is still required. + +## Initial browser failure and corrected compilation + +The first hosted browser attempt passes the existing JavaScript renderer check +but times out after 90 seconds with no compiled-game frame or cleanup marker. +Its compiler had warned that both engine imports were unresolved, yet exited +zero and emitted a 10,870-byte WASM module. The original local compilation had +the same warning. Those outputs are invalid game qualification, despite their +valid WASM headers. The new preparer creates a project with an explicit local +engine dependency, verifies it resolves to the exact checkout, rejects unresolved +import warnings and requires the intended engine FFI imports in the actual WASM. + +The corrected local fixture compiles six modules and includes 159 engine imports. +A Node VM probe executes the generated Perry runtime and game WASM against a +recording FFI, verifying eight callbacks, one cleanup and the fault control. +It does not load Bloom's renderer or replace hosted browser acceptance. + +That probe also found Perry 0.5.1220's plain TypeScript throw only sets internal +exception state in this fixture and continues into game initialization. The +control now explicitly injects a runtime error at the compiled FFI call, which +the actual Perry boot promise rejects. General Perry exception propagation +remains a separate limitation; this change does not claim to fix it. Failure +diagnostics now include startup logs, DOM status, FFI count and a screenshot. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index a507d6f..e6d6edb 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -17,9 +17,9 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at | #127 Vulkan PT correctness | Three deterministic progressive and motion runs, both negative controls, finite intermediates, reset/lighting/rigid-motion checks, retained report | Canonical hardware gate, all four focused temporal tests, and CPU reference sanity check pass on Radeon/Vulkan; [report](evidence/issue-127-windows-vulkan-v1.md) and [raw evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-155-windows-vulkan-20260910) published | | #128 Windows image discrepancies | Identify the first incorrect stage or document a reviewed backend-specific baseline decision; rerun the full strict corpus and reproducibility checks | Cutout and surface corrections pass all nine Radeon images. At #159 source `d610d6a`, full runs 2 and 3 pass every configured check and reproduce 257 artifacts byte-identically with matching metadata and timing differences inside existing noise bounds. Earlier invalid runs retain their failures; named hardware acceptance remains separate | | #135 / #149 temporal reconstruction | Enforced motion/producer/quality-preset corpus, representative scenes, fractional/native and frozen A/B timing, memory/resize checks, platform evidence | Device/resource, stationary SSGI, and profiler fixes are retained. The surface correction passes original HD startup limits and 154,720 analytic receiver checks on Vulkan, DX12, and hosted Metal; 93 local goldens pass, including lighting recovery. The full Radeon corpus passes twice. Wider representative scenes, frozen A/B performance, memory/resize, and platform acceptance remain open | -| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Release startup/install acceptance remains open | +| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Fresh installed headless scene/direct-2D rendering and cleanup pass through #169. Visible presentation and release packaging remain open. #170's initial Windows shared job again crashes despite FXC; serial mitigation awaits hosted qualification | | #138 capability fallback | Actual constrained-adapter startup and relevant forced-tier corpus, truthful capability outputs | Existing implementation/evidence preserved; physical constrained-limit acceptance still needs proof | -| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#166 remain drafts; no merge performed | +| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#170 remain drafts; no merge performed | ## Engine work retained in scope @@ -54,77 +54,41 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json ## Current next steps -1. Complete hosted Windows test and build execution. The - [CI and example correction](evidence/windows-example-ci-v1.md) explicitly - selects Bash, restores the MSVC linker ahead of Git's tools, and requires - execution-summary artifacts. The hosted native build passes at `636b69a`. - Draft #162 corrects the two focused DX12 failures and passes the complete - Vulkan shared component. Hosted Windows still hits an access violation. - The expanded local DX12 goldens expose a [camera-history reset defect](evidence/windows-camera-history-v1.md); - its read guard passes exact fresh/reset comparisons after eight and 40 - history frames on Vulkan and DX12. Both complete local shared components - now pass, including all 93 goldens. Hosted #163 also passes all 93 macOS - goldens and the expanded cut check; its [evidence is published](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-camera-history-20260911). - The Windows library crash reproduces locally inside WARP's DXIL shader - optimizer during concurrent traversal tests. An [explicit FXC hosted lane](evidence/windows-warp-compiler-v1.md) - and consistent test compiler selection pass all 22 hosted Tests jobs at #164, - with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-warp-compiler-20260911). - Hosted Windows goldens skip its CPU adapter; complete physical Radeon DX12/DXC - and Vulkan shared runs pass all 93 goldens. The expanded physical FXC run - exposed four layered-material compiler failures. A [level-zero LUT correction](evidence/windows-fxc-layered-lut-v1.md) - restores the complete local FXC shared component, including all 93 goldens; - all 93 DXC and Vulkan regression goldens also pass. Hosted checks for that - correction pass all 22 Tests jobs at #165, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-fxc-layered-20260911). The underlying WARP/DXIL - defect remains open. -2. Finish all-example native linking, real starter/example startup, and clean - Windows installation. The [native example gate](evidence/windows-example-gate-v1.md) - passes all 20 links locally using Perry 0.5.1220 and one matching source-built - runtime profile. It adds required full-lane Windows PR compilation and - rejects missing or stale executable outputs. Hosted example validation also passes at `59244b9`; actual startup and clean - package installation remain required. Packing and installing the actual - package succeeds in a clean project, but its installed `bloom-web --help` - command fails on Windows because npm's shim tries `/bin/bash`. A bounded - starter compiles to WASM from that installed package; runtime rendering is - not yet proven. The next implementation work is portable command execution - and native/browser startup acceptance. The [portable web command](evidence/windows-portable-web-cli-v1.md) - now passes its clean installed help command, nine failure/assembly regression - checks, and a complete installed Perry-plus-engine WASM build on Windows. - Its asynchronous-copy follow-up also passes the hosted Windows pack/install - check and a fresh local installed web build. All 22 hosted Tests jobs pass - at #166, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-portable-web-20260911). - The [native package correction](evidence/windows-installed-native-v1.md) - fixes Jolt directory lookup and redundant final-link metadata. A diagnostic - installed fixture simulates Jolt and renders an exact frame on DX12 and - Vulkan. The complete fresh-package checker also passes both backends with - all 16,384 pixels matching and no CMake fallback. All 22 hosted Tests jobs at - #167 pass, including installed startup, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-installed-native-20260911). - The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md) - also passes the installed physics/image fixture in both scene and direct-2D - modes on DX12 and Vulkan. A render-target regression checks capture deferral - and fresh output pixels. All 22 hosted Tests jobs at #168 pass, including - both installed rendering modes. The [game-loop cleanup](game-loop.md) adds an optional - final callback on native and web and moves Pong onto the shared loop with - edge-triggered pause input. [Native cleanup and Pong replay evidence](evidence/windows-game-cleanup-v1.md) - also verifies the corrected palette names and all 20 example links. The same - Pong source completes the real web build; its browser frame remains unproven. - A [compiled-game browser gate](evidence/compiled-web-startup-v1.md) now prepares - real Perry startup and failure-control pages for hosted Chrome acceptance, - including exact pixels and one cleanup. Its hosted result is still required. - Browser starter - rendering, visible native presentation, shared lifecycle, general long-path - support remain open. -3. Complete the wider temporal/geometry, performance, memory, resize, and - capability corpus. The - [HD surface correction](evidence/windows-ssgi-surface-v1.md) and two valid - full Radeon runs are [published with #159](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-ssgi-surface-20260911). - All nine images pass; reproducibility retains 257 byte-identical artifacts. - The original 16-frame HD startup limits pass on Vulkan, DX12, and hosted - Metal. Earlier host-load failures remain invalid timing windows. Hosted - Metal lacks timestamp queries and cannot qualify GPU timing. -4. Complete API generation, streaming, components, runtime UI, and packaging - against the full issue requirements above, then prepare the draft stack for - review and integration. These outcomes include both implementation work and - acceptance evidence; they do not imply that every subsystem is absent. +1. **Finish real compiled-game browser acceptance (#74/#142).** + The [compiled-game gate](evidence/compiled-web-startup-v1.md) catches a gap + in the earlier JavaScript-driven renderer check: Perry returned success for + unresolved imports and emitted a game without the engine calls. The corrected + preparer installs the exact checkout as a dependency, rejects unresolved + imports and inspects the actual WASM import table. A local recording-FFI probe + passes callback/cleanup and explicit startup-fault controls; hosted rendering + must still produce the exact frame. Perry's plain throw propagation remains + a separate limitation found during this work. +2. **Qualify the Windows CI mitigation (#140).** + All 22 Tests jobs passed through #169. #170's initial shared-library job then + hit an access violation despite FXC. The next attempt serializes the Windows + harness while retaining every assertion. The local serial library passes + 489 tests with one existing ignored test; that does not establish the crash's + cause or qualify every helper on WARP. Physical Radeon DX12/DXC and Vulkan + image evidence remains distinct from hosted software rendering. +3. **Complete the starter and example experience (#142/#145).** + The installed web command works on Windows. Fresh native packages render + exact scene/direct-2D frames and simulate Jolt locally and in hosted CI. + [Shared cleanup, corrected example palettes and Pong pause replay](evidence/windows-game-cleanup-v1.md) + are [published at #169](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-starter-lifecycle-20260911). + All 20 canonical native examples link. Project creation/build/run commands, + all-example web/runtime acceptance, fixed updates, visible native presentation, + packaged DXC/DXIL and general Windows long-path support remain incomplete. +4. **Complete wider graphics and performance acceptance.** + [Two strict full Radeon runs at #159](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-ssgi-surface-20260911) + pass all nine images and reproduce 257 artifacts byte-identically. Rerun + current-source qualification after integration, and complete representative + temporal/geometry scenes, fractional/native and frozen A/B timing, memory, + resize and constrained-adapter checks. Hosted Metal without timestamp queries + cannot qualify GPU timing. Named discrete hardware acceptance stays open. +5. **Finish API generation, streaming, components, runtime UI and packaging**, then + prepare the draft stack for review and integration. The full issue requirements + in the table above govern completion; each subsystem already has some code. -Local work continues on the Radeon 760M. RTX-specific, physical constrained -adapter, and other unavailable hardware acceptance remains explicitly open. +Local work continues on the Radeon 760M. An RTX 4080 is not a prerequisite for +this implementation work. RTX-specific and physical constrained-adapter evidence +remain explicitly unperformed. No draft PR has been merged or npm package released. diff --git a/tools/ci/compile_web_game.py b/tools/ci/compile_web_game.py index da2fe6e..eb10168 100644 --- a/tools/ci/compile_web_game.py +++ b/tools/ci/compile_web_game.py @@ -10,10 +10,24 @@ import re import shutil import subprocess +import sys import time ROOT = Path(__file__).resolve().parents[2] VERSION = "perry 0.5.1220" +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import npm_command + + +def validate_compilation(log, imports): + if "Could not resolve import" in log: + raise RuntimeError("Perry could not resolve a game import; a zero compiler exit is insufficient") + required = {"bloom_init_window", "bloom_set_target_fps", "bloom_set_direct_2d_mode", + "bloom_run_game_with_cleanup", "bloom_write_file", "bloom_draw_rect"} + actual = {item["name"] for item in imports if item["module"] == "ffi"} + missing = required - actual + if missing: + raise RuntimeError(f"compiled game is missing required engine FFI imports: {sorted(missing)}") def main() -> int: @@ -48,6 +62,16 @@ def run(name, command): save() try: + # Perry does not resolve package self-references from an arbitrary + # source folder. Install an explicit local dependency for this project. + manifest = {"name": "bloom-compiled-web-fixture", "private": True, + "dependencies": {"@bloomengine/engine": "file:" + os.path.relpath(ROOT, entries).replace(os.sep, "/")}, + "perry": {"allow": {"nativeLibrary": ["@bloomengine/engine", "@bloomengine/engine/*"]}}} + (entries / "package.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + run("fixture-install", npm_command() + ["install", "--prefix", str(entries), "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false", "--install-links=false"]) + dependency = entries / "node_modules/@bloomengine/engine" + if dependency.resolve() != ROOT.resolve(): + raise RuntimeError("compiled fixture dependency must resolve to this exact engine checkout") run("perry-version", [args.perry, "--version"]) report["compiler_version"] = (out / "perry-version.log").read_text(encoding="utf-8").strip() if report["compiler_version"] != VERSION: @@ -69,10 +93,17 @@ def run(name, command): wasm = base64.b64decode(match[1], validate=True) if not wasm.startswith(b"\0asm"): raise RuntimeError(f"{name}: invalid game WASM header") + wasm_path = out / f"{name}.game.wasm" + wasm_path.write_bytes(wasm) + inspect = "const fs=require('node:fs');process.stdout.write(JSON.stringify(WebAssembly.Module.imports(new WebAssembly.Module(fs.readFileSync(process.argv[1])))));" + run(name + "-imports", ["node", "-e", inspect, str(wasm_path)]) + imports = json.loads((out / f"{name}-imports.log").read_text(encoding="utf-8")) + validate_compilation((out / f"{name}-compile.log").read_text(encoding="utf-8", errors="replace"), imports) run(name + "-splice", ["node", str(ROOT / "native/web/splice_game.cjs"), str(raw), str(page)]) report["pages"].append({"name": name, "entry_sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), "game_wasm_sha256": hashlib.sha256(wasm).hexdigest(), "game_wasm_bytes": len(wasm), "html_sha256": hashlib.sha256(page.read_bytes()).hexdigest(), + "engine_ffi_imports": sorted(item["name"] for item in imports if item["module"] == "ffi"), "expected_startup_failure": fails}) report["status"] = "pass" print("PASS: compiled and spliced actual Perry startup and failure-control pages") diff --git a/tools/ci/compiled_web_smoke.py b/tools/ci/compiled_web_smoke.py index bad065c..c7e093f 100644 --- a/tools/ci/compiled_web_smoke.py +++ b/tools/ci/compiled_web_smoke.py @@ -23,13 +23,41 @@ MONITOR = """ (() => { globalThis.__compiledGameErrors = []; +globalThis.__compiledGameLog = []; const describe = (value) => String(value?.stack || value).slice(0, 16384); const record = (value) => { if (__compiledGameErrors.length < 32) __compiledGameErrors.push(describe(value)); }; const originalError = console.error; console.error = (...args) => { record(args.map(describe).join(' ')); originalError.apply(console, args); }; +for (const level of ['log', 'warn']) { + const original = console[level]; + console[level] = (...args) => { + if (__compiledGameLog.length < 100) __compiledGameLog.push(level + ': ' + args.map(describe).join(' ')); + original.apply(console, args); + }; +} addEventListener('error', (event) => record(event.error || event.message || 'script load error')); addEventListener('unhandledrejection', (event) => record(event.reason)); globalThis.__joltFactory = async () => { throw new Error('physics omitted in compiled render smoke'); }; +// Inject a startup trap at a real compiled-game FFI call. The original write +// records entry into the control before the exception crosses back into WASM. +let ffiImports; +Object.defineProperty(globalThis, '__ffiImports', { + configurable: true, + get: () => ffiImports, + set: (value) => { + const write = value.bloom_write_file; + if (typeof write === 'function') { + value.bloom_write_file = (...args) => { + const result = write(...args); + if (args[0] === 'compiled-web-expected-fault') { + throw new WebAssembly.RuntimeError('BLOOM_EXPECTED_STARTUP_FAILURE'); + } + return result; + }; + } + ffiImports = value; + }, +}); if (globalThis.GPU) { const request = GPU.prototype.requestAdapter; GPU.prototype.requestAdapter = async function(...args) { @@ -53,7 +81,7 @@ def validate_state(name, state): if name == "game": if state["errors"] or state["frames"] != "8" or state["cleanups"] != "1": raise RuntimeError(f"compiled game failed startup/frame/cleanup acceptance: {state}") - elif state["expectedFault"] != "BLOOM_EXPECTED_STARTUP_FAILURE" or not state["errors"] or state["frames"] is not None or state["cleanups"] is not None: + elif state["expectedFault"] != "BLOOM_EXPECTED_STARTUP_FAILURE" or not any("RuntimeError: BLOOM_EXPECTED_STARTUP_FAILURE" in error for error in state["errors"]) or state["frames"] is not None or state["cleanups"] is not None: raise RuntimeError(f"intentional compiled startup failure was not rejected: {state}") @@ -137,6 +165,10 @@ def save(): cleanups: localStorage.getItem('bloom_fs:compiled-web-cleanups'), expectedFault: localStorage.getItem('bloom_fs:compiled-web-expected-fault'), errors: globalThis.__compiledGameErrors || [], + logs: globalThis.__compiledGameLog || [], + loading: document.getElementById('loading')?.textContent || null, + rootText: document.getElementById('perry-root')?.textContent?.slice(0, 4096) || null, + ffiCount: Object.keys(globalThis.__ffiImports || {}).length, }); })()""") if isinstance(state, dict) and (state["errors"] or state["cleanups"] is not None): break @@ -162,6 +194,12 @@ def save(): except (OSError, ValueError, RuntimeError, KeyError, TypeError, StopIteration, subprocess.SubprocessError) as exc: report["status"] = "fail" report["failures"].append(str(exc)) + if devtools is not None: + try: + capture = devtools.call("Page.captureScreenshot", {"format": "png", "fromSurface": True}) + (out / "failure.png").write_bytes(base64.b64decode(capture["data"])) + except (OSError, ValueError, RuntimeError, KeyError) as capture_error: + report["failure_capture_error"] = str(capture_error) print(f"FAIL: {exc}") return 1 finally: diff --git a/tools/ci/fixtures/compiled-web.ts b/tools/ci/fixtures/compiled-web.ts index 9ce9ed4..24e1819 100644 --- a/tools/ci/fixtures/compiled-web.ts +++ b/tools/ci/fixtures/compiled-web.ts @@ -6,8 +6,10 @@ import { drawRect } from "@bloomengine/engine/shapes"; const BLOOM_SMOKE_FAIL_STARTUP = false; if (BLOOM_SMOKE_FAIL_STARTUP) { + // The browser acceptance monitor records this real FFI call and injects a + // WebAssembly.RuntimeError across its return boundary. This is a fault + // injection control; the normal game never writes this marker. writeFile("compiled-web-expected-fault", "BLOOM_EXPECTED_STARTUP_FAILURE"); - throw new Error("BLOOM_EXPECTED_STARTUP_FAILURE"); } initWindow(128, 128, "Bloom compiled web startup"); diff --git a/tools/ci/test_compiled_web_smoke.py b/tools/ci/test_compiled_web_smoke.py index 6baa15b..853d543 100644 --- a/tools/ci/test_compiled_web_smoke.py +++ b/tools/ci/test_compiled_web_smoke.py @@ -1,9 +1,20 @@ import unittest from tools.ci.compiled_web_smoke import validate_state +from tools.ci.compile_web_game import validate_compilation class CompiledGameAcceptanceTests(unittest.TestCase): + def test_unresolved_imports_and_empty_game_cannot_pass_compilation(self): + imports = [{"module": "ffi", "name": name} for name in + ["bloom_init_window", "bloom_set_target_fps", "bloom_set_direct_2d_mode", + "bloom_run_game_with_cleanup", "bloom_write_file", "bloom_draw_rect"]] + validate_compilation("Generating WebAssembly", imports) + with self.assertRaisesRegex(RuntimeError, "could not resolve"): + validate_compilation("Warning: Could not resolve import '@bloomengine/engine/core'", imports) + with self.assertRaisesRegex(RuntimeError, "missing required"): + validate_compilation("Generating WebAssembly", []) + def test_success_requires_render_progress_and_exactly_one_cleanup(self): state = {"frames": "8", "cleanups": "1", "expectedFault": None, "errors": []} validate_state("game", state) @@ -12,9 +23,9 @@ def test_success_requires_render_progress_and_exactly_one_cleanup(self): validate_state("game", {**state, **change}) def test_failure_control_requires_entering_its_fault_and_an_actual_error(self): - state = {"frames": None, "cleanups": None, "expectedFault": "BLOOM_EXPECTED_STARTUP_FAILURE", "errors": ["WASM Error: unreachable"]} + state = {"frames": None, "cleanups": None, "expectedFault": "BLOOM_EXPECTED_STARTUP_FAILURE", "errors": ["RuntimeError: BLOOM_EXPECTED_STARTUP_FAILURE"]} validate_state("trap-control", state) - for change in ({"expectedFault": None}, {"errors": []}, {"frames": "8"}, {"cleanups": "1"}): + for change in ({"expectedFault": None}, {"errors": []}, {"errors": ["unrelated error"]}, {"frames": "8"}, {"cleanups": "1"}): with self.subTest(change=change), self.assertRaises(RuntimeError): validate_state("trap-control", {**state, **change})