From 07b3dd224d9f7982c40527cf71cf72421eb910e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Fri, 18 Sep 2026 00:29:38 -0300 Subject: [PATCH 1/3] Validate and document shader compiler overrides --- build.zig | 2 ++ docs/shader-materials.md | 18 ++++++++++++++++++ src/cli/material_toolchain.zig | 31 +++++++++++++++++++++++++++++++ src/cli/pipeline.zig | 2 ++ 4 files changed, 53 insertions(+) create mode 100644 docs/shader-materials.md create mode 100644 src/cli/material_toolchain.zig diff --git a/build.zig b/build.zig index 74f9ae4f..f63a615c 100644 --- a/build.zig +++ b/build.zig @@ -64,6 +64,8 @@ pub fn build(b: *std.Build) void { const run_cli_tests = b.addRunArtifact(cli_tests); const test_step = b.step("test", "Run CLI unit tests"); test_step.dependOn(&run_cli_tests.step); + const material_tests = b.addTest(.{ .root_module = cli_tests.root_module, .filters = &.{"shader tool override"} }); + b.step("test-material-toolchain", "Validate shader compiler override diagnostics").dependOn(&b.addRunArtifact(material_tests).step); // ── Progress-feed subprocess e2e (cli#319) ─────────────────────── // Spawns the REAL built CLI (`zig-out/bin/labelle build diff --git a/docs/shader-materials.md b/docs/shader-materials.md new file mode 100644 index 00000000..27b33425 --- /dev/null +++ b/docs/shader-materials.md @@ -0,0 +1,18 @@ +# Building game-owned shaders + +The assembler discovers `materials//material.json` and generates the `@import("materials")` module. `labelle generate` validates and stages descriptors; `labelle build`, `labelle run`, and direct `zig build` execute shaderc in the generated build graph. Shader compilation is not a user prebuild hook, and `LABELLE_NO_PREBUILD` does not disable it. Watched wasm builds retain the same generate/build sequence. + +Without an override, the generated graph builds the hash-pinned compiler for the **host**, independently of the game target. To reuse a compatible compiler on Windows: + +```powershell +$env:LABELLE_SHADERC = 'C:/tools/shaderc.exe' +labelle build +``` + +The override is inherited by the Zig subprocess. It must be an absolute executable path; the CLI diagnoses an unavailable override when the project has `materials/`. Direct generated builds also accept `zig build -Dshaderc=C:/tools/shaderc.exe`. Unset the override to return to automatic provisioning. Docker builds need paths and tools available inside the container. + +The generated graph supplies pinned BGFX headers and sprite varyings, compiles each explicitly requested variant, and propagates shader diagnostics/nonzero exits. Windows/Linux desktop currently require SPIR-V, Apple requires Metal, Android/web require GLES. GLSL/Metal/GLES bytecode generation on Windows is supported by shaderc; this alone does not establish GPU runtime coverage on those targets. + +Use `materials.NAME.shaders`, `&materials.NAME.parameters`, `.label`, and `.blend` with the engine's catalog-backed texture bindings. The low-level generated `descriptor(&bindings)` helper returns core's borrowed descriptor, whose texture bindings differ from the engine's catalog union. Source includes can use material-local files or `materials/shared/`; parent traversal and macro include paths are rejected. The complete materials tree is tracked for conservative include-cache invalidation. + +See the assembler's `docs/shader-materials.md` for the exact JSON contract and tests. No release version pins change as part of this feature; local integration must use compatible core/engine/gfx/backend snapshots with material contract v2. diff --git a/src/cli/material_toolchain.zig b/src/cli/material_toolchain.zig new file mode 100644 index 00000000..b091b1bd --- /dev/null +++ b/src/cli/material_toolchain.zig @@ -0,0 +1,31 @@ +//! Shader execution/caching lives in the generated Zig graph. The CLI only +//! preflights an explicit host-tool override; normal builds provision it there. +const std = @import("std"); +const config = @import("config.zig"); +pub fn validateOverridePath(path: []const u8) !void { + if (path.len == 0 or !std.fs.path.isAbsolute(path)) return error.ShadercOverrideMustBeAbsolute; +} +pub fn preflight(a: std.mem.Allocator, project_dir: []const u8) !void { + const path = config.globalEnviron().getAlloc(a, "LABELLE_SHADERC") catch return; + defer a.free(path); + const materials = try std.fs.path.join(a, &.{ project_dir, "materials" }); + defer a.free(materials); + var dir = std.Io.Dir.cwd().openDir(config.globalIo(), materials, .{}) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + dir.close(config.globalIo()); + validateOverridePath(path) catch |err| { + std.debug.print("labelle: LABELLE_SHADERC must be an absolute path to the host shaderc executable; unset it to build the pinned compiler automatically\n", .{}); + return err; + }; + std.Io.Dir.cwd().access(config.globalIo(), path, .{}) catch |err| { + std.debug.print("labelle: LABELLE_SHADERC '{s}' is unavailable ({s}); unset it to build the pinned compiler automatically\n", .{ path, @errorName(err) }); + return error.ShadercOverrideUnavailable; + }; +} +test "shader tool override cannot depend on changed generated working directory" { + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("shaderc")); + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("")); + try validateOverridePath(if (@import("builtin").os.tag == .windows) "C:/tools/shaderc.exe" else "/tools/shaderc"); +} diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index 5a890774..a7042921 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -18,6 +18,7 @@ const emsdk_toolchain = @import("emsdk_toolchain.zig"); const emsdk_activate = @import("emsdk_activate.zig"); const python_provision = @import("python_provision.zig"); const prebuild = @import("prebuild.zig"); +const material_toolchain = @import("material_toolchain.zig"); const bake_mod = @import("bake.zig"); const docker = @import("docker.zig"); const serve = @import("serve.zig"); @@ -605,6 +606,7 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !void { // same rewrite, which bypasses any loading-scene gate the project // declares. The override is delivered at runtime via the // `LABELLE_SCENE` env var injected at the spawn site (~line 990). + try material_toolchain.preflight(allocator, project_dir); try assembler_proc.generate( asm_bin, allocator, From 200e53a0f12300f7ea99474a35ed4ab099fd0d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Fri, 18 Sep 2026 01:05:43 -0300 Subject: [PATCH 2/3] review: make the shaderc override preflight name the offending value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight rejected a bad `LABELLE_SHADERC` but the relative-path diagnostic never printed the value, so the message was not actionable without re-reading the environment. It also accepted anything `access()` could reach — a directory passed, and the failure then surfaced much later as an opaque `zig build` error (or, under `labelle run`, as a silently reused stale binary). - Every rejection now names the offending value. - `statFile` replaces `access`: a non-regular-file override is rejected as `ShadercOverrideNotAFile`. - `getAlloc` no longer swallows every error as "unset"; only `EnvironmentVariableMissing` means no override is configured. - Negative tests cover relative/`.`/`..`/missing/directory, plus a positive case so the rejections are the check firing rather than the check always failing. Verified on the condenser example (which has `materials/`): LABELLE_SHADERC=shaderc -> ShadercOverrideMustBeAbsolute LABELLE_SHADERC=/tmp -> ShadercOverrideNotAFile LABELLE_SHADERC=/nope/shaderc-> ShadercOverrideUnavailable each printing the value, each before `assembler generate` runs. zig build test --summary all: 880/885 passed (5 skipped), exit 0. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XBGRLojiysEr5zy1U5mbP6 --- docs/shader-materials.md | 2 +- src/cli/material_toolchain.zig | 58 ++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/shader-materials.md b/docs/shader-materials.md index 27b33425..1dd06a71 100644 --- a/docs/shader-materials.md +++ b/docs/shader-materials.md @@ -9,7 +9,7 @@ $env:LABELLE_SHADERC = 'C:/tools/shaderc.exe' labelle build ``` -The override is inherited by the Zig subprocess. It must be an absolute executable path; the CLI diagnoses an unavailable override when the project has `materials/`. Direct generated builds also accept `zig build -Dshaderc=C:/tools/shaderc.exe`. Unset the override to return to automatic provisioning. Docker builds need paths and tools available inside the container. +The override is inherited by the Zig subprocess. It must be an absolute path to an existing regular file. When the project has `materials/`, the CLI preflights the value before generation and **fails the build** — naming the offending value — if it is relative (`LABELLE_SHADERC 'shaderc' is not an absolute path...`), missing (`... is unavailable (FileNotFound)`), or a directory (`... is a directory, not an executable file`). It never falls back to the pinned compiler behind your back: unset the variable to get automatic provisioning. Direct generated builds also accept `zig build -Dshaderc=C:/tools/shaderc.exe`. Unset the override to return to automatic provisioning. Docker builds need paths and tools available inside the container. The generated graph supplies pinned BGFX headers and sprite varyings, compiles each explicitly requested variant, and propagates shader diagnostics/nonzero exits. Windows/Linux desktop currently require SPIR-V, Apple requires Metal, Android/web require GLES. GLSL/Metal/GLES bytecode generation on Windows is supported by shaderc; this alone does not establish GPU runtime coverage on those targets. diff --git a/src/cli/material_toolchain.zig b/src/cli/material_toolchain.zig index b091b1bd..11283f5b 100644 --- a/src/cli/material_toolchain.zig +++ b/src/cli/material_toolchain.zig @@ -1,12 +1,43 @@ //! Shader execution/caching lives in the generated Zig graph. The CLI only //! preflights an explicit host-tool override; normal builds provision it there. +//! +//! The preflight fails CLOSED: once `LABELLE_SHADERC` is set for a project that +//! has `materials/`, generation stops unless the value is an absolute path to a +//! real file. Silently ignoring a bad override would hand the generated graph a +//! compiler that cannot run, surfacing much later as an unrelated `zig build` +//! failure — or, under `labelle run`, as a silently reused stale binary. const std = @import("std"); const config = @import("config.zig"); + pub fn validateOverridePath(path: []const u8) !void { if (path.len == 0 or !std.fs.path.isAbsolute(path)) return error.ShadercOverrideMustBeAbsolute; } + +/// Full override check, diagnostics included. Every rejection names the +/// offending value so the message is actionable without re-reading the env. +pub fn validateOverride(path: []const u8) !void { + validateOverridePath(path) catch |err| { + std.debug.print("labelle: LABELLE_SHADERC '{s}' is not an absolute path; it must name the host shaderc executable, or be unset to build the pinned compiler automatically\n", .{path}); + return err; + }; + const stat = std.Io.Dir.cwd().statFile(config.globalIo(), path, .{}) catch |err| { + std.debug.print("labelle: LABELLE_SHADERC '{s}' is unavailable ({s}); unset it to build the pinned compiler automatically\n", .{ path, @errorName(err) }); + return error.ShadercOverrideUnavailable; + }; + if (stat.kind != .file) { + std.debug.print("labelle: LABELLE_SHADERC '{s}' is a {s}, not an executable file; unset it to build the pinned compiler automatically\n", .{ path, @tagName(stat.kind) }); + return error.ShadercOverrideNotAFile; + } +} + pub fn preflight(a: std.mem.Allocator, project_dir: []const u8) !void { - const path = config.globalEnviron().getAlloc(a, "LABELLE_SHADERC") catch return; + const path = config.globalEnviron().getAlloc(a, "LABELLE_SHADERC") catch |err| switch (err) { + // No override configured: the generated graph builds the pinned + // compiler itself. Any OTHER failure is real and must not be + // mistaken for "unset". + error.EnvironmentVariableMissing => return, + else => return err, + }; defer a.free(path); const materials = try std.fs.path.join(a, &.{ project_dir, "materials" }); defer a.free(materials); @@ -15,17 +46,26 @@ pub fn preflight(a: std.mem.Allocator, project_dir: []const u8) !void { else => return err, }; dir.close(config.globalIo()); - validateOverridePath(path) catch |err| { - std.debug.print("labelle: LABELLE_SHADERC must be an absolute path to the host shaderc executable; unset it to build the pinned compiler automatically\n", .{}); - return err; - }; - std.Io.Dir.cwd().access(config.globalIo(), path, .{}) catch |err| { - std.debug.print("labelle: LABELLE_SHADERC '{s}' is unavailable ({s}); unset it to build the pinned compiler automatically\n", .{ path, @errorName(err) }); - return error.ShadercOverrideUnavailable; - }; + try validateOverride(path); } + test "shader tool override cannot depend on changed generated working directory" { try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("shaderc")); try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("")); + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("./tools/shaderc")); + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverridePath("../shaderc")); try validateOverridePath(if (@import("builtin").os.tag == .windows) "C:/tools/shaderc.exe" else "/tools/shaderc"); } + +test "shader tool override rejects a relative, missing or non-file value" { + if (@import("builtin").os.tag == .windows) return error.SkipZigTest; + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, validateOverride("shaderc")); + // An absolute path that does not exist must NOT pass as "probably fine". + try std.testing.expectError(error.ShadercOverrideUnavailable, validateOverride("/nonexistent/labelle-shaderc-probe")); + // A directory satisfies `access()` but can never be executed, so the + // check has to look at the kind, not merely at reachability. + try std.testing.expectError(error.ShadercOverrideNotAFile, validateOverride("/tmp")); + // …and a real absolute file is accepted, so the rejections above are + // the check firing rather than the check always failing. + try validateOverride("/bin/sh"); +} From 24b4b5dff95a36a71670b97862cbae320c305b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Fri, 18 Sep 2026 01:50:03 -0300 Subject: [PATCH 3/3] review: gate watched wasm rebuilds on the shader override preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LABELLE_SHADERC preflight ran once, at the top of `pipeline.run`. `labelle wasm serve --watch` started without a `materials/` directory therefore skipped it, and when a later edit or prebuild hook created `materials/`, `WasmRebuildCtx.rebuild` went straight from its hooks to `assembler generate` — landing on the opaque shader-compiler failure the preflight exists to replace. A hole in the fail-closed guarantee. - `WasmRebuildCtx.rebuild` now runs `material_toolchain.preflight` (the same function, as the field default — not a copy) after the prebuild hooks and before generation, so a watched rebuild is gated exactly like a cold build. - The rebuild body is a staged `rebuildStaged() Stage!void`; `rebuild` is the bool wrapper the serve loop expects. The stage is what the test asserts. - `preflight` is split into env read + `preflightWith(project_dir, override)`, the gate keyed on `materials/` existing AT CALL TIME. These are the only two `assembler_proc.generate` sites in the CLI; run, build, docker, Android and iOS all go through `pipeline.run`'s single gated call, and `--watch` exists only on `wasm serve`. Test: start with no `materials/` → invalid override is not consulted and generation is reached (`GenerateFailed` from a bogus assembler path); create `materials/` → next rebuild stops at `ShaderPreflightFailed`. Force-fail against the gate removed: "expected ShaderPreflightFailed, found GenerateFailed", 881/887; restored. zig build test --summary all: 882/887 passed (5 skipped), exit 0. Co-authored-by: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XBGRLojiysEr5zy1U5mbP6 --- src/cli/material_toolchain.zig | 35 ++++++++++++ src/cli/pipeline.zig | 99 +++++++++++++++++++++++++++++++--- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/cli/material_toolchain.zig b/src/cli/material_toolchain.zig index 11283f5b..63adf052 100644 --- a/src/cli/material_toolchain.zig +++ b/src/cli/material_toolchain.zig @@ -30,6 +30,12 @@ pub fn validateOverride(path: []const u8) !void { } } +/// Read `LABELLE_SHADERC` and gate on it. Called by BOTH the cold pipeline +/// (`pipeline.run`, before `assembler generate`) and every watched rebuild +/// (`WasmRebuildCtx.rebuild`), so a `materials/` directory that appears +/// only after `wasm serve --watch` started is gated exactly like a cold +/// build — the first rebuild that would consume the override is the one +/// that validates it. pub fn preflight(a: std.mem.Allocator, project_dir: []const u8) !void { const path = config.globalEnviron().getAlloc(a, "LABELLE_SHADERC") catch |err| switch (err) { // No override configured: the generated graph builds the pinned @@ -39,6 +45,15 @@ pub fn preflight(a: std.mem.Allocator, project_dir: []const u8) !void { else => return err, }; defer a.free(path); + try preflightWith(a, project_dir, path); +} + +/// The gate itself, with the override value supplied by the caller: a +/// project WITHOUT `materials/` has nothing that will consume the override, +/// so it passes; a project WITH one must carry a valid override or stop +/// here, before generation. `null` (no override) always passes. +pub fn preflightWith(a: std.mem.Allocator, project_dir: []const u8, override: ?[]const u8) !void { + const path = override orelse return; const materials = try std.fs.path.join(a, &.{ project_dir, "materials" }); defer a.free(materials); var dir = std.Io.Dir.cwd().openDir(config.globalIo(), materials, .{}) catch |err| switch (err) { @@ -69,3 +84,23 @@ test "shader tool override rejects a relative, missing or non-file value" { // the check firing rather than the check always failing. try validateOverride("/bin/sh"); } + +test "shader tool override gate is keyed on materials/ existing at call time, not at startup" { + if (@import("builtin").os.tag == .windows) return error.SkipZigTest; + const a = std.testing.allocator; + const io = config.globalIo(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(io, "project"); + const project = try tmp.dir.realPathFileAlloc(io, "project", a); + defer a.free(project); + // Cold start without materials/: an invalid override is NOT consulted. + try preflightWith(a, project, "shaderc"); + // materials/ appears later (an edit, a prebuild hook): the SAME call + // now rejects the same value. This is the property the watched + // rebuild relies on when it re-invokes the preflight. + try tmp.dir.createDirPath(io, "project/materials"); + try std.testing.expectError(error.ShadercOverrideMustBeAbsolute, preflightWith(a, project, "shaderc")); + // No override configured: never gated, materials/ or not. + try preflightWith(a, project, null); +} diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index a7042921..4a9f90bd 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -64,6 +64,24 @@ const WasmRebuildCtx = struct { /// like a failing `generate` or `zig build` already does — not exit /// the process out from under the serve loop. prebuild_opts: prebuild.Options, + /// The shader-compiler override gate, run AFTER the prebuild hooks and + /// BEFORE generation on every rebuild — the same function the cold + /// pipeline runs before its `assembler generate`. It is a field only so + /// the stage test below can supply the override value without touching + /// the process environment; production never overrides the default. + shader_preflight: *const fn (std.mem.Allocator, []const u8) anyerror!void = material_toolchain.preflight, + + /// Which stage a rebuild stopped at. Ordered as the stages run; the + /// watch loop only needs the bool, but the test asserts the ORDER — + /// that the shader preflight fires before generation is attempted. + const Stage = error{ + PrebuildFailed, + ShaderPreflightFailed, + GenerateFailed, + FingerprintFailed, + ZigSpawnFailed, + BuildFailed, + }; /// Re-run prebuild → generate → fixFingerprints → `zig build`. Returns /// true only on a clean rebuild; on any failure it prints the error @@ -71,6 +89,11 @@ const WasmRebuildCtx = struct { /// reloaded onto a broken build. fn rebuild(ctx_ptr: *anyopaque) bool { const self: *WasmRebuildCtx = @ptrCast(@alignCast(ctx_ptr)); + self.rebuildStaged() catch return false; + return true; + } + + fn rebuildStaged(self: *WasmRebuildCtx) Stage!void { const a = self.allocator; // 0. Re-run the declared prebuild steps, ahead of generation just @@ -87,40 +110,102 @@ const WasmRebuildCtx = struct { // `--watch`. prebuild.runAll(a, self.project_dir, self.prebuild_steps, self.prebuild_opts) catch |err| { std.debug.print("labelle: rebuild prebuild step failed ({s})\n", .{@errorName(err)}); - return false; + return error.PrebuildFailed; + }; + + // 0b. Gate the shader-compiler override AFTER the hooks (a hook may + // be what creates `materials/`) and BEFORE generation. The cold + // pipeline runs this once at startup; a project that gains + // `materials/` while being watched would otherwise skip it and + // hit the opaque compiler failure this gate exists to replace. + self.shader_preflight(a, self.project_dir) catch |err| { + std.debug.print("labelle: rebuild stopped before generate: shader compiler override rejected ({s})\n", .{@errorName(err)}); + return error.ShaderPreflightFailed; }; // 1. Regenerate — scene/prefab/script *structure* (new files, added // components) can change, not just @embedFile'd content. assembler_proc.generate(self.asm_bin, a, self.project_dir, self.platform_tag, self.backend_tag) catch |err| { std.debug.print("labelle: rebuild generate failed ({s})\n", .{@errorName(err)}); - return false; + return error.GenerateFailed; }; // 2. `generate` rewrites build.zig with a placeholder fingerprint; // re-fix it before building. runner.fixFingerprints(a, self.project_dir, self.output_dir) catch |err| { std.debug.print("labelle: rebuild fingerprint fix failed ({s})\n", .{@errorName(err)}); - return false; + return error.FingerprintFailed; }; // 3. Rebuild the WASM bundle (captured output so a compile error // surfaces in the terminal without killing the serve loop). const res = runner.runZigWithEnv(a, self.target_dir, self.zig_args, self.zig_env) catch |err| { std.debug.print("labelle: rebuild could not spawn zig ({s})\n", .{@errorName(err)}); - return false; + return error.ZigSpawnFailed; }; defer a.free(res.stdout); defer a.free(res.stderr); switch (res.term) { .exited => |code| if (code != 0) { std.debug.print("labelle: rebuild failed:\n{s}\n", .{res.stderr}); - return false; + return error.BuildFailed; }, else => { std.debug.print("labelle: rebuild terminated abnormally\n{s}\n", .{res.stderr}); - return false; + return error.BuildFailed; }, } - return true; + } + + test "watched rebuild gates the shader override after hooks and before generate" { + if (@import("builtin").os.tag == .windows) return error.SkipZigTest; + const a = std.testing.allocator; + const io = config.globalIo(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(io, "project"); + const project = try tmp.dir.realPathFileAlloc(io, "project", a); + defer a.free(project); + + // Production wiring: the default IS the cold pipeline's preflight, + // not a copy of it. + const default_gate = std.meta.fieldInfo(WasmRebuildCtx, .shader_preflight).defaultValue() orelse return error.TestUnexpectedResult; + try std.testing.expect(default_gate == material_toolchain.preflight); + + const Fixture = struct { + fn invalidOverride(alloc: std.mem.Allocator, dir: []const u8) anyerror!void { + // The same gate `preflight` reaches, with the env read + // replaced by a known-bad value. + return material_toolchain.preflightWith(alloc, dir, "shaderc"); + } + }; + // No assembler exists at this path, so reaching generation is + // observable as GenerateFailed — distinct from the gate firing. + const asm_path = try a.dupe(u8, "/nonexistent/labelle-assembler-probe"); + defer a.free(asm_path); + var ctx = WasmRebuildCtx{ + .allocator = a, + .asm_bin = .{ .path = asm_path }, + .project_dir = project, + .platform_tag = "wasm", + .backend_tag = "bgfx", + .output_dir = project, + .target_dir = project, + .zig_args = &.{}, + .zig_env = null, + .prebuild_steps = &.{}, + .prebuild_opts = .{ .fatal_on_step_failure = false }, + .shader_preflight = Fixture.invalidOverride, + }; + + // Started WITHOUT materials/: the invalid override is not consulted + // and the rebuild proceeds to generation (which fails for its own + // reason here). This is the cold-start shape that skipped the gate. + try std.testing.expectError(error.GenerateFailed, ctx.rebuildStaged()); + + // materials/ appears while watched: the NEXT rebuild must stop at + // the preflight, before generation is attempted. + try tmp.dir.createDirPath(io, "project/materials"); + try std.testing.expectError(error.ShaderPreflightFailed, ctx.rebuildStaged()); + try std.testing.expect(!rebuild(@ptrCast(&ctx))); } };