Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Windows / installed web command
shell: pwsh
run: ./tools/ci/check-installed-web-cli.ps1

- name: Retain installed command evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-installed-web-cli
path: |
target/ci/installed-web-cli/result.json
target/ci/installed-web-cli/*/*.log
if-no-files-found: error

- name: Set up MSVC environment
# Populates LIB / INCLUDE / PATH so cmake (which builds the Jolt C++
# shim via bloom-shared's build.rs) can find the Windows SDK. Without
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ You'll also need:
- **Perry** — the TypeScript AOT compiler that turns your game into a native binary or WASM module. It also drives the engine's native build.
- **Rust toolchain** ([rustup.rs](https://rustup.rs)) — Perry invokes Cargo to compile the engine's platform crate the first time you build for each target.
- For web builds only: [wasm-pack](https://crates.io/crates/wasm-pack) (`cargo install wasm-pack`).
- The packaged `bloom-web` command uses Node.js 18 or newer. See the
[web build guide](docs/web-target.md) for tool paths and Windows serving commands.

## Quick Start

Expand Down
64 changes: 64 additions & 0 deletions docs/evidence/windows-portable-web-cli-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Portable installed web command

The actual npm package installed successfully in a clean Windows project, but
`npm exec -- bloom-web --help` failed because its generated command shim tried
to run `/bin/bash`. Invoking the script directly through Git Bash in repository
checks did not exercise that installed entry point.

The old build also piped `wasm-pack` into `tail` without `pipefail`, allowing a
failed engine compile to appear successful and reuse stale `pkg/` output.

## Change

The package now exposes a Node entry point. It passes executable arguments
directly, resolves game/output paths from the caller, compiles in a fresh
temporary directory and assembles the distribution only after successful
compilation and artifact checks. Missing tools, nonzero compiler exits, missing
outputs and incompatible Perry HTML stop the build. The optional optimizer may
be absent; a present or explicitly configured optimizer must succeed.

The existing shell and Python entry points delegate to the same Node build and
splicer implementations. Bash and Python are no longer package build
dependencies. Node 18 or newer is declared. The web guide distinguishes this
upcoming branch from the stable 0.4.16 npm command.

Nine regression checks cover the real subprocess exit boundary, stale/missing
artifacts, spaces and assets, repeated builds, optimizer failures and Perry
bootstrap validation. Their fake compiler outputs are orchestration fixtures,
not evidence of engine compilation or rendering. Repository contracts run them;
the Windows build job additionally packs and installs the package in a clean
project and invokes npm's actual installed command.

## Local verification

- All nine regression checks and the complete contracts component pass.
- The candidate package packs and installs with pinned Jolt dependency 0.4.1.
Lifecycle scripts are disabled; both package manifests have no lifecycle scripts.
- The installed `npm exec -- bloom-web --help` passes on Windows.
- The installed command compiles a bounded TypeScript probe using Perry
0.5.1220, builds the engine with wasm-pack and assembles a distribution in a
path containing spaces. The full build passes in 107.140 seconds. Its engine
WASM is 7,839,672 bytes; the asset copy and gated HTML bootstrap are checked.
- The Windows CI pack/install script also passes locally. An initial probe
exposed Windows PowerShell's UTF-8 BOM in a generated package manifest; the
script now writes that manifest without a BOM and retains native exit codes
independently of stderr.

Commands, exact candidate patch, package and artifact hashes, full build logs,
and install receipts are retained under
`tools/quality/out/windows-engine-plan/portable-web-cli/`.

The first hosted run passes the Linux contracts but its Windows regression
process exits with 3221226505 during directory assembly. The runner image uses
Node 22.23.2; local checks use Node 24.21.0. Node tracks a matching Unicode-path
failure in its synchronous directory copy implementation
([upstream report](https://github.com/nodejs/node/issues/59636)). This is a likely
match, not a captured native stack proving the same root cause. The command now
awaits asynchronous directory copies, and the same nine local checks plus the
actual Windows pack/install command pass. The original hosted failure is retained.
Full installed-build and hosted revalidation of this follow-up are pending.

The local browser connection is unavailable, so
no browser frame or runtime startup result is claimed here. Native startup,
the one-command starter, shared lifecycle, wider example runtime matrix and
packaged shader-runtime acceptance remain open under #142, #74 and #145.
30 changes: 24 additions & 6 deletions docs/web-target.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ Game.ts ─(perry --target wasm)──> game WASM (game logic, base64-embedded
Browser: <canvas> + WebGPU + Web Audio + DOM Events
```

Both game logic and rendering run in WebAssembly. A thin JS glue layer (`native/web/bloom_glue.js`, spliced into Perry's self-contained HTML by `splice_game.py`) bridges the two modules, handles DOM events, asset fetching, and audio output.
Both game logic and rendering run in WebAssembly. A thin JS glue layer (`native/web/bloom_glue.js`, spliced into Perry's self-contained HTML by `splice_game.cjs`) bridges the two modules, handles DOM events, asset fetching, and audio output.

## Building

### Prerequisites

- Node.js 18 or newer, npm and a Rust toolchain
- [wasm-pack](https://crates.io/crates/wasm-pack): `cargo install wasm-pack`
- [Perry compiler](https://github.com/PerryTS/perry): built from source
- [Perry compiler](https://github.com/PerryTS/perry) on PATH
- wasm-opt (optional): `cargo install wasm-opt`

### Quick Build
Expand All @@ -36,10 +37,24 @@ npm exec -- bloom-web path/to/game/main.ts --output dist/web
```

This runs:
1. `wasm-pack build` to compile `native/web/` → `pkg/bloom_web_bg.wasm` + `pkg/bloom_web.js` bindings
2. `wasm-opt -Oz` for binary size optimization (if installed)
3. `perry main.ts --target wasm` to compile game TypeScript → WASM
4. Assembles output directory at `dist/web/`
1. `perry compile main.ts --target wasm` to compile game TypeScript and prepare its engine bootstrap
2. `wasm-pack build` to compile `native/web/` into fresh WASM and JavaScript bindings
3. `wasm-opt -Oz` for binary size optimization (if installed)
4. Assembles the fresh artifacts in `dist/web/`

The packaged command runs directly through Node on Windows, macOS and Linux.
This portable entry point is part of the upcoming 0.5 branch; npm 0.4.16 still
uses the earlier Bash entry point.
It does not require Bash or Python. Paths resolve from the caller's directory,
and paths containing spaces are passed directly to the tools. The existing
`native/web/build.sh` entry point delegates to the same command.

Use `--help` without a compiler installed. Missing tools and nonzero compiler
exits stop the build before assembly; an old `pkg/` cannot satisfy a failed
build. Set `BLOOM_PERRY`, `BLOOM_WASM_PACK` or `BLOOM_WASM_OPT` to a tool's
executable path when it is not on PATH. These values are paths, not shell
commands or argument strings. An explicitly configured optimizer is required;
an absent default `wasm-opt` is optional.

### Serve Locally

Expand All @@ -49,6 +64,9 @@ python3 -m http.server 8080
# Open http://localhost:8080
```

On Windows, use `python -m http.server 8080` if Python is installed as `python`.
Python is only an example HTTP server, not a build dependency.

## Game Loop

Browsers cannot run blocking `while` loops. Use `runGame()` instead:
Expand Down
5 changes: 4 additions & 1 deletion docs/windows-engine-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json
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.
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.
Real starter rendering and native startup 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
Expand Down
164 changes: 164 additions & 0 deletions native/web/build.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env node
"use strict";

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { splice } = require("./splice_game.cjs");

const HELP = `Usage: bloom-web [game.ts] [--output dist/web]

Build the engine and optional Perry game for the browser.
Requires Rust, wasm-pack and (for a game) Perry on PATH.
Optional: wasm-opt for additional size optimization.
Set BLOOM_PERRY, BLOOM_WASM_PACK or BLOOM_WASM_OPT to an executable path.
`;

class BuildError extends Error {
constructor(message, exitCode = 1) {
super(message);
this.exitCode = exitCode;
}
}

function parseArgs(args, cwd = process.cwd()) {
let game;
let output = "dist/web";
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--help" || arg === "-h") return { help: true };
if (arg === "--output") {
if (!args[i + 1] || args[i + 1].startsWith("--")) {
throw new BuildError("--output requires a directory", 2);
}
output = args[++i];
} else if (arg.startsWith("--output=")) {
output = arg.slice("--output=".length);
if (!output) throw new BuildError("--output requires a directory", 2);
} else if (arg.startsWith("-")) {
throw new BuildError(`unknown option: ${arg}`, 2);
} else if (game !== undefined) {
throw new BuildError("only one game entry file may be supplied", 2);
} else {
game = arg;
}
}
return {
game: game === undefined ? undefined : path.resolve(cwd, game),
output: path.resolve(cwd, output),
};
}

function runTool(program, args, { execute = spawnSync, cwd, optional = false } = {}) {
const result = execute(program, args, {
cwd, stdio: "inherit", shell: false, windowsHide: true,
});
if (result.error) {
if (optional && result.error.code === "ENOENT") return false;
const detail = result.error.code === "ENOENT"
? "executable not found; install it or set its BLOOM_* executable path"
: result.error.message;
throw new BuildError(`${program}: ${detail}`);
}
if (result.status !== 0) {
throw new BuildError(
`${program} failed (${result.signal ? `signal ${result.signal}` : `exit ${result.status}`})`,
Number.isInteger(result.status) && result.status > 0 ? result.status : 1,
);
}
return true;
}

function requireFile(file, label) {
if (!fs.existsSync(file) || !fs.statSync(file).isFile() || fs.statSync(file).size === 0) {
throw new BuildError(`${label} did not produce a non-empty file: ${file}`);
}
}

async function build(options, { execute = spawnSync, webDir = __dirname, env = process.env } = {}) {
if (options.game) requireFile(options.game, "game entry");
const wasmPack = env.BLOOM_WASM_PACK || "wasm-pack";
const perry = env.BLOOM_PERRY || "perry";
const wasmOpt = env.BLOOM_WASM_OPT || "wasm-opt";
const run = (program, args, extra = {}) => runTool(program, args, { execute, ...extra });

// Check required tools before compiling either module. Help needs no toolchain.
run(wasmPack, ["--version"]);
if (options.game) run(perry, ["--version"]);

const temporaryRoot = path.resolve(os.tmpdir());
const temporary = fs.mkdtempSync(path.join(temporaryRoot, "bloom-web-"));
try {
const pkg = path.join(temporary, "pkg");
const index = path.join(temporary, "index.html");
if (options.game) {
console.log("Compiling game with Perry...");
const gameHtml = path.join(temporary, "game.html");
run(perry, ["compile", options.game, "--target", "wasm", "-o", gameHtml], {
cwd: path.dirname(options.game),
});
requireFile(gameHtml, "Perry");
fs.writeFileSync(index, splice(fs.readFileSync(gameHtml, "utf8")), "utf8");
} else {
fs.copyFileSync(path.join(webDir, "index.html"), index);
}

console.log("Building engine with wasm-pack...");
run(wasmPack, ["build", "--target", "web", "--out-dir", pkg, "--no-typescript"], {
cwd: webDir,
});
const wasm = path.join(pkg, "bloom_web_bg.wasm");
requireFile(wasm, "wasm-pack");
requireFile(path.join(pkg, "bloom_web.js"), "wasm-pack");

const optimized = path.join(temporary, "optimized.wasm");
if (run(wasmOpt, ["-Oz", wasm, "-o", optimized], { optional: !env.BLOOM_WASM_OPT })) {
requireFile(optimized, "wasm-opt");
fs.renameSync(optimized, wasm);
} else {
console.log("Skipping optional wasm-opt (not installed).");
}

// Build in a unique staging directory. Failed commands cannot reuse an old
// pkg/ or overwrite the caller's previous successful distribution.
fs.mkdirSync(options.output, { recursive: true });
// Use the asynchronous copy implementation: Node's synchronous native
// directory-copy path has Windows failures with Unicode paths (#59636).
await fs.promises.cp(pkg, path.join(options.output, "pkg"), { recursive: true });
for (const file of ["bloom_glue.js", "jolt_bridge.js"]) {
fs.copyFileSync(path.join(webDir, file), path.join(options.output, file));
}
if (options.game) {
const assets = path.join(path.dirname(options.game), "assets");
if (fs.existsSync(assets)) {
await fs.promises.cp(assets, path.join(options.output, "assets"), { recursive: true });
}
}
fs.copyFileSync(index, path.join(options.output, "index.html"));
console.log(`Build complete: ${options.output}`);
console.log(`Engine WASM: ${fs.statSync(wasm).size} bytes`);
console.log("Serve this directory over HTTP; see docs/web-target.md.");
} finally {
// Only remove the directory this invocation created under the temp root.
if (path.dirname(path.resolve(temporary)) !== temporaryRoot) {
throw new BuildError("temporary build directory escaped its root");
}
fs.rmSync(temporary, { recursive: true, force: true });
}
}

async function main(args = process.argv.slice(2)) {
try {
const options = parseArgs(args);
if (options.help) console.log(HELP);
else await build(options);
return 0;
} catch (error) {
console.error(`bloom-web: ${error.message}`);
return error.exitCode || 1;
}
}

module.exports = { BuildError, parseArgs, runTool, build, main };
if (require.main === module) main().then((code) => { process.exitCode = code; });
Loading
Loading