diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2916b3a..86baf118 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -297,6 +297,23 @@ jobs: CARGO_BUILD_JOBS: '2' run: python tools/ci/setup_windows_perry.py --out "$env:RUNNER_TEMP/bloom-perry" --github-env + - name: Windows / installed native startup + shell: pwsh + env: + CARGO_BUILD_JOBS: '2' + run: | + python -m unittest tools.ci.test_native_package_smoke + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/ci/native_package_smoke.py + + - name: Retain installed native startup evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-native-package-startup + path: target/ci/native-package + if-no-files-found: error + - name: full / example-compile env: CARGO_BUILD_JOBS: '2' diff --git a/docs/evidence/windows-installed-native-v1.md b/docs/evidence/windows-installed-native-v1.md new file mode 100644 index 00000000..03e694ac --- /dev/null +++ b/docs/evidence/windows-installed-native-v1.md @@ -0,0 +1,60 @@ +# Installed Windows native package + +The published Jolt dependency uses `lib/win32-x64`, while the engine's build +script searched for `windows-x64`. A clean install therefore ignored its shipped +archives and fell back to CMake. The long audit-project path then exceeded +MSBuild's path limit. The resolver now uses the published spelling and retains +the older spelling for staged CI archives. + +After that correction, Cargo builds the engine but Perry's final link still +requests `Jolt.lib` and `bloom_jolt.lib` from a CMake output directory that does +not exist for a prebuilt install. Rust already bundles those static archives in +`bloom_windows.lib`. Removing the redundant Windows manifest libraries and +source-build `libDirs` allows the installed game to link. A live Jolt simulation +in the startup fixture verifies that the physics implementation is present. + +## Local validation + +The diagnostic installed-package candidate links and starts on physical Radeon +DX12 and Vulkan. Its 128x128 frame matches all 16,384 expected pixels on each +backend. The fixture creates a dynamic Jolt body, advances it deterministically, +and draws the expected white square only while the valid body has fallen under +gravity. It destroys the body, shape and world after the native loop exits. + +The required Windows native-package check now packs and installs the actual +package into a fresh temporary project, compiles that fixture with the pinned +Perry toolchain, starts the native renderer and checks every frame pixel. It +rejects a CMake fallback, compiler/runtime errors, a missing capture and an +incorrect frame. Logs, source/package/compiler/archive hashes and captures are +retained; the temporary package, libraries and executable are removed. + +The fresh complete checker passes using Jolt 0.4.1 with no CMake fallback. Its +native compile takes 267.812 seconds; DX12 and Vulkan startup/capture take 6.328 +and 3.375 seconds. Both PNG files have SHA-256 +`8a509d87d3aa3fab96e0a9e2c67228e187f1bc0853cb4726aa5844799440f30e`. +These are compilation/test durations, not a frame-rate measurement. The tested +installed build script, manifest and fixture match the candidate source. + +Blank-frame and physics-failure-color controls are rejected. The full contracts +component, formatting and strict Clippy pass; Clippy also exercises the legacy +`windows-x64` staged-archive lookup. Hosted validation is pending. The runs use +the previously qualified Perry 0.5.1220 source/runtime profile. Local DX12 uses +SDK compiler DLLs from PATH. These results do not claim +stock-Perry usability, packaged DXC, a clean machine or window presentation. + +## Separate limitations retained + +- Supplying an external `CARGO_TARGET_DIR` lets Cargo build successfully but + Perry then searches its usual crate-local directory for the engine library. + That audit harness override was removed before the normal startup checks. +- Very long native project paths can still fail in MSVC's build-script linker; + the resolver correction does not fix general Windows path-length support. +- Direct-2D mode currently does not service the queued PNG capture. That initial + probe exited without an image and failed. The accepted fixture exercises the + normal scene path; direct-2D capture remains separate work. +- Native headless startup does not prove browser startup or visible native + presentation. #142/#74's complete starter and lifecycle remain open. + +Raw diagnostics and successive candidates are retained under +`tools/quality/out/windows-engine-plan/installed-native-startup/`. Earlier failures +are preserved with the changes in harness configuration and source clearly marked. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 0ffaee91..640efc7c 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. Release startup/install acceptance 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–#164 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–#166 remain drafts; no merge performed | ## Engine work retained in scope @@ -74,7 +74,7 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json exposed four layered-material compiler failures. A [level-zero LUT correction](evidence/windows-fxc-layered-lut-v1.md) restores the complete local FXC shared component, including all 93 goldens; all 93 DXC and Vulkan regression goldens also pass. Hosted checks for that - correction are pending. The underlying WARP/DXIL + correction pass all 22 Tests jobs at #165, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-fxc-layered-20260911). The underlying WARP/DXIL defect remains open. 2. Finish all-example native linking, real starter/example startup, and clean Windows installation. The [native example gate](evidence/windows-example-gate-v1.md) @@ -89,7 +89,16 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json 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. + Its asynchronous-copy follow-up also passes the hosted Windows pack/install + check and a fresh local installed web build; remaining hosted jobs are running. + The [native package correction](evidence/windows-installed-native-v1.md) + fixes Jolt directory lookup and redundant final-link metadata. A diagnostic + installed fixture simulates Jolt and renders an exact frame on DX12 and + Vulkan. The complete fresh-package checker also passes both backends with + all 16,384 pixels matching and no CMake fallback. Hosted startup checks are + pending. Browser starter + rendering, visible native presentation, shared lifecycle, general long-path + support and direct-2D frame capture 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 diff --git a/native/shared/build.rs b/native/shared/build.rs index b047397e..9896cd8c 100644 --- a/native/shared/build.rs +++ b/native/shared/build.rs @@ -222,19 +222,28 @@ fn find_prebuilt_dir( } else { "" }; - let target_token = format!("{}-{}{}", target_os, arch_token, sim_suffix); + // The published npm package/release matrix uses Node's Windows platform + // name. Keep Rust's spelling as a compatibility alias for staged CI libs. + let package_os = if target_os == "windows" { + "win32" + } else { + target_os + }; + let package_token = format!("{}-{}{}", package_os, arch_token, sim_suffix); + let rust_token = format!("{}-{}{}", target_os, arch_token, sim_suffix); + let mut target_tokens = vec![package_token]; + if target_tokens[0] != rust_token { + target_tokens.push(rust_token); + } - let candidates = std::iter::empty::() + let roots = std::env::var_os("BLOOM_JOLT_PREBUILT_DIR") + .map(std::path::PathBuf::from) + .into_iter() .chain( - std::env::var_os("BLOOM_JOLT_PREBUILT_DIR") - .map(|v| std::path::PathBuf::from(v).join(&target_token)), - ) - .chain(walk_up_for_node_modules(manifest_dir).map(|nm| { - nm.join("@bloomengine") - .join("jolt-prebuilt") - .join("lib") - .join(&target_token) - })); + walk_up_for_node_modules(manifest_dir) + .map(|nm| nm.join("@bloomengine").join("jolt-prebuilt").join("lib")), + ); + let candidates = roots.flat_map(|root| target_tokens.iter().map(move |token| root.join(token))); let (lib_prefix, lib_ext) = if target_os == "windows" { ("", "lib") diff --git a/npm/jolt-prebuilt/README.md b/npm/jolt-prebuilt/README.md index 2a3f9b07..7bfabeff 100644 --- a/npm/jolt-prebuilt/README.md +++ b/npm/jolt-prebuilt/README.md @@ -47,3 +47,8 @@ Built by `.github/workflows/release.yml` on each tag push — a matrix job per p This package is versioned independently. `@bloomengine/engine` pins the compatible prebuilt version in its dependencies. + +The Windows package directory is `win32-x64`. The engine also accepts the legacy +`windows-x64` spelling for manually staged archives. Rust bundles the selected +static archives into `bloom_windows.lib`; Perry links that engine library without +requiring an additional Jolt source-build directory. diff --git a/package.json b/package.json index 8d55e7ca..1cfaae22 100644 --- a/package.json +++ b/package.json @@ -4499,12 +4499,7 @@ "dxguid", "xinput", "opengl32", - "d3dcompiler", - "Jolt", - "bloom_jolt" - ], - "libDirs": [ - "native/third_party/bloom_jolt/build/windows-x86_64/lib" + "d3dcompiler" ] }, "linux": { diff --git a/tools/ci/fixtures/native-package.ts b/tools/ci/fixtures/native-package.ts new file mode 100644 index 00000000..fd52b72e --- /dev/null +++ b/tools/ci/fixtures/native-package.ts @@ -0,0 +1,34 @@ +import { + initWindow, runGame, clearBackground, closeWindow, + captureFrameToPng, isFrameCaptureReady, +} from "@bloomengine/engine/core"; +import { drawRect } from "@bloomengine/engine/shapes"; +import { + createWorld, destroyWorld, sphereShape, releaseShape, createBody, + destroyBody, bodyCount, isBodyValid, stepVariable, getBodyPosition, +} from "@bloomengine/engine/physics"; + +// Native installed-package acceptance: a real Jolt body must fall before the +// renderer produces the white square checked by the host. No random inputs. +initWindow(128, 128, "Bloom installed native startup"); +const world = createWorld({ gravity: { x: 0, y: -9.81, z: 0 }, maxBodies: 64, numThreads: 1 }); +const shape = sphereShape(0.5); +const body = createBody(world, shape, { motionType: 2, position: { x: 0, y: 4, z: 0 } }); +let frames = 0; +runGame((_dt) => { + stepVariable(world, 1 / 60, 1); + const y = getBodyPosition(body).y; + const physicsReady = isBodyValid(body) && bodyCount(world) === 1 && y > 0 && y < 4; + clearBackground({ r: 0, g: 0, b: 0, a: 255 }); + drawRect(32, 32, 64, 64, { + r: 255, g: physicsReady ? 255 : 0, b: physicsReady ? 255 : 0, a: 255, + }); + frames = frames + 1; + if (frames === 8) captureFrameToPng("native-startup.png"); + if (frames > 8 && isFrameCaptureReady()) closeWindow(); + // A failed capture must terminate and fail the host's image check. + if (frames >= 120) closeWindow(); +}); +destroyBody(body); +releaseShape(shape); +destroyWorld(world); diff --git a/tools/ci/native_package_smoke.py b/tools/ci/native_package_smoke.py new file mode 100644 index 00000000..1e047fa8 --- /dev/null +++ b/tools/ci/native_package_smoke.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Pack/install Bloom, compile a Perry game, and require its native frame.""" + +from __future__ import annotations + +import argparse +import ctypes +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.quality.khronos_materials import png_rgb # noqa: E402 + + +def npm_command() -> list[str]: + executable = shutil.which("npm") + if executable is None: + raise RuntimeError("npm is required to install the packed engine") + if os.name == "nt" and Path(executable).suffix.lower() in (".cmd", ".bat", ".ps1"): + # Invoke npm's JavaScript entry directly; do not turn paths into shell + # command text to work around Windows batch-file execution. + cli = Path(executable).parent / "node_modules/npm/bin/npm-cli.js" + node = shutil.which("node") + if not cli.is_file() or node is None: + raise RuntimeError(f"cannot locate Node/npm-cli.js beside {executable}") + return [node, str(cli)] + return [executable] + + +def check_frame(path: Path) -> dict: + width, height, pixels = png_rgb(path) + if (width, height) != (128, 128): + raise RuntimeError(f"unexpected startup frame size: {width}x{height}") + mismatches = 0 + for index, pixel in enumerate(pixels): + x, y = index % width, index // width + expected = (255, 255, 255) if 32 <= x < 96 and 32 <= y < 96 else (0, 0, 0) + mismatches += pixel != expected + if mismatches: + raise RuntimeError(f"startup/physics frame differs at {mismatches} of {width * height} pixels") + return {"width": width, "height": height, "exact_pixels": len(pixels), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/native-package") + parser.add_argument("--backend", choices=["dx12", "vulkan"], action="append") + args = parser.parse_args() + if os.name != "nt": + parser.error("this installed-package smoke currently supports Windows") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + report = {"schema": "bloom-native-package-smoke-v1", "status": "running", "commands": [], "frames": [], + "scope": "Installed source package, native headless renderer and Jolt; window presentation and packaged DXC remain separate."} + + def save() -> None: + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> str: + print(name, flush=True) + started = time.monotonic() + stdout_path, stderr_path = out / f"{name}.log", out / f"{name}.stderr.log" + record = {"name": name, "command": command, "cwd": str(cwd)} + report["commands"].append(record) + save() + try: + with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr: + result = subprocess.run(command, cwd=cwd, env=env, stdout=stdout, + stderr=stderr, timeout=timeout, check=False) + record["exit_code"] = result.returncode + if result.returncode: + raise RuntimeError(f"{name} exited {result.returncode}; see {stderr_path}") + return stdout_path.read_text(encoding="utf-8", errors="replace") + except (OSError, subprocess.SubprocessError, RuntimeError) as exc: + record["error"] = str(exc) + raise + finally: + record["duration_seconds"] = round(time.monotonic() - started, 3) + save() + + temporary_root = Path(tempfile.gettempdir()).resolve() + temporary = Path(tempfile.mkdtemp(prefix="bn-", dir=temporary_root)).resolve() + save() + try: + env = os.environ.copy() + env.setdefault("CARGO_BUILD_JOBS", "2") + # Perry locates native output in the crate's own target directory. + # Exercise dependency discovery without repository-specific overrides. + for name in ("CARGO_TARGET_DIR", "BLOOM_JOLT_PREBUILT_DIR", "BLOOM_JOLT_FROM_SOURCE"): + env.pop(name, None) + compiler = env.get("BLOOM_PERRY") or shutil.which("perry") + if not compiler: + raise RuntimeError("Perry is required; prepare the pinned Windows toolchain first") + report["compiler_version"] = run("perry-version", [compiler, "--version"], ROOT, env, 30).strip() + report["compiler_sha256"] = hashlib.sha256(Path(compiler).read_bytes()).hexdigest() + report["source_commit"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + npm = npm_command() + packed = json.loads(run("pack", npm + ["pack", "--json", "--ignore-scripts", "--pack-destination", str(temporary)], ROOT, env, 120))[0] + archive = temporary / packed["filename"] + report["archive_sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + project = temporary / "game" + project.mkdir() + manifest = {"name": "bloom-native-smoke", "version": "1.0.0", "private": True, "main": "main.ts", + "perry": {"allow": {"nativeLibrary": ["@bloomengine/engine", "@bloomengine/engine/*"]}}} + (project / "package.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + fixture = ROOT / "tools/ci/fixtures/native-package.ts" + shutil.copyfile(fixture, project / "main.ts") + report["fixture_sha256"] = hashlib.sha256(fixture.read_bytes()).hexdigest() + run("install", npm + ["install", "--ignore-scripts", "--no-audit", "--no-fund", str(archive)], project, env, 240) + installed = project / "node_modules/@bloomengine/engine" + jolt = project / "node_modules/@bloomengine/jolt-prebuilt" + report["jolt_version"] = json.loads((jolt / "package.json").read_text())["version"] + report["jolt_archives"] = [{"name": name, "sha256": hashlib.sha256((jolt / "lib/win32-x64" / name).read_bytes()).hexdigest()} + for name in ("Jolt.lib", "bloom_jolt.lib")] + binary = temporary / "native-smoke.exe" + run("compile", [compiler, "compile", "main.ts", "-o", str(binary)], project, env, 1800) + with binary.open("rb") as stream: + if stream.read(2) != b"MZ": + raise RuntimeError("Perry did not produce a native Windows executable") + report["binary_sha256"] = hashlib.sha256(binary.read_bytes()).hexdigest() + report["binary_bytes"] = binary.stat().st_size + report["used_cmake_fallback"] = (installed / "native/third_party/bloom_jolt/build").exists() + if report["used_cmake_fallback"]: + raise RuntimeError("installed prebuilt package was ignored; CMake fallback was used") + ctypes.windll.kernel32.SetErrorMode(0x0002 | 0x8000) + for backend in args.backend or ["dx12"]: + run_dir = temporary / backend + run_dir.mkdir() + runtime = env.copy() + runtime.update(BLOOM_HEADLESS="1", BLOOM_HEADLESS_PIXEL_EXACT="1", BLOOM_WGPU_BACKEND=backend) + run("startup-" + backend, [str(binary)], run_dir, runtime, 180) + png = run_dir / "native-startup.png" + if not png.is_file(): + raise RuntimeError("native startup exited without its required frame capture") + capture = out / f"startup-{backend}.png" + shutil.copyfile(png, capture) + report["frames"].append({"backend": backend, **check_frame(capture)}) + save() + report["status"] = "pass" + print("PASS: installed native package links, simulates Jolt, and renders its exact frame.") + return 0 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: + report.update(status="fail", error=str(exc)) + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + finally: + save() + if temporary.parent != temporary_root 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_native_package_smoke.py b/tools/ci/test_native_package_smoke.py new file mode 100644 index 00000000..5272e969 --- /dev/null +++ b/tools/ci/test_native_package_smoke.py @@ -0,0 +1,46 @@ +"""Negative controls for installed native startup acceptance.""" + +from pathlib import Path +import struct +import tempfile +import unittest +import zlib + +from tools.ci.native_package_smoke import check_frame + + +def fixture_png(path: Path, square: tuple[int, int, int] | None) -> None: + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + rows = bytearray() + for y in range(128): + rows.append(0) + for x in range(128): + rows.extend(square if square and 32 <= x < 96 and 32 <= y < 96 else (0, 0, 0)) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", 128, 128, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +class NativeFrameControls(unittest.TestCase): + def test_blank_frame_cannot_pass_startup(self): + with tempfile.TemporaryDirectory(prefix="bloom-frame-") as directory: + path = Path(directory) / "blank.png" + fixture_png(path, None) + with self.assertRaisesRegex(RuntimeError, "4096 of 16384"): + check_frame(path) + + def test_physics_failure_color_cannot_pass_startup(self): + with tempfile.TemporaryDirectory(prefix="bloom-frame-") as directory: + path = Path(directory) / "physics-failed.png" + fixture_png(path, (255, 0, 0)) + with self.assertRaisesRegex(RuntimeError, "4096 of 16384"): + check_frame(path) + + +if __name__ == "__main__": + unittest.main()