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..1dd06a71 --- /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 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. + +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..63adf052 --- /dev/null +++ b/src/cli/material_toolchain.zig @@ -0,0 +1,106 @@ +//! 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; + } +} + +/// 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 + // compiler itself. Any OTHER failure is real and must not be + // mistaken for "unset". + error.EnvironmentVariableMissing => return, + 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) { + error.FileNotFound => return, + else => return err, + }; + dir.close(config.globalIo()); + 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"); +} + +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 5a890774..4a9f90bd 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"); @@ -63,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 @@ -70,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 @@ -86,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))); } }; @@ -605,6 +691,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,