diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c8ba8bb..9c8c4dd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -282,7 +282,8 @@ jobs:
~/.cargo/registry
~/.cargo/git
native/windows/target
- key: ${{ runner.os }}-windows-crate-${{ hashFiles('native/windows/Cargo.lock') }}
+ native/web/target
+ key: ${{ runner.os }}-windows-crate-web-${{ hashFiles('native/windows/Cargo.lock', 'native/web/Cargo.lock') }}
restore-keys: ${{ runner.os }}-windows-crate-
- name: full / host-build
@@ -338,6 +339,29 @@ jobs:
if-no-files-found: error
retention-days: 1
+ - name: Prepare pinned Windows web build tool
+ shell: pwsh
+ run: |
+ python tools/ci/setup_windows_wasm_pack.py --out "$env:RUNNER_TEMP/bloom-wasm-pack" --github-env
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ rustup target add wasm32-unknown-unknown
+
+ - name: Windows / installed starter web run
+ shell: pwsh
+ env:
+ CARGO_BUILD_JOBS: '2'
+ run: python tools/ci/starter_package_smoke.py --web --out target/ci/starter-web
+
+ - name: Retain complete installed starter website
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: bloom-starter-web-${{ github.run_id }}
+ path: target/ci/starter-web
+ include-hidden-files: true
+ if-no-files-found: error
+ retention-days: 1
+
- name: Windows / installed native startup
shell: pwsh
env:
@@ -523,6 +547,12 @@ jobs:
name: bloom-compiled-web-game-${{ github.run_id }}
path: target/ci/compiled-web-game
+ - name: Download installed starter website
+ uses: actions/download-artifact@v4
+ with:
+ name: bloom-starter-web-${{ github.run_id }}
+ path: target/ci/starter-web
+
- name: web / browser-smoke
run: ./scripts/ci-check.sh --web --component browser-smoke
diff --git a/docs/evidence/installed-starter-browser-v1.md b/docs/evidence/installed-starter-browser-v1.md
new file mode 100644
index 0000000..ca8df0e
--- /dev/null
+++ b/docs/evidence/installed-starter-browser-v1.md
@@ -0,0 +1,95 @@
+# Complete installed starter browser acceptance
+
+The minimal compiled-game fixture verifies rendering and lifecycle hooks, but
+does not exercise the generated starter's asset read, named function callbacks
+or text rendering. The new gate creates a project through the installed CLI,
+then runs the installed `bloom run --web` command against its unchanged source.
+The command compiles game and engine WASM, writes the asset manifest and starts
+its development server. The preparer checks served HTML, manifest, asset and
+engine bytes, including the WASM MIME type, then stops its owned process tree.
+The complete generated website and every file's SHA-256 are retained.
+
+Windows CI uses the existing qualified Perry 0.5.1220 profile and a pinned
+[official wasm-pack 0.15.0 archive](https://github.com/wasm-bindgen/wasm-pack/releases/tag/v0.15.0).
+Archive SHA-256 is checked before selecting the executable from the tar file;
+the tool's version and executable hash are recorded. The build adds no test
+markers or alternate logic to the starter source.
+
+Hosted macOS Chrome loads this exact website through its production bootstrap.
+The monitor observes actual asset fetch/read, text and rectangle FFI calls and
+the real Perry callback dispatcher. It also verifies every clear's opaque-black
+RGBA arguments. It requests normal engine stop after eight
+successful frames and requires exactly one cleanup. The screenshot must contain
+the 800x450 viewport, the square's white interior at its actual interpolated
+position, a populated text region and the expected black background. Text
+coverage allows glyph antialiasing differences; the real draw call must contain
+the exact text read from `assets/welcome.txt`. The existing startup-fault gate
+remains required. This new gate does not replace the game's draw calls or load
+a fake physics factory.
+
+## Failure found by executing the full starter
+
+The first real generated WASM run fails during named `init` callback dispatch:
+Perry exports that void function without a WASM result, but its closure bridge
+passes the returned JavaScript `undefined` into an i64 decoder. The decoder then
+raises `Cannot mix BigInt and other types`. Native starter rendering passed;
+compilation-only web evidence did not expose this startup failure. The raw
+failed probe and the observed `undefined` return are retained.
+
+Bloom's production splicer now installs a compatibility bridge that preserves
+an actual void return and delegates every boxed value to the existing decoder.
+It does not suppress other decoder exceptions. Actual native/WASM lifecycle
+checks now cover named functions for all five hooks. A recording-FFI probe runs
+the real compiled starter and production scheduler, reads the asset text,
+records eight text/rectangle calls and cleans up once with the correction.
+That initial probe did not validate color channels, load the engine renderer
+or claim browser/network acceptance.
+
+The corrected installed `bloom run --web` command passes locally in 64.531
+seconds, including game/engine compilation, serving the four required resources
+with exact file bytes and the correct WASM MIME type, and controlled shutdown.
+The resulting 11-file website includes the actual production compatibility
+bridge. Running that exact generated boot script with the recording FFI also
+passes all starter callbacks and one cleanup. Ten Python acceptance-control
+tests and the repository contracts pass; the text/image verifier accepts the
+previous actual native starter capture. Hosted browser rendering is pending.
+
+The first hosted run, 34571662102, passed all 21 preceding Tests jobs and the
+existing browser checks. The complete starter verifier then rejected the
+downloaded website before launch: upload-artifact omitted wasm-pack's
+`pkg/.gitignore`, while its build receipt correctly included all 11 files.
+All ten downloaded file hashes match. The scoped starter artifact upload now
+includes hidden files, preserving the complete site and the exact hash check.
+That failure remains retained.
+
+The retry, 34574961654, passed those same 21 preceding jobs and both existing
+browser checks. The complete site passed its file-hash check, loaded the actual
+asset and reached drawing. The full starter then failed its first frame:
+`Colors.BLACK` and `Colors.WHITE` reached FFI with undefined RGBA channels, and
+WebGPU rejected the non-finite clear alpha. Its error, draw arguments and
+failure screenshot are retained. This demonstrates why call counts and a
+successful build are insufficient evidence of complete starter execution.
+
+The starter now defines its two colors as explicit typed RGBA literals, matching
+the qualified native examples. Its strengthened actual-WASM recording probe
+rejects the earlier site and accepts the fresh build's exact black clear and
+white text/square arguments in all eight frames, with one cleanup. The hosted
+monitor now retains clear arguments as well as text/rectangle arguments; the
+verifier rejects missing channels. Fresh installed `bloom run --web` passes in
+47.218 seconds with unchanged source, the complete website and exact served
+bytes retained. This correction qualifies the starter's color usage; general
+imported palette compatibility remains separate. Hosted rendering of the
+corrected starter remains pending.
+
+The corrected unmodified starter also compiles and links natively. A bounded
+copy on Radeon 760M / DX12 renders the expected 800x450 black background, white
+square and asset text (857 lit text pixels), exits after nine frames and runs
+cleanup once. The capture SHA-256 is
+`d89c3b30d0e472526d6a39869e0c9e3ae3e9c0943f87b2dbf87a34cc91ac0b3f`.
+This local native check uses a linked checkout and the SDK's shader dependencies;
+it does not qualify a clean Windows installation.
+
+General Perry exception propagation, browser physics acceptance, the complete
+canonical runtime matrix, visible native presentation and clean distribution
+packaging remain separate. A browser warning about optional physics startup is
+retained as observed; the starter itself does not exercise physics.
diff --git a/docs/starter.md b/docs/starter.md
index fd5a9b3..cab1300 100644
--- a/docs/starter.md
+++ b/docs/starter.md
@@ -81,3 +81,9 @@ The fixed-lifecycle follow-up repeats fresh installed default creation, native
build and the bounded greeting/asset/cleanup run using the revised template,
then completes its full web build. See [the lifecycle evidence](evidence/fixed-game-lifecycle-v1.md).
The browser runtime still needs to qualify this complete starter.
+
+The [complete starter browser gate](evidence/installed-starter-browser-v1.md)
+now runs the installed web command and retains its unchanged generated website
+for hosted rendering, including the actual asset read, text draw and cleanup.
+It also covers the production bridge for named void callbacks in Perry WASM.
+Its hosted result is pending.
diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md
index d550910..13828f7 100644
--- a/docs/windows-engine-plan.md
+++ b/docs/windows-engine-plan.md
@@ -19,7 +19,7 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at
| #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. 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; the serial follow-up and #171 each pass all 22 hosted Tests jobs. The driver root cause remains open |
| #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–#171 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–#172 remain drafts; no merge performed |
## Engine work retained in scope
@@ -61,6 +61,11 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json
pass default project creation and setup-error controls in hosted CI. The full
starter's assets/text browser rendering and canonical example runtime matrix
remain open. All 20 native examples compile and link.
+ The [complete starter browser candidate](evidence/installed-starter-browser-v1.md)
+ now runs the installed web command, verifies its served files and retains the
+ unchanged site for hosted asset/text/render acceptance. Executing its real WASM
+ found a named-void-callback return conversion failure; the production bootstrap
+ bridge and expanded named-hook contracts pass locally. Hosted rendering is pending.
2. **Finish and qualify fixed lifecycle integration.**
The [fixed lifecycle candidate](evidence/fixed-game-lifecycle-v1.md) passes pure
native/WASM timing and hook-order contracts, plus exact installed rendering
diff --git a/native/web/splice_game.cjs b/native/web/splice_game.cjs
index b9095fb..281cfcc 100644
--- a/native/web/splice_game.cjs
+++ b/native/web/splice_game.cjs
@@ -27,6 +27,19 @@ const BLOOM_SHELL = `
`;
+function installPerryVoidReturnCompatibility(runtime) {
+ // Perry 0.5.1220 exports named void functions with no WASM result, while
+ // its closure bridge always decodes the return as a NaN-boxed i64. Preserve
+ // an actual void result; keep the original decoder for every boxed value.
+ const decode = runtime.__bitsToJsValue;
+ if (typeof decode !== "function") {
+ throw new Error("Perry runtime has no value decoder; use the supported compiler version.");
+ }
+ runtime.__bitsToJsValue = function(bits) {
+ return bits === undefined ? undefined : decode(bits);
+ };
+}
+
function splice(html) {
if (!html.includes(PERRY_ROOT)) {
throw new Error("could not find perry-root in Perry HTML; compiler output format may have changed");
@@ -43,10 +56,10 @@ function splice(html) {
}
tail = tail.replace(BOOT_CALL, 'window.__bloomReady.then(() => bootPerryWasm("');
tail = tail.replace(BOOT_CATCH, '\")).catch(');
- return head + tail;
+ return head + `(${installPerryVoidReturnCompatibility.toString()})(globalThis);\n` + tail;
}
-module.exports = { splice };
+module.exports = { splice, installPerryVoidReturnCompatibility };
if (require.main === module) {
try {
if (process.argv.length !== 4) throw new Error("Usage: splice_game.cjs ");
diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh
index 3c7e458..1e3e198 100755
--- a/scripts/ci-check.sh
+++ b/scripts/ci-check.sh
@@ -289,6 +289,11 @@ run_component() {
tools/ci/test_compiled_web_smoke.py \
tools/ci/fixed_step_smoke.py \
tools/ci/test_fixed_step_smoke.py \
+ tools/ci/starter_package_smoke.py \
+ tools/ci/starter_web_run.py \
+ tools/ci/starter_browser_smoke.py \
+ tools/ci/test_starter_browser_smoke.py \
+ tools/ci/setup_windows_wasm_pack.py \
tools/ci/test_compile_examples.py
"$python_cmd" -m unittest \
tools/quality/test_run.py \
@@ -303,6 +308,7 @@ run_component() {
tools/ci/test_compile_examples.py \
tools/ci/test_compiled_web_smoke.py \
tools/ci/test_fixed_step_smoke.py \
+ tools/ci/test_starter_browser_smoke.py \
-v
hr "visual metric and fault-engine tests"
cargo test --release --manifest-path tools/bloom-diff/Cargo.toml
@@ -340,6 +346,7 @@ run_component() {
"$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
+ "$python_cmd" tools/ci/starter_browser_smoke.py
;;
target-check)
cross_crate="${BLOOM_CROSS_CRATE:-}"
diff --git a/tools/ci/fixed_step_smoke.py b/tools/ci/fixed_step_smoke.py
index 4e02919..7cf1ec9 100644
--- a/tools/ci/fixed_step_smoke.py
+++ b/tools/ci/fixed_step_smoke.py
@@ -24,6 +24,7 @@
"overflowRejected": True, "hugeAlpha": 0.9, "tinyBounded": True,
"finiteContract": True, "sparseStarted": True, "sparseDraws": 1,
"updateStopEvents": "UC", "invalidDriverRejected": True, "invalidCalls": 0,
+ "namedEvents": "IFUDC",
}
@@ -95,7 +96,8 @@ def run(name, command):
report["source_sha256"] = {
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
for name in ("src/core/fixed_step.ts", "src/core/game_lifecycle.ts", "src/core/numbers.ts",
- "tools/ci/fixtures/fixed-step.ts", "tools/ci/perry_wasm_console.cjs")
+ "tools/ci/fixtures/fixed-step.ts", "tools/ci/perry_wasm_console.cjs",
+ "native/web/splice_game.cjs")
}
for mode in ("native", "wasm"):
artifact = out / ("fixed-step.html" if mode == "wasm" else "fixed-step.exe" if os.name == "nt" else "fixed-step")
diff --git a/tools/ci/fixtures/fixed-step.ts b/tools/ci/fixtures/fixed-step.ts
index 1c2df18..b02e775 100644
--- a/tools/ci/fixtures/fixed-step.ts
+++ b/tools/ci/fixtures/fixed-step.ts
@@ -104,6 +104,18 @@ const invalidDriverRejected = !invalidDriver.initialize();
invalidDriver.frame(0.01, () => false);
invalidDriver.dispose();
+let namedEvents = '';
+function namedInit(): void { namedEvents = namedEvents + 'I'; }
+function namedFixed(_dt: number, _tick: number): void { namedEvents = namedEvents + 'F'; }
+function namedUpdate(_dt: number): void { namedEvents = namedEvents + 'U'; }
+function namedDraw(_alpha: number): void { namedEvents = namedEvents + 'D'; }
+function namedCleanup(): void { namedEvents = namedEvents + 'C'; }
+const namedDriver = new GameLifecycleDriver({ init: namedInit, fixedUpdate: namedFixed,
+ update: namedUpdate, draw: namedDraw, cleanup: namedCleanup });
+namedDriver.initialize();
+namedDriver.frame(1 / 60, () => false);
+namedDriver.dispose();
+
// Read each observation directly. The original object-literal JSON.stringify
// report returned undefined in Perry WASM; preserve that separately from the
// timing/lifecycle contract. Events contain only this fixture's fixed ASCII tags.
@@ -142,4 +154,5 @@ result = result + ",\"sparseDraws\":" + sparseDraws;
result = result + ",\"updateStopEvents\":\"" + updateStopEvents + "\"";
result = result + ",\"invalidDriverRejected\":" + invalidDriverRejected;
result = result + ",\"invalidCalls\":" + invalidCalls;
+result = result + ",\"namedEvents\":\"" + namedEvents + "\"";
console.log("BLOOM_FIXED_STEP_RESULT:" + result + "}");
diff --git a/tools/ci/perry_wasm_console.cjs b/tools/ci/perry_wasm_console.cjs
index d12b639..7b250e0 100644
--- a/tools/ci/perry_wasm_console.cjs
+++ b/tools/ci/perry_wasm_console.cjs
@@ -5,6 +5,7 @@
// renderer, GPU, network or FFI stubs participate in the contract result.
const fs = require("node:fs");
const vm = require("node:vm");
+const { installPerryVoidReturnCompatibility } = require("../../native/web/splice_game.cjs");
async function main() {
if (process.argv.length !== 3) throw new Error("Usage: perry_wasm_console.cjs ");
@@ -36,6 +37,7 @@ async function main() {
context.window = context;
context.self = context;
vm.runInContext(scripts[0][1], context, { timeout: 5000 });
+ vm.runInContext(`(${installPerryVoidReturnCompatibility.toString()})(globalThis);`, context, { timeout: 5000 });
const boot = context.bootPerryWasm;
if (typeof boot !== "function") throw new Error("Perry runtime has no boot entry");
let completion;
diff --git a/tools/ci/setup_windows_wasm_pack.py b/tools/ci/setup_windows_wasm_pack.py
new file mode 100644
index 0000000..357dbfe
--- /dev/null
+++ b/tools/ci/setup_windows_wasm_pack.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Prepare the pinned official Windows wasm-pack binary for the starter gate."""
+
+import argparse
+import hashlib
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import tarfile
+import urllib.request
+
+VERSION = '0.15.0'
+ARCHIVE = f'wasm-pack-v{VERSION}-x86_64-pc-windows-msvc.tar.gz'
+URL = f'https://github.com/wasm-bindgen/wasm-pack/releases/download/v{VERSION}/{ARCHIVE}'
+SHA256 = '518dc51180c7bc864699c9279b3bc99025bc109123e0249a34ba34d130a509bf'
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--out', type=Path, required=True)
+ parser.add_argument('--github-env', action='store_true')
+ args = parser.parse_args()
+ out = args.out.resolve()
+ out.mkdir(parents=True, exist_ok=True)
+ archive = out / ARCHIVE
+ if not archive.exists() or hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256:
+ with urllib.request.urlopen(URL, timeout=120) as response:
+ data = response.read()
+ if hashlib.sha256(data).hexdigest() != SHA256:
+ raise RuntimeError('official wasm-pack archive SHA-256 differs from the pinned release')
+ archive.write_bytes(data)
+ with tarfile.open(fileobj=io.BytesIO(archive.read_bytes()), mode='r:gz') as tar:
+ files = [member for member in tar.getmembers() if member.isfile() and Path(member.name).name == 'wasm-pack.exe']
+ if len(files) != 1:
+ raise RuntimeError('official wasm-pack archive must contain exactly one executable')
+ data = tar.extractfile(files[0]).read()
+ executable = out / 'wasm-pack.exe'
+ executable.write_bytes(data)
+ version = subprocess.check_output([str(executable), '--version'], text=True).strip()
+ if version != f'wasm-pack {VERSION}':
+ raise RuntimeError(f'unexpected wasm-pack version: {version}')
+ receipt = dict(url=URL, archive_sha256=SHA256, executable_sha256=hashlib.sha256(data).hexdigest(), version=version)
+ (out / 'toolchain.json').write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8')
+ if args.github_env:
+ with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as output:
+ output.write(f'BLOOM_WASM_PACK={executable}\n')
+ print(json.dumps(receipt))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/ci/starter_browser_smoke.py b/tools/ci/starter_browser_smoke.py
new file mode 100644
index 0000000..f790f9d
--- /dev/null
+++ b/tools/ci/starter_browser_smoke.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Render the complete, unchanged installed starter website in hosted Chrome."""
+
+import argparse
+import base64
+import hashlib
+import http.server
+import json
+import math
+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.compiled_web_smoke import evaluate
+from tools.ci.web_smoke import QuietHandler, DevTools, browser_path, devtools_target, free_local_port
+from tools.quality.khronos_materials import png_rgb
+
+
+def validate_state(state):
+ if not isinstance(state, dict) or state.get('errors') or state.get('stopped') is not True:
+ raise RuntimeError(f'starter failed or did not stop normally: {state}')
+ for name, expected in [('frames', 8), ('cleanups', 1), ('registrations', 1)]:
+ if type(state.get(name)) is not int or state[name] != expected:
+ raise RuntimeError(f'starter {name} must be {expected}')
+ reads = state.get('reads', [])
+ if len(reads) != 1 or reads[0].get('path') != 'assets/welcome.txt' or reads[0].get('value', '').strip() != 'Hello, Bloom!':
+ raise RuntimeError('starter did not read the actual welcome asset')
+ if not any(item.get('path', '').endswith('/assets/welcome.txt') and item.get('status') == 200 for item in state.get('fetches', [])):
+ raise RuntimeError('starter did not fetch its welcome asset successfully')
+ texts, rects = state.get('texts', []), state.get('rects', [])
+ if state.get('clears') != [[0, 0, 0, 255] for _ in range(8)]:
+ raise RuntimeError('each starter frame must clear to opaque black')
+ if len(texts) != 8 or len(rects) != 8:
+ raise RuntimeError('each starter frame must draw its text and square')
+ for text in texts:
+ if len(text) != 8 or text[0].strip() != 'Hello, Bloom!' or text[1:] != [24, 24, 24, 255, 255, 255, 255]:
+ raise RuntimeError('starter text draw differs from the shipped template')
+ for rect in rects:
+ if len(rect) != 8 or not isinstance(rect[0], (int, float)) or not math.isfinite(rect[0]) or not 288 <= rect[0] <= 448 or rect[1:] != [193, 64, 64, 255, 255, 255, 255]:
+ raise RuntimeError('starter square draw differs from the shipped template')
+ return rects[-1]
+
+
+def check_pixels(width, height, pixels, rect):
+ if (width, height) != (800, 450) or len(pixels) != 800 * 450:
+ raise RuntimeError('starter capture must contain the complete 800x450 viewport')
+ x = rect[0]
+ text_pixels = 0
+ for index, pixel in enumerate(pixels):
+ px, py = index % width, index // width
+ text_region = 24 <= px < 250 and 24 <= py < 56
+ square_region = math.floor(x) - 1 <= px <= math.ceil(x + 64) + 1 and 192 <= py <= 258
+ if math.ceil(x) + 1 <= px < math.floor(x + 64) - 1 and 194 <= py < 256:
+ if pixel != (255, 255, 255):
+ raise RuntimeError('starter square interior is missing or incorrect')
+ elif text_region:
+ if max(pixel) >= 32: text_pixels += 1
+ elif not square_region and pixel != (0, 0, 0):
+ raise RuntimeError('starter background contains unexpected content')
+ if not 100 <= text_pixels <= 3000:
+ raise RuntimeError(f'starter text raster is missing or filled: {text_pixels} lit pixels')
+ return dict(width=width, height=height, text_pixels=text_pixels, square_x=x)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--browser')
+ parser.add_argument('--game', type=Path, default=ROOT / 'target/ci/starter-web')
+ parser.add_argument('--out', type=Path, default=ROOT / 'target/ci/web-smoke/starter')
+ parser.add_argument('--timeout', type=float, default=90)
+ args = parser.parse_args()
+ if not math.isfinite(args.timeout) or args.timeout <= 0: parser.error('timeout must be finite and positive')
+ out = args.out.resolve()
+ out.mkdir(parents=True, exist_ok=True)
+ report = dict(schema='bloom-starter-browser-v1', status='running', failures=[])
+ def save(): (out / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
+ process = devtools = server = thread = None
+ parent = Path(tempfile.gettempdir()).resolve()
+ temporary = Path(tempfile.mkdtemp(prefix='bloom-starter-browser-', dir=parent)).resolve()
+ try:
+ prepared = json.loads((args.game / 'result.json').read_text(encoding='utf-8'))
+ head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
+ if prepared.get('status') != 'pass' or prepared.get('source_commit') != head or not prepared.get('web', {}).get('source_unchanged'):
+ raise RuntimeError('starter artifact did not qualify the unchanged source at this checkout')
+ if prepared.get('web_command', {}).get('status') != 'pass':
+ raise RuntimeError('installed bloom run --web did not qualify its served website')
+ site = (args.game / 'site').resolve()
+ actual = {}
+ for current, dirs, files in os.walk(site, followlinks=False):
+ directory = Path(current)
+ if any((directory / name).is_symlink() or (directory / name).is_junction() for name in dirs + files):
+ raise RuntimeError('starter artifact contains an unexpected filesystem link')
+ for name in files:
+ file = directory / name
+ actual[file.relative_to(site).as_posix()] = hashlib.sha256(file.read_bytes()).hexdigest()
+ if actual != prepared['web']['files']: raise RuntimeError('starter website differs from its build receipt')
+ report['prepared'] = prepared
+ browser = browser_path(args.browser)
+ if browser is None: raise RuntimeError('Chrome/Chromium is required for full starter 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=900,600', f'--user-data-dir={temporary / "profile"}', 'about:blank']
+ 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 starter page')
+ devtools = DevTools(target)
+ devtools.call('Page.enable')
+ devtools.call('Runtime.enable')
+ devtools.call('Emulation.setDeviceMetricsOverride', dict(width=800, height=450, deviceScaleFactor=1, mobile=False))
+ devtools.call('Page.addScriptToEvaluateOnNewDocument', {'source': (ROOT / 'tools/ci/starter_monitor.js').read_text()})
+ url = f'http://127.0.0.1:{server.server_port}/'
+ devtools.call('Page.navigate', {'url': url})
+ started = time.monotonic()
+ state = None
+ while time.monotonic() - started < args.timeout:
+ state = evaluate(devtools, f'location.href === {json.dumps(url)} ? globalThis.__starterProbe || null : null')
+ if isinstance(state, dict) and (state.get('errors') or state.get('cleanups')): break
+ time.sleep(0.1)
+ report['state'] = state
+ report['duration_seconds'] = round(time.monotonic() - started, 3)
+ save()
+ rect = validate_state(state)
+ evaluate(devtools, 'new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
+ final_state = evaluate(devtools, 'globalThis.__starterProbe')
+ validate_state(final_state)
+ report['state'] = final_state
+ capture = devtools.call('Page.captureScreenshot', {'format': 'png', 'fromSurface': True})
+ frame = out / 'starter.png'
+ frame.write_bytes(base64.b64decode(capture['data']))
+ report['frame'] = {**check_pixels(*png_rgb(frame), rect), 'sha256': hashlib.sha256(frame.read_bytes()).hexdigest()}
+ report['adapter'] = evaluate(devtools, '(() => { const i = globalThis.__starterAdapter?.info; return i ? {vendor:i.vendor,architecture:i.architecture,device:i.device,description:i.description} : null; })()')
+ report['status'] = 'pass'
+ print('PASS: unchanged installed starter reads its asset, renders text/square, and cleans up once in Chrome')
+ return 0
+ except (OSError, ValueError, RuntimeError, KeyError, TypeError, subprocess.SubprocessError) as error:
+ report['status'] = 'fail'
+ report['failures'].append(str(error))
+ 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: {error}')
+ 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 != parent or temporary.is_symlink() or temporary.is_junction():
+ raise RuntimeError('refusing cleanup outside owned starter browser profile')
+ shutil.rmtree(temporary)
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/tools/ci/starter_monitor.js b/tools/ci/starter_monitor.js
new file mode 100644
index 0000000..23820ff
--- /dev/null
+++ b/tools/ci/starter_monitor.js
@@ -0,0 +1,81 @@
+// Observe the unmodified starter through its real FFI and callback boundaries.
+// The sole control action is a normal engine stop after eight successful frames.
+(() => {
+ const state = globalThis.__starterProbe = {
+ frames: 0, cleanups: 0, registrations: 0, reads: [], texts: [], rects: [], clears: [],
+ errors: [], logs: [], fetches: [], stopped: false,
+ };
+ const describe = value => String(value?.stack || value).slice(0, 16384);
+ const error = value => { if (state.errors.length < 32) state.errors.push(describe(value)); };
+ for (const level of ['error', 'warn', 'log']) {
+ const original = console[level];
+ console[level] = (...args) => {
+ if (level === 'error') error(args.map(describe).join(' '));
+ else if (state.logs.length < 100) state.logs.push(level + ': ' + args.map(describe).join(' '));
+ original.apply(console, args);
+ };
+ }
+ addEventListener('error', event => error(event.error || event.message));
+ addEventListener('unhandledrejection', event => error(event.reason));
+ const fetchOriginal = globalThis.fetch;
+ globalThis.fetch = async (...args) => {
+ const response = await fetchOriginal(...args);
+ const url = new URL(args[0]?.url || String(args[0]), location.href);
+ if (url.origin === location.origin && ['assets_manifest.json', 'assets/welcome.txt'].some(p => url.pathname.endsWith('/' + p))) {
+ state.fetches.push({ path: url.pathname, status: response.status });
+ }
+ return response;
+ };
+ if (globalThis.GPU) {
+ const request = GPU.prototype.requestAdapter;
+ GPU.prototype.requestAdapter = async function(...args) {
+ const adapter = await request.apply(this, args);
+ globalThis.__starterAdapter = adapter;
+ return adapter;
+ };
+ }
+ let ffi;
+ Object.defineProperty(globalThis, '__ffiImports', {
+ configurable: true,
+ get: () => ffi,
+ set: value => {
+ const originalRead = value.bloom_read_file;
+ value.bloom_read_file = (...args) => {
+ const result = originalRead(...args);
+ if (state.reads.length < 32) state.reads.push({ path: String(args[0]), value: String(result).slice(0, 4096) });
+ return result;
+ };
+ for (const [name, destination] of [['bloom_draw_text', 'texts'], ['bloom_draw_rect', 'rects'], ['bloom_clear_background', 'clears']]) {
+ const original = value[name];
+ value[name] = (...args) => {
+ const result = original(...args);
+ if (state[destination].length < 120) state[destination].push(args.map(v => typeof v === 'bigint' ? Number(v) : v));
+ return result;
+ };
+ }
+ let updateHandle, cleanupHandle;
+ const register = value.bloom_run_game_with_cleanup;
+ value.bloom_run_game_with_cleanup = (update, cleanup) => {
+ state.registrations++;
+ updateHandle = update;
+ cleanupHandle = cleanup;
+ return register(update, cleanup);
+ };
+ const call = globalThis.callWasmClosure;
+ if (typeof call !== 'function') throw new Error('Starter monitor requires the real Perry closure dispatcher');
+ globalThis.callWasmClosure = (callback, ...args) => {
+ if (callback === cleanupHandle) state.cleanups++;
+ const result = call(callback, ...args);
+ if (callback === updateHandle) {
+ state.frames++;
+ if (state.frames === 8) {
+ state.stopped = true;
+ value.bloom_close_window();
+ }
+ }
+ return result;
+ };
+ ffi = value;
+ },
+ });
+})();
diff --git a/tools/ci/starter_package_smoke.py b/tools/ci/starter_package_smoke.py
index 02a4d31..0e053f5 100644
--- a/tools/ci/starter_package_smoke.py
+++ b/tools/ci/starter_package_smoke.py
@@ -2,9 +2,11 @@
"""Require the packed starter CLI to create a project with the exact engine package."""
import argparse
+import base64
import hashlib
import json
import os
+import re
from pathlib import Path
import shutil
import subprocess
@@ -15,24 +17,26 @@
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from tools.ci.native_package_smoke import npm_command
+from tools.ci.starter_web_run import run_web
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=ROOT / "target/ci/installed-starter")
+ parser.add_argument("--web", action="store_true", help="Build and retain the full installed starter website")
args = parser.parse_args()
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
report = {"schema": "bloom-installed-starter-v1", "status": "running", "commands": []}
def save():
(out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
- def run(name, command, cwd, env=None, expected_error=None):
+ def run(name, command, cwd, env=None, expected_error=None, timeout=240):
record = {"name": name, "command": command, "cwd": str(cwd)}
report["commands"].append(record)
save()
start = time.monotonic()
with (out / f"{name}.stdout.log").open("wb") as stdout, (out / f"{name}.stderr.log").open("wb") as stderr:
- result = subprocess.run(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, timeout=240)
+ result = subprocess.run(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, timeout=timeout)
record.update(exit_code=result.returncode, duration_seconds=round(time.monotonic() - start, 3))
save()
stdout = (out / f"{name}.stdout.log").read_text(encoding="utf-8", errors="replace")
@@ -67,6 +71,9 @@ def run(name, command, cwd, env=None, expected_error=None):
source_files = ["tools/cli/bloom.cjs", "tools/cli/toolchain.cjs", "tools/cli/serve.cjs",
"tools/cli/templates/main.ts", "tools/cli/templates/README.md",
"tools/ci/setup_windows_perry.py"]
+ if args.web:
+ source_files += ['native/web/splice_game.cjs', 'native/web/game_loop.mjs',
+ 'src/core/index.ts', 'src/core/game_lifecycle.ts', 'src/core/fixed_step.ts']
report["installed_source_sha256"] = {}
for name in source_files:
# npm normalizes executable shebang line endings on Windows.
@@ -82,6 +89,55 @@ def run(name, command, cwd, env=None, expected_error=None):
run("unsupported-target", npm + ["exec", "--", "bloom", "build", "--target", "android"], project, expected_error="Unsupported starter target")
missing_env = {**os.environ, "BLOOM_PERRY": str(temporary / "missing-perry.exe")}
run("missing-compiler", npm + ["exec", "--", "bloom", "build", "--target", "web"], project, env=missing_env, expected_error="not found")
+ if args.web:
+ compiler = os.environ.get('BLOOM_PERRY') or shutil.which('perry')
+ wasm_pack = os.environ.get('BLOOM_WASM_PACK') or shutil.which('wasm-pack')
+ if not compiler or not wasm_pack:
+ raise RuntimeError('Perry and wasm-pack are required for installed starter web acceptance')
+ report['compiler_version'] = run('perry-version', [compiler, '--version'], project).strip()
+ report['wasm_pack_version'] = run('wasm-pack-version', [wasm_pack, '--version'], project).strip()
+ if report['compiler_version'] != 'perry 0.5.1220' or report['wasm_pack_version'] != 'wasm-pack 0.15.0':
+ raise RuntimeError('starter web gate requires Perry 0.5.1220 and wasm-pack 0.15.0')
+ report['compiler_sha256'] = hashlib.sha256(Path(compiler).read_bytes()).hexdigest()
+ web_env = os.environ.copy()
+ web_env['CARGO_TARGET_DIR'] = str(ROOT / 'native/web/target')
+ report['web_command'] = run_web(project, out, web_env)
+ log = (out / 'installed-web-run.stdout.log').read_text(encoding='utf-8', errors='replace') + (out / 'installed-web-run.stderr.log').read_text(encoding='utf-8', errors='replace')
+ if 'Could not resolve import' in log:
+ raise RuntimeError('starter web build contains unresolved Perry imports')
+ website = project / 'dist/web'
+ html = (website / 'index.html').read_text(encoding='utf-8')
+ encoded = re.search(r'window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"', html)
+ if encoded is None:
+ raise RuntimeError('starter web build has no compiled game WASM')
+ game_wasm = base64.b64decode(encoded[1], validate=True)
+ (out / 'starter.game.wasm').write_bytes(game_wasm)
+ inspect = "const fs=require('node:fs');process.stdout.write(JSON.stringify(WebAssembly.Module.imports(new WebAssembly.Module(fs.readFileSync(process.argv[1])))));"
+ imports = json.loads(run('game-imports', ['node', '-e', inspect, str(out / 'starter.game.wasm')], project))
+ required = {'bloom_init_window', 'bloom_run_game_with_cleanup', 'bloom_read_file', 'bloom_draw_text', 'bloom_draw_rect'}
+ actual = {item['name'] for item in imports if item['module'] == 'ffi'}
+ if required - actual:
+ raise RuntimeError(f'starter is missing required compiled imports: {sorted(required - actual)}')
+ if json.loads((website / 'assets_manifest.json').read_text()) != {'files': ['assets/welcome.txt']}:
+ raise RuntimeError('starter asset manifest differs from the shipped asset inventory')
+ if (website / 'assets/welcome.txt').read_text().strip() != 'Hello, Bloom!':
+ raise RuntimeError('starter text asset changed during the build')
+ if (project / 'main.ts').read_text(encoding='utf-8') != (ROOT / 'tools/cli/templates/main.ts').read_text(encoding='utf-8'):
+ raise RuntimeError('starter source was changed for browser acceptance')
+ # Retain the complete output of the actual installed build command.
+ # Reusing an output directory must never retain stale website files.
+ site = out / 'site'
+ if site.exists():
+ raise RuntimeError('starter web evidence output already contains a website; choose a fresh --out')
+ shutil.copytree(website, site)
+ (out / 'starter.ts').write_bytes((project / 'main.ts').read_bytes())
+ report['web'] = {'source_unchanged': True, 'game_wasm_sha256': hashlib.sha256(game_wasm).hexdigest(),
+ 'engine_ffi_imports': sorted(actual), 'files': {}}
+ for file in sorted(site.rglob('*')):
+ if file.is_symlink() or file.is_junction():
+ raise RuntimeError('starter website unexpectedly contains a filesystem link')
+ if file.is_file():
+ report['web']['files'][file.relative_to(site).as_posix()] = hashlib.sha256(file.read_bytes()).hexdigest()
report["status"] = "pass"
print("PASS: installed CLI creates an exact-package starter; setup failure controls rejected")
return 0
diff --git a/tools/ci/starter_web_run.py b/tools/ci/starter_web_run.py
new file mode 100644
index 0000000..1415ec1
--- /dev/null
+++ b/tools/ci/starter_web_run.py
@@ -0,0 +1,74 @@
+"""Run the installed public web command and verify the files it actually serves."""
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import socket
+import subprocess
+import time
+import urllib.error
+import urllib.request
+
+
+def run_web(project, out, env, timeout=1800):
+ with socket.socket() as listener:
+ listener.bind(('127.0.0.1', 0))
+ port = listener.getsockname()[1]
+ cli = project / 'node_modules/@bloomengine/engine/tools/cli/bloom.cjs'
+ command = [shutil.which('node'), str(cli), 'run', '--web', '--port', str(port)]
+ record = dict(command=command, cwd=str(project), status='running', served_files={})
+ def save(): (out / 'installed-web-run.json').write_text(json.dumps(record, indent=2) + '\n', encoding='utf-8')
+ save()
+ started = time.monotonic()
+ with (out / 'installed-web-run.stdout.log').open('wb') as stdout, (out / 'installed-web-run.stderr.log').open('wb') as stderr:
+ process = subprocess.Popen(command, cwd=project, env=env, stdout=stdout, stderr=stderr,
+ start_new_session=os.name != 'nt',
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
+ try:
+ while time.monotonic() - started < timeout:
+ if process.poll() is not None:
+ raise RuntimeError(f'installed bloom run --web exited before serving: {process.returncode}')
+ try:
+ with urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=1) as response:
+ if response.status == 200:
+ break
+ except (OSError, urllib.error.URLError):
+ pass
+ time.sleep(0.2)
+ else:
+ raise RuntimeError('installed bloom run --web did not serve before timeout')
+ for name in ('index.html', 'assets/welcome.txt', 'assets_manifest.json', 'pkg/bloom_web_bg.wasm'):
+ with urllib.request.urlopen(f'http://127.0.0.1:{port}/{name}', timeout=15) as response:
+ data = response.read()
+ mime = response.headers.get_content_type()
+ if data != (project / 'dist/web' / name).read_bytes():
+ raise RuntimeError(f'installed server returned different bytes for {name}')
+ if name.endswith('.wasm') and mime != 'application/wasm':
+ raise RuntimeError('installed server did not serve the engine with the WASM MIME type')
+ record['served_files'][name] = dict(sha256=hashlib.sha256(data).hexdigest(), mime=mime)
+ record['status'] = 'pass'
+ record['duration_seconds'] = round(time.monotonic() - started, 3)
+ save()
+ return record
+ except Exception as error:
+ record.update(status='fail', error=str(error), duration_seconds=round(time.monotonic() - started, 3))
+ save()
+ raise
+ finally:
+ # Terminate only this invocation's owned process tree; a failure may
+ # happen while its compiler child is still running.
+ if process.poll() is None:
+ if os.name == 'nt':
+ subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
+ else:
+ os.killpg(process.pid, signal.SIGTERM)
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ if os.name != 'nt': os.killpg(process.pid, signal.SIGKILL)
+ else: process.kill()
+ process.wait(timeout=5)
diff --git a/tools/ci/test_fixed_step_smoke.py b/tools/ci/test_fixed_step_smoke.py
index a06daa9..1c1a3e3 100644
--- a/tools/ci/test_fixed_step_smoke.py
+++ b/tools/ci/test_fixed_step_smoke.py
@@ -20,7 +20,7 @@ def test_wasm_harness_waits_for_boot_and_rejects_late_errors(self):
for body, success in ((emit, True), (emit + emit, False), ('', False),
(emit + 'console.error("late failure");', False),
(emit + 'throw new Error("late boot failure");', False)):
- page.write_text(''
+ page.write_text(''
'', encoding='utf-8')
result = subprocess.run(['node', str(helper), str(page)], capture_output=True, text=True, timeout=15)
diff --git a/tools/ci/test_starter_browser_smoke.py b/tools/ci/test_starter_browser_smoke.py
new file mode 100644
index 0000000..914f8c6
--- /dev/null
+++ b/tools/ci/test_starter_browser_smoke.py
@@ -0,0 +1,48 @@
+import copy
+import unittest
+
+from tools.ci.starter_browser_smoke import validate_state, check_pixels
+
+
+def valid_state():
+ return dict(frames=8, cleanups=1, registrations=1, stopped=True, errors=[],
+ reads=[dict(path='assets/welcome.txt', value='Hello, Bloom!\n')],
+ fetches=[dict(path='/assets/welcome.txt', status=200)],
+ clears=[[0, 0, 0, 255] for _ in range(8)],
+ texts=[['Hello, Bloom!\n', 24, 24, 24, 255, 255, 255, 255] for _ in range(8)],
+ rects=[[368, 193, 64, 64, 255, 255, 255, 255] for _ in range(8)])
+
+
+class StarterBrowserAcceptanceTests(unittest.TestCase):
+ def test_requires_real_asset_draw_progress_and_one_cleanup(self):
+ state = valid_state()
+ validate_state(state)
+ for change in (dict(frames=7), dict(cleanups=2), dict(registrations=True), dict(stopped=False),
+ dict(errors=['startup failed']), dict(reads=[]), dict(fetches=[]), dict(texts=[]),
+ dict(rects=[]), dict(clears=[]), dict(reads=[dict(path='assets/welcome.txt', value='')])):
+ with self.subTest(change=change), self.assertRaises(RuntimeError):
+ validate_state({**state, **change})
+ for field, position, value in [('texts', 0, 'wrong asset'), ('rects', 0, float('nan')), ('rects', 2, 0)]:
+ wrong = copy.deepcopy(state)
+ wrong[field][0][position] = value
+ with self.subTest(field=field), self.assertRaises(RuntimeError): validate_state(wrong)
+ for field in ['texts', 'rects', 'clears']:
+ wrong = copy.deepcopy(state)
+ wrong[field][0][-4:] = [None, None, None, None]
+ with self.subTest(field=field), self.assertRaises(RuntimeError): validate_state(wrong)
+
+ def test_pixels_reject_empty_missing_text_and_unexpected_background(self):
+ pixels = [(0, 0, 0)] * (800 * 450)
+ for y in range(193, 257):
+ for x in range(368, 432): pixels[y * 800 + x] = (255, 255, 255)
+ with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368])
+ for x in range(24, 224): pixels[32 * 800 + x] = (200, 200, 200)
+ check_pixels(800, 450, pixels, [368])
+ pixels[225 * 800 + 400] = (0, 0, 0)
+ with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368])
+ pixels[225 * 800 + 400] = (255, 255, 255)
+ pixels[0] = (255, 255, 255)
+ with self.assertRaises(RuntimeError): check_pixels(800, 450, pixels, [368])
+
+
+if __name__ == '__main__': unittest.main()
diff --git a/tools/ci/test_web_build.cjs b/tools/ci/test_web_build.cjs
index 11fc834..5baa046 100644
--- a/tools/ci/test_web_build.cjs
+++ b/tools/ci/test_web_build.cjs
@@ -7,7 +7,25 @@ const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { test } = require("node:test");
const { build, parseArgs, runTool } = require("../../native/web/build.cjs");
-const { splice } = require("../../native/web/splice_game.cjs");
+const { splice, installPerryVoidReturnCompatibility } = require("../../native/web/splice_game.cjs");
+
+test("void callback compatibility preserves boxed values and unrelated decoder errors", () => {
+ const calls = [];
+ const runtime = { __bitsToJsValue(bits) {
+ calls.push(bits);
+ if (typeof bits !== "bigint") throw new TypeError("expected boxed i64");
+ if (bits === 9n) throw new Error("decoder failure");
+ return bits === 7n ? undefined : "decoded";
+ } };
+ installPerryVoidReturnCompatibility(runtime);
+ assert.equal(runtime.__bitsToJsValue(undefined), undefined);
+ assert.deepEqual(calls, []);
+ assert.equal(runtime.__bitsToJsValue(7n), undefined);
+ assert.equal(runtime.__bitsToJsValue(8n), "decoded");
+ assert.throws(() => runtime.__bitsToJsValue(9n), /decoder failure/);
+ assert.throws(() => runtime.__bitsToJsValue(1), /expected boxed/);
+ assert.throws(() => installPerryVoidReturnCompatibility({}), /no value decoder/);
+});
const perryHtml = `