diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9db07f5..fc97b3b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -263,6 +263,18 @@ jobs: # MSVC is installed on the runner. uses: ilammy/msvc-dev-cmd@v1 + - name: Windows / installed starter creation + shell: pwsh + run: python tools/ci/starter_package_smoke.py + + - name: Retain installed starter evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-installed-starter + path: target/ci/installed-starter + if-no-files-found: error + - name: Cache cargo uses: actions/cache@v4 with: diff --git a/README.md b/README.md index 5456e22..ac7749f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ cd dist/web && python3 -m http.server 8080 ## Features +The draft [starter command](docs/starter.md) adds project creation, native +build/run and web build/serve from one source. It includes the template, +permissions and asset example. Use the documented draft package flow; these +commands are not present in stable npm 0.4.16. + - **Simple API** — A function-first gameplay API with plain data handles. ([design rationale](docs/design-api.md)) - **True native** — Compiles to Metal, DirectX 12, Vulkan, OpenGL, and WebGPU via wgpu. - **Ship everywhere** — macOS, Windows, Linux, iOS, tvOS, watchOS, visionOS, Android, and Web from one codebase. diff --git a/docs/evidence/windows-starter-cli-v1.md b/docs/evidence/windows-starter-cli-v1.md new file mode 100644 index 0000000..61cbbc7 --- /dev/null +++ b/docs/evidence/windows-starter-cli-v1.md @@ -0,0 +1,55 @@ +# Installed starter command acceptance + +The new package includes `bloom new/run/build`, a shared TypeScript template, +asset example and local web server. The generator carries the exact engine +archive into each project, avoiding a preview/stable collision at the existing +0.4.16 version number. Native build/run selects the host platform; web uses the +existing actual Perry-plus-engine builder and writes a prefetch manifest. + +## Local execution + +The first fresh-package probe uses the explicit local archive option. Packing, +bootstrap install, installed command help and installed project creation all +pass. The unmodified starter builds natively in 270.360 seconds. The installed +`npm start` path then builds/runs a bounded copy in 10.141 seconds. Capture/state +instrumentation preserves the template's init/update/draw/cleanup logic. + +The 800x450 capture shows the greeting loaded from `assets/welcome.txt` and the +moving white square on black. Checks require its observed square center, +background corners, at least 4,096 white pixels and exactly one cleanup. Visual +inspection also confirms the greeting. The PNG SHA-256 is +`be7a32be2fb93fb24fa838370dc9c656c7b363ba19fe9b12f1681aa4b9d1acbd`. +These checks are not a claim that all 360,000 pixels are an exact golden. + +The probe restores the unmodified source and completes the full web build in +63.765 seconds. The generated asset manifest includes `assets/welcome.txt`, +whose bytes match the source. Engine WASM SHA-256 is +`c81e7d5760d7fc4de30b14f8a177f71b1a0988669252eab7e7e1aaedc7342d65`. +No compiled-starter browser rendering is claimed by this build result. + +The local native run uses the qualified Perry 0.5.1220 source/runtime profile, +Radeon 760M DX12, headless pixel-exact output and SDK shader DLLs on PATH. It +installs Jolt from the package dependency without repository prebuilt overrides. +Executables and the installed diagnostic project remain private. + +## Creation and failure controls + +The default creation follow-up packs the running installed engine instead of +selecting an older registry package with the same version. The installed-package +checker verifies the generated source, packaged command/setup files, asset and +archive reference, and requires nonzero actionable failures for a missing +compiler and unsupported target. This local default-creation check passes. +The cached managed Windows profile also passes compiler/library hash validation +and selects its matching runtime environment. It does not exercise a fresh +toolchain download. Hosted acceptance remains pending. + +Five unit checks cover help without tools, argument rejection, manifest drift, +existing-project preservation, explicit archive copying, compiler/process +failure causes and local serving. Repository contracts pass. Local HTTP checks +request an owned test server's generated files; they do not control a browser. + +Visible native startup, actual compiled-starter browser rendering, macOS/Linux +starter runtime, automatic fresh-machine toolchain setup through this CLI, +all-example runtime coverage, fixed updates, hot reload and distributable +shader-runtime packaging remain open. The tested native CLI uses an explicitly +prepared toolchain. No npm release or PR merge is performed. diff --git a/docs/starter.md b/docs/starter.md new file mode 100644 index 0000000..f3ffed9 --- /dev/null +++ b/docs/starter.md @@ -0,0 +1,78 @@ +# Starter commands + +This draft adds `bloom new`, `bloom run`, `bloom run --web` and `bloom build`. +They are not available in the stable npm 0.4.16 package. To try the draft from +its checkout, first create a package archive: + +```sh +npm pack --ignore-scripts +npm exec --package ./bloomengine-engine-0.4.16.tgz -- bloom new my-game +cd my-game +npm start +``` + +The creation command installs dependencies and writes one TypeScript source, +an asset example, Bloom/Perry version metadata and the native-library permissions. +It packs the exact engine package running the command into `.bloom/engine.tgz`, +which the project references locally. This keeps preview and registry packages +with the same version number from being confused. Keep the archive and generated +lockfile with the project. `--engine-package ` supplies a specific +compatible archive; `--no-install` defers dependency installation. + +## Prerequisites and targets + +- Node.js 18+ with npm, plus Rust. +- Windows native: Python 3.12+, Git and Visual Studio C++ build tools. Use a + developer shell. The first build prepares Perry 0.5.1220 and matching source + libraries under `%LOCALAPPDATA%/Bloom/perry-0.5.1220`. The existing verified + setup helper handles the compiler download and Cargo commands. Later builds + verify and reuse that cache. +- macOS/Linux native: Perry 0.5.1220 and the host platform SDK. These starter + command paths require their own runtime qualification; local evidence is Windows. +- Web: Perry 0.5.1220, wasm-pack and a WebGPU-capable browser. Set BLOOM_PERRY to + the executable prepared by Windows if that compiler is not on PATH. + +```sh +npm run web +npm exec -- bloom run --web --port 8081 +npm exec -- bloom build --target web +npm run build +``` + +Web run builds and serves at `http://127.0.0.1:8080` until Ctrl+C. It does not +automatically open a browser. Both native and web build the same `main.ts`. +The web asset manifest is generated before serving, so the glue prefetches the +template's text asset before synchronous game initialization. Native run uses +the distribution directory as its working directory to find the copied assets. + +Build targets are `native`, `web`, `windows`, `linux` and `macos`; native targets +must match the current host. Mobile deployment remains in its platform SDK flow. +Release builds omit Perry's debug-symbol flag. Signing, installers, DXC/DXIL +distribution and publishing are separate packaging work. + +The Windows toolchain location can be changed with BLOOM_TOOLCHAIN_DIR. To use +an explicitly prepared native profile, set BLOOM_PERRY, PERRY_RUNTIME_DIR and +PERRY_WORKSPACE_ROOT together. BLOOM_PYTHON controls the setup interpreter. +Perry currently requires crate-local native Cargo output; the command rejects +a configured CARGO_TARGET_DIR instead of reporting a successful build with no +executable. General long Windows project paths remain unqualified. + +## Development and validation + +The template separates init, variable update, draw and cleanup while using +`runGame` on both targets. See the [game-loop contract](game-loop.md). Edit, +stop and rerun to rebuild; automatic hot reload, fixed update and device-loss +recovery are not implemented by this command. + +Local installed-package acceptance creates the project through the installed +npm command, builds the unmodified template natively, and completes the full +web build from that same source. A bounded copy adds capture/state/cleanup +observations for native execution. It renders the 800x450 greeting and square, +loads `assets/welcome.txt` and cleans up once on Radeon DX12. This does not prove +visible presentation, compiled-starter browser rendering or a clean machine's +shader DLL packaging. + +The installed-package CI check verifies default creation from the exact packed +engine and rejects missing compilers and unsupported targets. Unit tests cover +manifest drift, existing-project preservation, asset inventory and the local +server. See [the retained acceptance report](evidence/windows-starter-cli-v1.md). diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index e6d6edb..6819d67 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -75,8 +75,11 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json 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, + All 20 canonical native examples link. The [starter command](starter.md) + creates a project from an installed package; its unmodified native/web builds + and bounded native greeting/asset/cleanup run pass locally. Default creation + now carries the exact engine archive, with hosted packaging checks pending. + 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) diff --git a/package.json b/package.json index 36f9035..f98f9fa 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "src/index.ts", "types": "src/index.ts", "bin": { + "bloom": "tools/cli/bloom.cjs", "bloom-web": "native/web/build.cjs" }, "exports": { @@ -24,6 +25,8 @@ "./quality": "./src/quality/index.ts" }, "files": [ + "tools/cli/**", + "tools/ci/setup_windows_perry.py", "src/", "perry.config.ts", "native/shared/Cargo.toml", diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 340cc48..325f156 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -226,6 +226,7 @@ run_component() { contracts) hr "CI command inventory" node tools/check-ci-contract.js + node --test tools/ci/test_starter_cli.cjs hr "FFI/schema parity" node tools/validate-ffi.js hr "documentation and package contracts" diff --git a/tools/ci/starter_package_smoke.py b/tools/ci/starter_package_smoke.py new file mode 100644 index 0000000..02a4d31 --- /dev/null +++ b/tools/ci/starter_package_smoke.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Require the packed starter CLI to create a project with the exact engine package.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import npm_command + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/installed-starter") + 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): + 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) + 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") + stderr = (out / f"{name}.stderr.log").read_text(encoding="utf-8", errors="replace") + if expected_error: + if result.returncode == 0 or expected_error not in stdout + stderr: + raise RuntimeError(f"{name}: missing expected nonzero failure: {expected_error}") + elif result.returncode: + raise RuntimeError(f"{name} exited {result.returncode}; see retained logs") + return stdout + parent = Path(tempfile.gettempdir()).resolve() + temporary = Path(tempfile.mkdtemp(prefix="bsc-", dir=parent)).resolve() + save() + try: + npm = npm_command() + report["source_commit"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + report["source_dirty"] = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT)) + packed = json.loads(run("pack", npm + ["pack", "--json", "--ignore-scripts", "--pack-destination", str(temporary)], ROOT))[0] + archive = temporary / packed["filename"] + report["package_sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + bootstrap = temporary / "bootstrap" + bootstrap.mkdir() + (bootstrap / "package.json").write_text('{"name":"starter-cli-bootstrap","private":true}\n') + run("install", npm + ["install", "--ignore-scripts", "--no-audit", "--no-fund", str(archive)], bootstrap) + run("help", npm + ["exec", "--", "bloom", "--help"], bootstrap) + project = temporary / "game" + run("create", npm + ["exec", "--", "bloom", "new", str(project)], bootstrap) + manifest = json.loads((project / "package.json").read_text()) + if manifest["dependencies"]["@bloomengine/engine"] != "file:.bloom/engine.tgz": + raise RuntimeError("generated project must pin the exact engine archive") + installed = project / "node_modules/@bloomengine/engine" + 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"] + report["installed_source_sha256"] = {} + for name in source_files: + # npm normalizes executable shebang line endings on Windows. + if (installed / name).read_text(encoding="utf-8") != (ROOT / name).read_text(encoding="utf-8"): + raise RuntimeError(f"installed starter source differs: {name}") + report["installed_source_sha256"][name] = hashlib.sha256((installed / name).read_bytes()).hexdigest() + if (project / "main.ts").read_text(encoding="utf-8") != (ROOT / "tools/cli/templates/main.ts").read_text(encoding="utf-8"): + raise RuntimeError("generated game source differs from the shipped template") + if (project / "assets/welcome.txt").read_text().strip() != "Hello, Bloom!": + raise RuntimeError("starter asset is missing") + report["project_engine_archive_sha256"] = hashlib.sha256((project / ".bloom/engine.tgz").read_bytes()).hexdigest() + report["project_config"] = json.loads((project / "bloom.json").read_text()) + 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") + report["status"] = "pass" + print("PASS: installed CLI creates an exact-package starter; setup failure controls rejected") + return 0 + except (OSError, ValueError, RuntimeError, KeyError, subprocess.SubprocessError) as error: + report.update(status="fail", error=str(error)) + print(f"FAIL: {error}") + return 1 + finally: + save() + if temporary.parent != 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/test_starter_cli.cjs b/tools/ci/test_starter_cli.cjs new file mode 100644 index 0000000..1d4ba91 --- /dev/null +++ b/tools/ci/test_starter_cli.cjs @@ -0,0 +1,89 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { spawnSync } = require("node:child_process"); +const { test } = require("node:test"); +const { parseArgs, createProject, readProject, assetManifest } = require("../cli/bloom.cjs"); +const { prepareToolchain, execute } = require("../cli/toolchain.cjs"); +const { serve } = require("../cli/serve.cjs"); + +function temporary(t) { + const parent = path.resolve(os.tmpdir()); + const root = fs.mkdtempSync(path.join(parent, "bloom-starter-test-")); + t.after(() => { + assert.equal(path.dirname(path.resolve(root)), parent); + fs.rmSync(root, { recursive: true, force: true }); + }); + return root; +} + +test("installed-style help needs no compiler and invalid commands fail", () => { + const help = spawnSync(process.execPath, [path.resolve(__dirname, "../cli/bloom.cjs"), "--help"], { env: { ...process.env, PATH: "" }, encoding: "utf8", windowsHide: true }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /bloom new/); + for (const args of [["new"], ["run", "--typo"], ["new", "game", "--engine-package"], ["build", "--target", "android"], ["run", "--web", "--port", "0"]]) assert.throws(() => parseArgs(args)); +}); + +test("scaffold keeps pinned manifests, separate lifecycle and assets; refuses existing projects", async (t) => { + const root = temporary(t); + const directory = path.join(root, "My game & Unicode-\u00e9"); + const archive = path.join(root, "fixture.tgz"); + fs.writeFileSync(archive, "owned package fixture"); + await createProject({ directory, engineArchive: archive, install: false }); + const config = readProject(directory); + assert.equal(config.entry, fs.realpathSync(path.join(directory, "main.ts"))); + const source = fs.readFileSync(config.entry, "utf8"); + assert.match(source, /runGame\(\(dt\) => \{ update\(dt\); draw\(\); \}, cleanup\)/); + assert.match(source, /readFile\("assets\/welcome.txt"\)/); + assert.deepEqual(await assetManifest(path.join(directory, "assets")), ["assets/welcome.txt"]); + await assert.rejects(() => createProject({ directory, install: false }), /already exists/); + assert.equal(fs.readFileSync(config.entry, "utf8"), source); + const manifestPath = path.join(directory, "package.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + manifest.perry.allow.nativeLibrary = []; + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + assert.throws(() => readProject(directory), /permissions/); +}); + +test("local package pin is copied into the project and missing archives fail before creation", async (t) => { + const root = temporary(t); + const missing = path.join(root, "missing-game"); + await assert.rejects(() => createProject({ directory: missing, engineArchive: path.join(root, "missing.tgz"), install: false })); + assert.equal(fs.existsSync(missing), false); + const archive = path.join(root, "test.tgz"); + fs.writeFileSync(archive, "owned package fixture"); + const directory = path.join(root, "local-game"); + await createProject({ directory, engineArchive: archive, install: false }); + assert.equal(fs.readFileSync(path.join(directory, ".bloom/engine.tgz"), "utf8"), "owned package fixture"); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"))); + assert.equal(manifest.dependencies["@bloomengine/engine"], "file:.bloom/engine.tgz"); + const configPath = path.join(directory, "bloom.json"); + const config = JSON.parse(fs.readFileSync(configPath)); + fs.writeFileSync(configPath, JSON.stringify({ ...config, perryVersion: "0.0.0" })); + assert.throws(() => readProject(directory), /mismatch/); + fs.writeFileSync(path.join(root, "outside.ts"), "// outside fixture"); + fs.writeFileSync(configPath, JSON.stringify({ ...config, entry: "../outside.ts" })); + assert.throws(() => readProject(directory), /inside/); +}); + +test("tool failures retain the cause and compiler mismatches fail before Cargo", () => { + assert.throws(() => execute(process.execPath, ["-e", "process.exit(23)"]), /exit 23/); + assert.throws(() => prepareToolchain({ native: false, engineRoot: process.cwd(), env: { ...process.env, BLOOM_PERRY: process.execPath } }), /Compiler mismatch/); +}); + +test("development server presents generated files with WASM MIME and rejects missing routes", async (t) => { + const root = temporary(t); + fs.writeFileSync(path.join(root, "index.html"), "owned test page"); + fs.writeFileSync(path.join(root, "engine.wasm"), "fixture wasm"); + const server = await serve(root, 0); + t.after(() => new Promise(resolve => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + assert.equal(await (await fetch(base)).text(), "owned test page"); + const wasm = await fetch(base + "/engine.wasm"); + assert.equal(wasm.headers.get("content-type"), "application/wasm"); + assert.equal((await fetch(base + "/missing.txt")).status, 404); + assert.equal((await fetch(base, { method: "POST" })).status, 405); +}); diff --git a/tools/cli/bloom.cjs b/tools/cli/bloom.cjs new file mode 100644 index 0000000..68d4826 --- /dev/null +++ b/tools/cli/bloom.cjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { PERRY_VERSION, execute, runNpm, installDependencies, prepareToolchain } = require("./toolchain.cjs"); +const { serve } = require("./serve.cjs"); +const engineRoot = path.resolve(__dirname, "../.."); +const enginePackage = require(path.join(engineRoot, "package.json")); + +async function assetManifest(directory, prefix = "assets") { + const files = []; + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { + const name = `${prefix}/${entry.name}`; + if (entry.isDirectory()) files.push(...await assetManifest(path.join(directory, entry.name), name)); + else if (entry.isFile()) files.push(name); + else throw new Error(`Starter assets must be regular files or directories: ${name}`); + } + return files.sort(); +} +const HELP = `Usage: + bloom new [--no-install] [--engine-package ] + bloom run [--web] [--port 8080] + bloom build [--release] [--target native|web|windows|linux|macos] + +Run build/run from the generated project directory. Native builds target the host. +Web run builds the same source and serves it at http://127.0.0.1:8080. +Requires Node.js 18+, Rust and Perry ${PERRY_VERSION}; web also needs wasm-pack. +Windows native builds prepare a matching compiler/runtime automatically unless +BLOOM_PERRY, PERRY_RUNTIME_DIR and PERRY_WORKSPACE_ROOT are explicitly configured. +Windows prerequisites: Python 3.12+, Git, Rust and Visual Studio C++ build tools. +Set BLOOM_PERRY, BLOOM_PYTHON or BLOOM_TOOLCHAIN_DIR to customize tool locations. +`; + +function parseArgs(args) { + if (!args.length || args.includes("--help") || args.includes("-h")) return { help: true }; + const [command, ...rest] = args; + if (!["new", "run", "build"].includes(command)) throw new Error(`Unknown command: ${command}`); + const options = { command, install: true, target: "native", port: 8080, release: false }; + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + if (arg === "--no-install" && command === "new") options.install = false; + else if (arg === "--web" && command === "run") options.target = "web"; + else if (arg === "--release" && command === "build") options.release = true; + else if ((arg === "--engine-package" && command === "new") || (arg === "--target" && command === "build") || (arg === "--port" && command === "run")) { + if (!rest[i + 1] || rest[i + 1].startsWith("--")) throw new Error(`${arg} requires a value`); + options[{ "--engine-package": "engineArchive", "--target": "target", "--port": "port" }[arg]] = rest[++i]; + } else if (command === "new" && !arg.startsWith("-") && !options.directory) options.directory = arg; + else throw new Error(`Unexpected argument for ${command}: ${arg}`); + } + if (command === "new" && !options.directory) throw new Error("bloom new requires a directory"); + options.port = Number(options.port); + if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("--port must be an integer from 1 to 65535"); + if (!["native", "web", "windows", "linux", "macos"].includes(options.target)) throw new Error(`Unsupported starter target: ${options.target}. Use native or web; mobile packaging needs the target-specific SDK flow.`); + return options; +} + +async function createProject(options) { + const directory = path.resolve(options.directory); + if (fs.existsSync(directory)) throw new Error(`Directory already exists: ${directory}; choose a new project directory.`); + const archive = options.engineArchive && path.resolve(options.engineArchive); + if (archive && (!archive.endsWith(".tgz") || !fs.statSync(archive).isFile())) throw new Error("--engine-package must name an existing npm .tgz archive"); + await fs.promises.mkdir(path.join(directory, "assets"), { recursive: true }); + await fs.promises.mkdir(path.join(directory, ".bloom")); + if (archive) { + await fs.promises.copyFile(archive, path.join(directory, ".bloom/engine.tgz")); + } else { + // Pin the exact package running this command. A preview tarball can share + // a version number with an older registry release that lacks these APIs. + const packed = JSON.parse(runNpm(["pack", "--json", "--ignore-scripts", "--pack-destination", path.join(directory, ".bloom")], engineRoot, + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] })); + const filename = packed[0]?.filename; + if (!filename || path.basename(filename) !== filename) throw new Error("npm pack did not report a package filename"); + await fs.promises.rename(path.join(directory, ".bloom", filename), path.join(directory, ".bloom/engine.tgz")); + } + const name = path.basename(directory).toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "bloom-game"; + const project = { + name, version: "0.1.0", private: true, main: "main.ts", + scripts: { start: "bloom run", web: "bloom run --web", build: "bloom build --release" }, + dependencies: { "@bloomengine/engine": "file:.bloom/engine.tgz" }, + perry: { allow: { nativeLibrary: ["@bloomengine/engine", "@bloomengine/engine/*"] } }, + }; + const config = { schema: 1, entry: "main.ts", bloomVersion: enginePackage.version, perryVersion: PERRY_VERSION }; + for (const [name, data] of [["package.json", project], ["bloom.json", config]]) { + await fs.promises.writeFile(path.join(directory, name), JSON.stringify(data, null, 2) + "\n"); + } + await fs.promises.copyFile(path.join(__dirname, "templates/main.ts"), path.join(directory, "main.ts")); + await fs.promises.copyFile(path.join(__dirname, "templates/welcome.txt"), path.join(directory, "assets/welcome.txt")); + await fs.promises.writeFile(path.join(directory, ".gitignore"), "node_modules/\ndist/\n"); + await fs.promises.copyFile(path.join(__dirname, "templates/README.md"), path.join(directory, "README.md")); + if (options.install) installDependencies(directory); + console.log(`Created ${directory}\nNext: cd into the directory${options.install ? "" : ", run npm install,"} and run npm start or npm run web.`); + return directory; +} + +function readProject(cwd) { + let config; + try { config = JSON.parse(fs.readFileSync(path.join(cwd, "bloom.json"), "utf8")); } + catch (error) { throw new Error(`Cannot read bloom.json. Run from a generated project directory. ${error.message}`); } + if (config.schema !== 1 || config.bloomVersion !== enginePackage.version || config.perryVersion !== PERRY_VERSION) throw new Error("Project/package mismatch: bloom.json must match the installed Bloom and compatible Perry versions. Reinstall the project's pinned dependency."); + if (typeof config.entry !== "string" || !config.entry.endsWith(".ts")) throw new Error("bloom.json entry must be a TypeScript file"); + const entry = fs.realpathSync(path.resolve(cwd, config.entry)); + const relative = path.relative(fs.realpathSync(cwd), entry); + if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) throw new Error("Project entry must stay inside the project directory"); + const manifest = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); + const allow = manifest.perry?.allow?.nativeLibrary; + if (!Array.isArray(allow) || !["@bloomengine/engine", "@bloomengine/engine/*"].every(value => allow.includes(value))) throw new Error("Project manifest is missing Bloom native-library permissions; restore the generated package.json perry.allow.nativeLibrary entries."); + if (!fs.existsSync(path.join(cwd, "assets"))) throw new Error("Project asset directory is missing: assets"); + return { entry }; +} + +async function buildProject(options, cwd = process.cwd()) { + const { entry } = readProject(cwd); + const host = { win32: "windows", linux: "linux", darwin: "macos" }[process.platform]; + if (options.target !== "web" && options.target !== "native" && options.target !== host) throw new Error(`Target ${options.target} requires its native host; this host is ${host || process.platform}. Use --target web for a portable build.`); + if (options.target !== "web" && !host) throw new Error(`Native starter builds are unsupported on ${process.platform}`); + const toolchain = prepareToolchain({ native: options.target !== "web", engineRoot }); + if (options.target === "web") { + const output = path.join(cwd, "dist/web"); + const assets = await assetManifest(path.join(cwd, "assets")); + execute(process.execPath, [path.join(engineRoot, "native/web/build.cjs"), entry, "--output", output], { cwd, env: { ...toolchain.env, BLOOM_PERRY: toolchain.compiler } }); + // The glue prefetches this list before entering synchronous game code. + await fs.promises.writeFile(path.join(output, "assets_manifest.json"), JSON.stringify({ files: assets }, null, 2) + "\n"); + if (options.command === "run") await serve(output, options.port); + return output; + } + const output = path.join(cwd, "dist", host); + await fs.promises.mkdir(output, { recursive: true }); + const temporary = await fs.promises.mkdtemp(path.join(output, ".build-")); + const filename = host === "windows" ? "game.exe" : "game"; + try { + const args = ["compile", entry, "-o", path.join(temporary, filename)]; + if (!options.release) args.push("--debug-symbols"); + execute(toolchain.compiler, args, { cwd, env: toolchain.env }); + const binary = path.join(temporary, filename); + if (!fs.existsSync(binary) || fs.statSync(binary).size === 0) throw new Error("Compiler did not produce a fresh native executable"); + await fs.promises.cp(path.join(cwd, "assets"), path.join(temporary, "assets"), { recursive: true }); + await fs.promises.cp(temporary, output, { recursive: true }); + } finally { + if (path.dirname(path.resolve(temporary)) !== path.resolve(output)) throw new Error("Build staging directory escaped the output directory"); + await fs.promises.rm(temporary, { recursive: true, force: true }); + } + console.log(`Build complete: ${path.join(output, filename)}`); + if (options.command === "run") execute(path.join(output, filename), [], { cwd: output, env: toolchain.env }); + return output; +} + +async function main(args = process.argv.slice(2)) { + try { + const options = parseArgs(args); + if (options.help) console.log(HELP); + else if (options.command === "new") await createProject(options); + else await buildProject(options); + return 0; + } catch (error) { console.error(`bloom: ${error.message}`); return 1; } +} + +module.exports = { parseArgs, createProject, readProject, assetManifest, buildProject, main }; +if (require.main === module) main().then(code => { process.exitCode = code; }); diff --git a/tools/cli/serve.cjs b/tools/cli/serve.cjs new file mode 100644 index 0000000..1b5ac22 --- /dev/null +++ b/tools/cli/serve.cjs @@ -0,0 +1,32 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const http = require("node:http"); + +async function serve(directory, port) { + const root = await fs.promises.realpath(directory); + const types = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript", ".wasm": "application/wasm", ".json": "application/json", ".png": "image/png", ".txt": "text/plain; charset=utf-8" }; + const server = http.createServer(async (request, response) => { + try { + if (!["GET", "HEAD"].includes(request.method)) { response.writeHead(405).end(); return; } + const route = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const file = await fs.promises.realpath(path.resolve(root, "." + (route === "/" ? "/index.html" : route))); + const relative = path.relative(root, file); + if (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + const stat = await fs.promises.stat(file); + if (!stat.isFile()) { response.writeHead(404).end(); return; } + response.writeHead(200, { "Content-Type": types[path.extname(file)] || "application/octet-stream", "Content-Length": stat.size, "Cache-Control": "no-store" }); + if (request.method === "HEAD") response.end(); + else fs.createReadStream(file).on("error", () => response.destroy()).pipe(response); + } catch { response.writeHead(404).end(); } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + console.log(`Bloom: http://127.0.0.1:${server.address().port} (Ctrl+C to stop)`); + return server; +} + +module.exports = { serve }; diff --git a/tools/cli/templates/README.md b/tools/cli/templates/README.md new file mode 100644 index 0000000..39056b2 --- /dev/null +++ b/tools/cli/templates/README.md @@ -0,0 +1,47 @@ +# My Bloom game + +This project pins Bloom in `package.json` and the compatible Perry version in +`bloom.json`. `.bloom/engine.tgz` contains the exact engine package used by the +creation command, so a preview cannot silently install an older registry package +with the same version number. Keep that archive and `package-lock.json` with the +project after dependency installation. + +Install Node.js 18+ and Rust. Web builds also require wasm-pack and Perry +0.5.1220 on PATH, or an executable path in BLOOM_PERRY. Windows native builds +require Python 3.12+, Git and Visual Studio C++ build tools, and automatically +prepare a matching Perry compiler/runtime under your local application data. +Run from a Visual Studio developer shell so the native linker is available. +macOS/Linux native builds require Perry 0.5.1220 and their normal platform SDK. + +```sh +npm install +npm start +npm run web +npm run build +``` + +`npm start` builds and runs natively. `npm run web` builds the same `main.ts` and +serves it at http://127.0.0.1:8080; open that address in a WebGPU-capable browser. +Ctrl+C stops the server. `npm exec -- bloom run --web --port 8081` changes the port. +Build-only web output is available with `npm exec -- bloom build --target web`. +Native builds target the current host; other native/mobile targets require their +platform SDK and packaging flow. Release builds omit Perry's debug-symbol flag; +this command does not sign, install or publish a distributable release. + +Edit `main.ts`, stop and rerun the command to rebuild. There is no automatic hot +reload yet. The template has init, variable update, draw and cleanup functions. +Cleanup runs after the final frame during normal shutdown. Focus loss does not +automatically pause the game; the template caps its variable delta at 0.1 seconds. +Fixed update and recovery after device loss are separate engine work. + +`assets/welcome.txt` demonstrates loading the same asset on native and web. Asset +paths are relative to the project and are copied beside the generated binary or +web page. Keep runtime resource disposal in `cleanup`. Do not put another +beginDrawing/endDrawing pair inside the runGame callback. + +Missing prerequisites, incompatible versions and manifest errors stop the build. +Native failures retain the compiler's output; browser startup failures appear in +the browser console. The first native build compiles the engine and can take +several minutes. Later builds reuse Cargo and Perry caches. Current Windows +native builds require short project paths and crate-local Cargo output; unset +CARGO_TARGET_DIR if it is configured globally. diff --git a/tools/cli/templates/main.ts b/tools/cli/templates/main.ts new file mode 100644 index 0000000..12a23ef --- /dev/null +++ b/tools/cli/templates/main.ts @@ -0,0 +1,37 @@ +import { + initWindow, runGame, closeWindow, clearBackground, setTargetFPS, + setDirect2DMode, getScreenWidth, getScreenHeight, readFile, Colors, +} from "@bloomengine/engine/core"; +import { drawRect } from "@bloomengine/engine/shapes"; +import { drawText } from "@bloomengine/engine/text"; + +let elapsed = 0; +let greeting = ""; + +function init(): void { + initWindow(800, 450, "My Bloom game"); + setTargetFPS(60); + setDirect2DMode(true); + greeting = readFile("assets/welcome.txt"); + if (greeting.length === 0) throw new Error("Missing starter asset: assets/welcome.txt"); +} + +function update(dt: number): void { + // Variable update, with a cap on time accumulated while a tab is hidden. + elapsed = elapsed + Math.min(dt, 0.1); +} + +function draw(): void { + clearBackground(Colors.BLACK); + const x = getScreenWidth() / 2 - 32 + Math.sin(elapsed) * 80; + drawRect(x, getScreenHeight() / 2 - 32, 64, 64, Colors.WHITE); + drawText(greeting, 24, 24, 24, Colors.WHITE); +} + +function cleanup(): void { + // Dispose owned textures, models, audio and physics resources here. + closeWindow(); +} + +init(); +runGame((dt) => { update(dt); draw(); }, cleanup); diff --git a/tools/cli/templates/welcome.txt b/tools/cli/templates/welcome.txt new file mode 100644 index 0000000..56b4fe6 --- /dev/null +++ b/tools/cli/templates/welcome.txt @@ -0,0 +1 @@ +Hello, Bloom! diff --git a/tools/cli/toolchain.cjs b/tools/cli/toolchain.cjs new file mode 100644 index 0000000..c803f74 --- /dev/null +++ b/tools/cli/toolchain.cjs @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const crypto = require("node:crypto"); +const { spawnSync } = require("node:child_process"); + +const PERRY_VERSION = "0.5.1220"; + +function execute(program, args, options = {}) { + const result = spawnSync(program, args, { + stdio: "inherit", shell: false, windowsHide: true, ...options, + }); + if (result.error) { + const detail = result.error.code === "ENOENT" ? "not found; install the documented prerequisite or set its BLOOM_* executable path" : result.error.message; + throw new Error(`${program}: ${detail}`); + } + if (result.status !== 0) throw new Error(`${program} failed (${result.signal || `exit ${result.status}`})`); + return result.stdout; +} + +function runNpm(args, cwd, options = {}) { + let npm = process.env.BLOOM_NPM; + let cli = process.env.npm_execpath; + if (!cli || !fs.existsSync(cli)) { + cli = path.join(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"); + } + if (!npm && fs.existsSync(cli)) return execute(process.execPath, [cli, ...args], { cwd, ...options }); + if (!npm && process.platform === "win32") { + for (const directory of (process.env.PATH || "").split(path.delimiter)) { + if (fs.existsSync(path.join(directory, "npm.exe"))) { npm = path.join(directory, "npm.exe"); break; } + const candidate = path.join(directory, "node_modules/npm/bin/npm-cli.js"); + if (fs.existsSync(candidate)) return execute(process.execPath, [candidate, ...args], { cwd, ...options }); + } + if (!npm) throw new Error("npm CLI not found. Install Node.js with npm, or set BLOOM_NPM to npm.exe."); + } + return execute(npm || "npm", args, { cwd, ...options }); +} + +function installDependencies(cwd) { return runNpm(["install", "--no-audit", "--no-fund"], cwd); } + +function hash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } + +function prepareToolchain({ native, engineRoot, env = process.env }) { + const buildEnv = { ...env }; + let compiler = env.BLOOM_PERRY || "perry"; + if (native && process.platform === "win32" && !(env.BLOOM_PERRY && env.PERRY_RUNTIME_DIR && env.PERRY_WORKSPACE_ROOT)) { + const cache = path.resolve(env.BLOOM_TOOLCHAIN_DIR || path.join(env.LOCALAPPDATA || os.homedir(), "Bloom", "perry-" + PERRY_VERSION)); + const metadata = path.join(cache, "toolchain.json"); + if (!fs.existsSync(metadata)) { + console.log("Preparing the matching Windows compiler and runtime (first build only)..."); + execute(env.BLOOM_PYTHON || "python", [path.join(engineRoot, "tools/ci/setup_windows_perry.py"), "--out", cache], { env: buildEnv }); + } + const receipt = JSON.parse(fs.readFileSync(metadata, "utf8")); + if (receipt.schema !== "bloom-windows-perry-toolchain-v1" || receipt.version !== PERRY_VERSION || + receipt.source_sha !== "06137858dc8c6f80975238377138f2f948d6ef88" || + receipt.runtime_rustflags !== "-C panic=unwind" || + hash(receipt.compiler) !== receipt.compiler_sha256) { + throw new Error(`Windows toolchain mismatch at ${metadata}; use a fresh BLOOM_TOOLCHAIN_DIR.`); + } + for (const name of ["perry_runtime.lib", "perry_stdlib.lib"]) { + if (hash(path.join(receipt.environment.PERRY_RUNTIME_DIR, name)) !== receipt.runtime_libraries_sha256[name]) { + throw new Error(`Windows runtime mismatch: ${name}; use a fresh BLOOM_TOOLCHAIN_DIR.`); + } + } + compiler = receipt.compiler; + Object.assign(buildEnv, receipt.environment); + } + const version = execute(compiler, ["--version"], { env: buildEnv, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).trim(); + if (version !== `perry ${PERRY_VERSION}`) throw new Error(`Compiler mismatch: expected Perry ${PERRY_VERSION}, found ${version}. Set BLOOM_PERRY to the compatible executable.`); + if (native && buildEnv.CARGO_TARGET_DIR) throw new Error("Perry native builds currently require crate-local Cargo output. Unset CARGO_TARGET_DIR for this command."); + execute("cargo", ["--version"], { env: buildEnv }); + return { compiler, env: buildEnv }; +} + +module.exports = { PERRY_VERSION, execute, runNpm, installDependencies, prepareToolchain };