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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/shader-materials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Building game-owned shaders

The assembler discovers `materials/<name>/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.
106 changes: 106 additions & 0 deletions src/cli/material_toolchain.zig
Original file line number Diff line number Diff line change
@@ -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);
}
101 changes: 94 additions & 7 deletions src/cli/pipeline.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -63,13 +64,36 @@ 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
/// (keeping the server alive) and returns false so the browser is NOT
/// 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
Expand All @@ -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)));
}
};

Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the shader compiler override into Docker

When labelle build --docker is run with LABELLE_SHADERC, this preflight validates the path in the host filesystem, but docker.runBuild starts the Ubuntu container without -e LABELLE_SHADERC or a corresponding mount. Consequently, an override path intended to exist inside the container is rejected on the host, while a host-valid path passes but is invisible to the generated build graph, so the documented Docker override cannot work. The Docker path needs container-aware validation and must forward/mount the override.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the override preflight before package installation

When a project already has materials and LABELLE_SHADERC is invalid, this supposedly early validation runs only after prebuild hooks, assembler resolution, ASTC conversion, and asm_bin.run("install", ...). On a fresh or offline machine those earlier operations can download packages, modify generated assets, or fail first, so the user may never receive the new shader-override diagnostic despite the configuration being invalid from the outset. Run this check after any hook that may create the materials tree but before assembler/toolchain resolution and package installation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-run the override preflight in watched builds

When labelle wasm serve --watch starts without a materials/ directory and a later edit or prebuild hook creates it, this initial preflight is skipped and WasmRebuildCtx.rebuild proceeds directly from the hook to generation without calling it. With an invalid LABELLE_SHADERC, the watched rebuild therefore reaches the opaque shader compiler/build failure that this validation is intended to replace; invoke the preflight in the rebuild callback after its prebuild hooks as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 24b4b5d — this was a real hole in the fail-closed guarantee.

Fix. WasmRebuildCtx.rebuild now calls material_toolchain.preflight (the same function the cold pipeline calls before its assembler generate — it is the struct field's default, not a copy) after its prebuild hooks and before generation. The rebuild body is now a staged rebuildStaged() Stage!void (PrebuildFailed → ShaderPreflightFailed → GenerateFailed → …) with rebuild reduced to the bool wrapper the serve loop expects, so the stage a rebuild stops at is observable.

Other rebuild/incremental paths. There are exactly two assembler_proc.generate call sites in the CLI: the cold one in pipeline.run (gated since 200e53a) and this rebuild callback (gated now). labelle run, labelle build, the docker, Android and iOS flows all reach generation through pipeline.run's single call; --watch exists only on wasm serve. No other loop can reach the shader pipeline.

Test asserts the mechanism, not a value. cli.pipeline.WasmRebuildCtx.test.watched rebuild gates the shader override after hooks and before generate: a tmp project with no materials/, an invalid override (shaderc), a bogus assembler path. First rebuild → error.GenerateFailed (the gate correctly stays silent and generation is reached). Then materials/ is created — the next rebuild → error.ShaderPreflightFailed, i.e. stopped before generation. A companion test in material_toolchain covers the gate being keyed on materials/ at call time rather than at startup.

Force-fail proof (gate call removed, test run, restored):

error: 'cli.pipeline.WasmRebuildCtx.test.watched rebuild gates the shader override after hooks and before generate' failed:
       expected error.ShaderPreflightFailed, found error.GenerateFailed
Build Summary: 4/6 steps succeeded (1 failed); 881/887 tests passed (5 skipped, 1 failed)

Green: zig build exit 0; zig build test --summary all 882/887 passed, 5 skipped, exit 0 (baseline 880/885; +2 are the two new tests). zig fmt --check clean on both touched files.

try assembler_proc.generate(
asm_bin,
allocator,
Expand Down
Loading