From 204a9df150bbfb5c88ba25233708d70193291e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calva=CC=83o?= Date: Sat, 26 Sep 2026 16:45:33 -0300 Subject: [PATCH] feat(astc): read texture capabilities from backend manifests --- build.zig | 2 + docs/provider-targets.md | 3 +- docs/texture-capabilities.md | 44 +++++++++ src/agnostic_guard_test.zig | 1 - src/astc/capabilities.zig | 184 +++++++++++++++++++++++++++++++++++ src/astc/cmd.zig | 174 ++++++++------------------------- src/astc/convert.zig | 86 +++------------- src/cli/pipeline.zig | 12 ++- 8 files changed, 293 insertions(+), 213 deletions(-) create mode 100644 docs/texture-capabilities.md create mode 100644 src/astc/capabilities.zig diff --git a/build.zig b/build.zig index 8a4206a7..15b27037 100644 --- a/build.zig +++ b/build.zig @@ -99,6 +99,8 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_provider_tests.step); const provider_step = b.step("test-provider-contract", "Validate provider contract v1"); provider_step.dependOn(&run_provider_tests.step); + const capability_tests = b.addTest(.{ .root_module = cli_tests.root_module, .filters = &.{"texture capabilities"} }); + b.step("test-texture-caps", "Validate texture capability resolution").dependOn(&b.addRunArtifact(capability_tests).step); const material_tests = b.addTest(.{ .root_module = cli_tests.root_module, .filters = &.{"shader tool override"} }); const dispatch_tests = b.addTest(.{ .root_module = cli_tests.root_module, .filters = &.{"provider "} }); b.step("test-provider-dispatch", "Validate provider manifest and dispatch rules").dependOn(&b.addRunArtifact(dispatch_tests).step); diff --git a/docs/provider-targets.md b/docs/provider-targets.md index 5b894edb..2cc62a6a 100644 --- a/docs/provider-targets.md +++ b/docs/provider-targets.md @@ -127,7 +127,8 @@ explicit line: With one, the provider generates; the core steps it does not replace (`build`, `run`) treat the target as the generic host baseline. The ASTC prepass is not among them: it keys on the requested target, and a target - outside the enum has no capability table (`labelle astc` refuses the name), + outside the enum has no `asset_compression` setting (standalone `labelle astc` + can read that target's backend manifest declaration), so the prepass is skipped rather than run with the derived `desktop` — a provider owns its target's asset pipeline. diff --git a/docs/texture-capabilities.md b/docs/texture-capabilities.md new file mode 100644 index 00000000..4a93f729 --- /dev/null +++ b/docs/texture-capabilities.md @@ -0,0 +1,44 @@ +# Backend texture capabilities + +`labelle astc` reads the pinned `.backend_package`'s `backend.manifest.zon`. +The CLI does not infer upload support from backend names or release versions. +The build pipeline installs dependencies before conversion. For a standalone +`labelle astc` on a cold cache, run `labelle install` first. + +```zig +.texture_caps = .{ + .wasm = .{ + .astc = .{ + .blocks = .{ .@"4x4", .@"8x8" }, + .default_block = .@"8x8", + }, + .fallback = .png, + }, +}, +``` + +Target keys are the exact names supplied by the build pipeline or `--platform`. +There is no target-name table or implicit aliasing: a backend serving both +`wasm` and `web` declares both. Unknown unrelated root fields are ignored; +the texture declarations themselves are strict. Blocks must be supported ASTC +sizes, unique and nonempty, and `default_block` must be a member of `blocks`. +The optional `fallback` is `png` or `none` (default `none`). This records the +runtime fallback contract for assembler consumers; this change does not move +the assembler's existing PNG-fallback generation (assembler #378). + +A missing package declaration, missing manifest/texture section, or undeclared +target uses the conservative 4x4-only default. A missing package directory or +malformed declaration is an error, never a silent fallback. The command logs +`manifest` or `conservative_default` so its decision is visible. An explicit +`--backend` switch away from the project's backend cannot reuse the previous +backend's package metadata and therefore uses the conservative default. + +Selection precedence remains `--block`, then the atlas's `.astc_block`, then +the backend default. Unsupported explicit flags fail; unsupported atlas pins +warn and use the backend default. Existing sibling headers are checked before +reuse, so a capability/default change re-encodes stale blocks. + +Older backend releases without this extension safely use 4x4. To retain an +8x8 default, pin a backend release carrying the declaration. In particular, +the companion bgfx manifest declares 4x4/8x8 with an 8x8 default and web PNG +fallback. It deliberately does not advertise unverified intermediate blocks. diff --git a/src/agnostic_guard_test.zig b/src/agnostic_guard_test.zig index ff976a59..efa01206 100644 --- a/src/agnostic_guard_test.zig +++ b/src/agnostic_guard_test.zig @@ -79,7 +79,6 @@ const allowed_files = [_][]const u8{ "agnostic_guard_test.zig", // Legacy platform, store, package and backend sites (RFC #406 "Migration"). "astc/cmd.zig", - "astc/convert.zig", "cli.zig", "cli/add.zig", "cli/android.zig", diff --git a/src/astc/capabilities.zig b/src/astc/capabilities.zig new file mode 100644 index 00000000..04e00dc5 --- /dev/null +++ b/src/astc/capabilities.zig @@ -0,0 +1,184 @@ +//! Read target-specific texture data from the pinned backend package. +const std = @import("std"); +const convert = @import("convert.zig"); +const config = @import("../cli/config.zig"); +const project = @import("../cli/project_config.zig"); +const plugins = @import("../cli/plugins.zig"); + +pub const Selection = struct { + caps: convert.BackendCaps = .{}, + source: enum { manifest, conservative_default } = .conservative_default, + fallback: enum { png, none } = .none, +}; +const Target = struct { + astc: struct { + blocks: []const convert.BlockSize, + default_block: convert.BlockSize, + }, + fallback: @FieldType(Selection, "fallback") = .none, +}; + +/// Unknown root fields belong to other manifest consumers. The texture section +/// itself is strict, including declarations for targets other than this run. +pub fn parse(gpa: std.mem.Allocator, bytes: []const u8, target: []const u8) !Selection { + // ZON diagnostics may allocate on parse failures; all parsed data is + // temporary because Selection owns only value types. + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const source = try a.dupeZ(u8, bytes); + defer a.free(source); + var ast = try std.zig.Ast.parse(a, source, .zon); + defer ast.deinit(a); + if (ast.errors.len != 0) return error.InvalidTextureCapabilities; + var zoir = try std.zig.ZonGen.generate(a, ast, .{ .parse_str_lits = false }); + defer zoir.deinit(a); + if (zoir.hasCompileErrors()) return error.InvalidTextureCapabilities; + const root = switch (std.zig.Zoir.Node.Index.root.get(zoir)) { + .struct_literal => |fields| fields, + .empty_literal => return .{}, + else => return error.InvalidTextureCapabilities, + }; + var result: Selection = .{}; + var seen = false; + for (root.names, 0..) |name, i| { + if (!std.mem.eql(u8, name.get(zoir), "texture_caps")) continue; + if (seen) return error.InvalidTextureCapabilities; + seen = true; + const targets = switch (root.vals.at(@intCast(i)).get(zoir)) { + .struct_literal => |fields| fields, + .empty_literal => return result, + else => return error.InvalidTextureCapabilities, + }; + for (targets.names, 0..) |key, j| { + const target_name = key.get(zoir); + for (targets.names[0..j]) |previous| { + if (std.mem.eql(u8, previous.get(zoir), target_name)) return error.InvalidTextureCapabilities; + } + const value = std.zon.parse.fromZoirNodeAlloc(Target, a, ast, zoir, targets.vals.at(@intCast(j)), null, .{}) catch |err| { + if (err == error.OutOfMemory) return err; + return error.InvalidTextureCapabilities; + }; + defer std.zon.parse.free(a, value); + var caps: convert.BackendCaps = .{ .blocks = .initEmpty(), .default_block = value.astc.default_block }; + for (value.astc.blocks) |block| { + if (caps.blocks.contains(block)) return error.InvalidTextureCapabilities; + caps.blocks.insert(block); + } + if (!caps.supports(caps.default_block)) return error.InvalidTextureCapabilities; + if (std.mem.eql(u8, target, target_name)) result = .{ .caps = caps, .source = .manifest, .fallback = value.fallback }; + } + } + return result; +} + +/// Packages without the extension retain the conservative default. A missing +/// package is different from an old manifest: ask for install rather than let +/// a cold cache silently change the selected block size. +pub fn resolve(a: std.mem.Allocator, project_dir: []const u8, package: ?project.PluginDep, target: []const u8) !Selection { + const dep = package orelse return .{}; + const root = try plugins.resolvePluginDir(a, project_dir, dep); + defer a.free(root); + var dir = std.Io.Dir.cwd().openDir(config.globalIo(), root, .{}) catch { + std.debug.print("labelle astc: backend package '{s}' is unavailable at '{s}'; run labelle install first\n", .{ dep.name, root }); + return error.InvalidTextureCapabilities; + }; + defer dir.close(config.globalIo()); + const bytes = dir.readFileAlloc(config.globalIo(), "backend.manifest.zon", a, .limited(1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => return .{}, + error.OutOfMemory => return err, + else => { + std.debug.print("labelle astc: cannot read '{s}/backend.manifest.zon': {s}\n", .{ root, @errorName(err) }); + return error.InvalidTextureCapabilities; + }, + }; + defer a.free(bytes); + return parse(a, bytes, target) catch |err| { + std.debug.print("labelle astc: invalid texture_caps in '{s}/backend.manifest.zon': {s}\n", .{ root, @errorName(err) }); + return err; + }; +} + +test "texture capabilities distinguish manifest and conservative paths even for identical blocks" { + const a = std.testing.allocator; + const bytes = ".{ .other_runtime_field = true, .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\" }, .default_block = .@\"4x4\" }, .fallback = .png } } }"; + const declared = try parse(a, bytes, "custom_target"); + const fallback = try parse(a, bytes, "missing_target"); + try std.testing.expectEqual(.manifest, declared.source); + try std.testing.expectEqual(.conservative_default, fallback.source); + try std.testing.expectEqual(declared.caps.defaultBlock(), fallback.caps.defaultBlock()); + try std.testing.expectEqual(.conservative_default, (try parse(a, ".{ .unrelated = .{} }", "custom_target")).source); +} + +test "texture capabilities honor per-target defaults without backend identity" { + const a = std.testing.allocator; + const bytes = ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\", .@\"8x8\" }, .default_block = .@\"8x8\" } }, .other_target = .{ .astc = .{ .blocks = .{ .@\"6x6\" }, .default_block = .@\"6x6\" }, .fallback = .none } } }"; + const wide = try parse(a, bytes, "custom_target"); + try std.testing.expectEqual(.manifest, wide.source); + try std.testing.expectEqual(convert.BlockSize.@"8x8", wide.caps.defaultBlock()); + try std.testing.expect(!wide.caps.supports(.@"6x6")); + const other = try parse(a, bytes, "other_target"); + try std.testing.expectEqual(convert.BlockSize.@"6x6", other.caps.defaultBlock()); + try std.testing.expectEqual(.none, other.fallback); +} + +test "texture capabilities reject malformed declarations rather than silently defaulting" { + const a = std.testing.allocator; + for ([_][]const u8{ + ".{ .texture_caps = true }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\" }, .default_block = .@\"8x8\" } } } }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{}, .default_block = .@\"4x4\" } } } }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\", .@\"4x4\" }, .default_block = .@\"4x4\" } } } }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"7x7\" }, .default_block = .@\"4x4\" } } } }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\" }, .default_block = .@\"4x4\", .typo = true } } } }", + ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\" }, .default_block = .@\"4x4\" }, .typo = true } } }", + }) |bytes| try std.testing.expectError(error.InvalidTextureCapabilities, parse(a, bytes, "custom_target")); +} + +const fixture_wide = ".{ .texture_caps = .{ .custom_target = .{ .astc = .{ .blocks = .{ .@\"4x4\", .@\"8x8\" }, .default_block = .@\"8x8\" }, .fallback = .png } } }"; + +test "texture capabilities resolve local package declarations and legacy absence" { + const a = std.testing.allocator; + const io = config.globalIo(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(root); + try tmp.dir.createDirPath(io, "backend"); + const dep: project.PluginDep = .{ .name = "custom", .repo = "local:backend" }; + try std.testing.expectEqual(.conservative_default, (try resolve(a, root, dep, "custom_target")).source); + try tmp.dir.writeFile(io, .{ .sub_path = "backend/backend.manifest.zon", .data = fixture_wide }); + const selected = try resolve(a, root, dep, "custom_target"); + try std.testing.expectEqual(.manifest, selected.source); + try std.testing.expectEqual(convert.BlockSize.@"8x8", selected.caps.defaultBlock()); + try tmp.dir.writeFile(io, .{ .sub_path = "backend/backend.manifest.zon", .data = ".{ .texture_caps = false }" }); + try std.testing.expectError(error.InvalidTextureCapabilities, resolve(a, root, dep, "custom_target")); + try std.testing.expectError(error.InvalidTextureCapabilities, resolve(a, root, .{ .name = "missing", .repo = "local:missing" }, "custom_target")); + try std.testing.expectEqual(.conservative_default, (try resolve(a, root, null, "custom_target")).source); +} + +test "texture capabilities resolve exactly the remote version pin" { + const cache = @import("../cli/asm_cache.zig"); + const a = std.testing.allocator; + const io = config.globalIo(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(root); + cache.setCacheRootOverride(root); + defer cache.clearCacheRootOverride(); + const old = "packages/plugins/github.com/example/custom/old"; + const new = "packages/plugins/github.com/example/custom/new"; + try tmp.dir.createDirPath(io, old); + try tmp.dir.createDirPath(io, new); + try tmp.dir.writeFile(io, .{ .sub_path = old ++ "/backend.manifest.zon", .data = ".{}" }); + try tmp.dir.writeFile(io, .{ .sub_path = new ++ "/backend.manifest.zon", .data = fixture_wide }); + const dep: project.PluginDep = .{ .name = "custom", .repo = "github.com/example/custom", .version = "new" }; + const selected = try resolve(a, root, dep, "custom_target"); + try std.testing.expectEqual(.manifest, selected.source); + var earlier = dep; + earlier.version = "old"; + try std.testing.expectEqual(.conservative_default, (try resolve(a, root, earlier, "custom_target")).source); + earlier.version = "not-installed"; + try std.testing.expectError(error.InvalidTextureCapabilities, resolve(a, root, earlier, "custom_target")); +} diff --git a/src/astc/cmd.zig b/src/astc/cmd.zig index 020126e6..7177c4f6 100644 --- a/src/astc/cmd.zig +++ b/src/astc/cmd.zig @@ -15,21 +15,21 @@ const util = @import("../cli/util.zig"); const lockfile = @import("../cli/lockfile.zig"); const contract = @import("../cli/provider_contract.zig"); const convert = @import("convert.zig"); +const capabilities = @import("capabilities.zig"); const astcenc_bin = @import("astcenc_bin.zig"); const usage = \\Usage: labelle astc [dir] [--block <4x4|6x6|8x8|...>] [--quality ] \\ \\Converts each atlas texture declared in project.labelle to a co-located - \\.astc (GPU-native, zero runtime decode). Default block 8x8, quality fast. + \\.astc (GPU-native, zero runtime decode). Default block comes from backend metadata; quality fast. \\ \\An individual atlas can pin its own block in project.labelle \\(`.astc_block = .@"4x4"` on the resource); an explicit --block here \\overrides every such pin. \\ \\ --platform target name (the resolved target, as the build pipeline - \\ passes it; `desktop` or a legacy schema platform — other - \\ targets have no ASTC table yet); default is + \\ passes it); default is \\ project.labelle's `.platform`. \\ --backend target backend; default is project.labelle's. \\ --allow-older-cli proceed even when labelle.lock was written by a @@ -71,7 +71,7 @@ fn existingBlockMatches(out: []const u8, block: convert.BlockSize) bool { /// Whether the `.astc` sibling at `out` can be kept as-is: newer than its /// source AND encoded at `block`. The block check is what keeps a sibling /// shared across platforms honest — an 8x8 file an Android build left behind -/// is NOT current for a bgfx web build that needs 4x4 (labelle-bgfx#134), so +/// is NOT current for a target that needs 4x4, so /// switching platforms re-encodes rather than shipping an unloadable atlas. fn siblingIsCurrent(src: []const u8, out: []const u8, block: convert.BlockSize) bool { return !convert.needsReencode(Stat, src, out) and existingBlockMatches(out, block); @@ -112,7 +112,7 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { var dir: []const u8 = "."; var opts = convert.Options{}; // Whether the user pinned `--block` explicitly. When they didn't, we pick a - // backend-safe default below (sokol can only load 4×4); when they did, we + // backend-declared default below; when they did, we // validate it against the backend instead of silently emitting an // unloadable file. var block_explicit = false; @@ -123,9 +123,7 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { // Target overrides: `labelle build --platform=wasm` resolves a platform // (and backend) that project.labelle may not declare, and the loadable // blocks depend on both — so the pipeline passes what it resolved. The - // target is a NAME (RFC #406 phase 3b, `provider_targets.zig`); the - // legacy capability table below still keys on the schema enum, so a - // target outside it has no table yet (labelle-assembler#378). + // target is a NAME, matched against the backend manifest's target keys. var platform_override: ?[]const u8 = null; var backend_override: ?project_config.Backend = null; @@ -148,7 +146,7 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { platform_override = cmd_args[i]; } else if (std.mem.eql(u8, arg, "--backend")) { i += 1; - if (i >= cmd_args.len) return usageErr("--backend needs a value (e.g. bgfx)"); + if (i >= cmd_args.len) return usageErr("--backend needs a value"); backend_override = std.meta.stringToEnum(project_config.Backend, cmd_args[i]) orelse return usageErr("unknown --backend"); } else if (std.mem.eql(u8, arg, "--allow-older-cli")) { allow_older_cli = true; @@ -174,25 +172,20 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { return error.InvalidArgs; }; - // The target backend constrains which block sizes are loadable at runtime. - // sokol ships ASTC 4×4 only — an 8×8 atlas parses but fails to upload - // (error.LoadFailed), which on FP left the game stuck on the loading scene. - // Default to a backend-safe block when the user didn't pin one; reject an - // explicit block the backend can't load rather than baking a dud. const backend = backend_override orelse cfg.backend; - const platform = if (platform_override) |name| - std.meta.stringToEnum(project_config.Platform, name) orelse { - std.debug.print("labelle astc: target '{s}' has no ASTC capability table yet (labelle-assembler#378)\n", .{name}); - return error.InvalidArgs; - } - else - cfg.platform; - const caps = backendCaps(backend, platform, bgfxWebSamples8x8(cfg.backend_package)); + const platform = platform_override orelse @tagName(cfg.platform); + // An explicit backend switch cannot inherit another package's capabilities. + const package = if (backend == cfg.backend) cfg.backend_package else null; + const selection = try capabilities.resolve(allocator, dir, package, platform); + const caps = selection.caps; + std.debug.print("labelle astc: {s} capabilities for {s}, default {s}\n", .{ + @tagName(selection.source), platform, caps.defaultBlock().arg(), + }); if (block_explicit) { if (!caps.supports(opts.block)) { std.debug.print( "labelle astc: backend '{s}' on {s} cannot upload ASTC {s} (try {s})\n", - .{ @tagName(backend), @tagName(platform), opts.block.arg(), caps.defaultBlock().arg() }, + .{ @tagName(backend), platform, opts.block.arg(), caps.defaultBlock().arg() }, ); return error.InvalidArgs; } @@ -282,42 +275,6 @@ pub fn cmdAstc(gpa: std.mem.Allocator, cmd_args: []const []const u8) !void { if (tally.failed > 0) return error.AstcConversionFailed; } -/// First labelle-bgfx that samples ASTC 8x8 on WebGL (zbgfx#2 lists the full -/// LDR set in bgfx's emscripten format table, labelle-bgfx#147). -const bgfx_web_8x8_min: std.SemanticVersion = .{ .major = 0, .minor = 28, .patch = 1 }; - -/// Can the project's pinned bgfx sample ASTC 8x8 on the web? Yes for a pin -/// at or after `bgfx_web_8x8_min`, and for a `local:` checkout (the -/// developer's own current source). No for an older pin, an unparseable -/// version, or no `backend_package` at all (the assembler's built-in bgfx -/// provider pins an old backend). An older bgfx REFUSES 8x8 on the web, and -/// an assembler before 0.113.0 has no PNG fallback, so emitting 8x8 there -/// would leave atlases unloaded (Codex review on #404). -fn bgfxWebSamples8x8(pkg: ?project_config.PluginDep) bool { - const p = pkg orelse return false; - if (p.isLocal()) return true; - const v = std.SemanticVersion.parse(p.version) catch return false; - return v.order(bgfx_web_8x8_min) != .lt; -} - -/// Which ASTC blocks `backend` can load on `platform`, see `BackendCaps`. -/// `bgfx_web_8x8` is `bgfxWebSamples8x8` for the project's pinned bgfx. -fn backendCaps(backend: project_config.Backend, platform: project_config.Platform, bgfx_web_8x8: bool) convert.BackendCaps { - return switch (backend) { - .sokol => .sokol_4x4_only, - .raylib => .raylib_4x4_8x8, - // bgfx uploads any block it can name and validates none of them, so - // an unsupported one renders garbage with no error at all, verified - // on device with 6x6. See `BackendCaps.bgfx_4x4_8x8`. The web shares - // it from bgfx v0.28.1; older bgfx on the web is 4x4-only (#147). - .bgfx => if (platform == .wasm and !bgfx_web_8x8) .bgfx_web_4x4_only else .bgfx_4x4_8x8, - .wgpu => .full, - // sdl/null aren't ASTC upload targets; the gfx seam falls back to PNG - // decode if they ever see a compressed blob, so leave block unconstrained. - .sdl, .null => .full, - }; -} - fn resolveAstcenc(allocator: std.mem.Allocator) ![]const u8 { const cache_root = try asm_cache.getCacheRoot(allocator); defer allocator.free(cache_root); @@ -875,7 +832,7 @@ fn atlasPinning(block: ?convert.BlockSize) project_config.ResourceDef { test "resourceOpts: an atlas without a pin keeps the run-wide block" { const base = convert.Options{ .block = .@"8x8" }; - const opts = resourceOpts(atlasPinning(null), base, false, .full); + const opts = resourceOpts(atlasPinning(null), base, false, test_full_caps); try std.testing.expectEqual(convert.BlockSize.@"8x8", opts.block); } @@ -883,28 +840,28 @@ test "resourceOpts: a pinned block wins over the backend default" { // The whole point of the knob: characters at 4x4 while the rest of // the project stays on the 8x8 default. const base = convert.Options{ .block = .@"8x8" }; - const opts = resourceOpts(atlasPinning(.@"4x4"), base, false, .full); + const opts = resourceOpts(atlasPinning(.@"4x4"), base, false, test_full_caps); try std.testing.expectEqual(convert.BlockSize.@"4x4", opts.block); } test "resourceOpts: an explicit --block overrides every pin" { const base = convert.Options{ .block = .@"6x6" }; - const opts = resourceOpts(atlasPinning(.@"4x4"), base, true, .full); + const opts = resourceOpts(atlasPinning(.@"4x4"), base, true, test_full_caps); try std.testing.expectEqual(convert.BlockSize.@"6x6", opts.block); } test "resourceOpts: a pin the backend cannot upload degrades to the default" { - // sokol loads 4x4 only. A pin it can't upload must NOT be baked: the + // This declaration allows 4x4 only. A pin it can't upload must NOT be baked: the // atlas would parse and then fail to upload, stranding the game on // the loading scene. Fall back rather than ship a dud. const base = convert.Options{ .block = .@"4x4" }; - const opts = resourceOpts(atlasPinning(.@"8x8"), base, false, .sokol_4x4_only); + const opts = resourceOpts(atlasPinning(.@"8x8"), base, false, test_narrow_caps); try std.testing.expectEqual(convert.BlockSize.@"4x4", opts.block); } test "resourceOpts: quality and other options are carried through unchanged" { const base = convert.Options{ .block = .@"8x8", .quality = .thorough }; - const opts = resourceOpts(atlasPinning(.@"4x4"), base, false, .full); + const opts = resourceOpts(atlasPinning(.@"4x4"), base, false, test_full_caps); try std.testing.expectEqual(convert.Quality.thorough, opts.quality); } @@ -1455,12 +1412,8 @@ test "conflictingBlockPin: injected case-sensitive probe keeps case aliases dist try std.testing.expect((try conflictingBlockPinProbe(a, &jobs, probe)) == null); } -test "BackendCaps: bgfx rejects blocks it cannot actually upload" { - // bgfx names every ASTC block in a `TextureFormat` but validates none - // against the runtime, so an unsupported one renders garbage with a clean - // log — measured on an Adreno 610 with 6x6. Only the two blocks verified - // on hardware are accepted. - const caps: convert.BackendCaps = .bgfx_4x4_8x8; +test "resourceOpts rejects undeclared blocks" { + const caps: convert.BackendCaps = test_wide_caps; try std.testing.expect(caps.supports(.@"4x4")); try std.testing.expect(caps.supports(.@"8x8")); try std.testing.expect(!caps.supports(.@"6x6")); @@ -1473,40 +1426,15 @@ test "BackendCaps: bgfx rejects blocks it cannot actually upload" { try std.testing.expectEqual(convert.BlockSize.@"8x8", opts.block); } -test "backendCaps: with a current bgfx, no backend's blocks depend on the platform (bgfx#147)" { - for (std.enums.values(project_config.Platform)) |p| { - // bgfx: the two hardware-verified blocks, on the web too now that - // zbgfx's WebGL table lists 8x8. - try std.testing.expectEqual(convert.BackendCaps.bgfx_4x4_8x8, backendCaps(.bgfx, p, true)); - try std.testing.expectEqual(convert.BackendCaps.sokol_4x4_only, backendCaps(.sokol, p, true)); - try std.testing.expectEqual(convert.BackendCaps.raylib_4x4_8x8, backendCaps(.raylib, p, true)); - try std.testing.expectEqual(convert.BackendCaps.full, backendCaps(.wgpu, p, true)); - try std.testing.expectEqual(convert.BackendCaps.full, backendCaps(.sdl, p, true)); - try std.testing.expectEqual(convert.BackendCaps.full, backendCaps(.null, p, true)); - } -} - -test "resourceOpts: bgfx web honours an 8x8 pin and defaults to 8x8 (bgfx#147)" { - const caps = backendCaps(.bgfx, .wasm, true); - // Web used to be forced to 4x4, which cost 4x the GPU memory and - // download of 8x8 on large soft atlases. It now matches native bgfx. - try std.testing.expectEqual(convert.BlockSize.@"8x8", caps.defaultBlock()); +test "resourceOpts honors declared defaults and per-atlas pins" { + const caps = test_wide_caps; const base = convert.Options{ .block = caps.defaultBlock() }; try std.testing.expectEqual(convert.BlockSize.@"8x8", resourceOpts(atlasPinning(null), base, false, caps).block); try std.testing.expectEqual(convert.BlockSize.@"8x8", resourceOpts(atlasPinning(.@"8x8"), base, false, caps).block); try std.testing.expectEqual(convert.BlockSize.@"4x4", resourceOpts(atlasPinning(.@"4x4"), base, false, caps).block); - // A block bgfx can't load anywhere is still downgraded, not baked. try std.testing.expectEqual(convert.BlockSize.@"8x8", resourceOpts(atlasPinning(.@"6x6"), base, false, caps).block); } -test "resourceOpts: non-wasm bgfx still honours an 8x8 pin and defaults to 8x8" { - const caps = backendCaps(.bgfx, .android, true); - try std.testing.expectEqual(convert.BlockSize.@"8x8", caps.defaultBlock()); - const base = convert.Options{ .block = caps.defaultBlock() }; - try std.testing.expectEqual(convert.BlockSize.@"8x8", resourceOpts(atlasPinning(.@"8x8"), base, false, caps).block); - try std.testing.expectEqual(convert.BlockSize.@"4x4", resourceOpts(atlasPinning(.@"4x4"), base, false, caps).block); -} - /// A minimal `.astc` header (magic + block dims + 1x1x1 image) — all /// `existingBlockMatches` reads. fn fakeAstcHeader(block: convert.BlockSize) [16]u8 { @@ -1539,15 +1467,15 @@ test "an existing sibling at another block is re-encoded, whatever the mtime say // The mtime says fresh — it is the header BLOCK that decides. try std.testing.expect(!convert.needsReencode(Stat, src, out)); - // Same file: current for a build that resolves 8x8 (the bgfx default)... - const bgfx = resourceOpts(atlasPinning(null), .{ .block = backendCaps(.bgfx, .android, true).defaultBlock() }, false, backendCaps(.bgfx, .android, true)); - try std.testing.expectEqual(convert.BlockSize.@"8x8", bgfx.block); - try std.testing.expect(siblingIsCurrent(src, out, bgfx.block)); - // ...but NOT for one that resolves 4x4 (sokol, or a 4x4 pin), which + // Same file: current for a build that resolves 8x8 (the declared default)... + const renderer = resourceOpts(atlasPinning(null), .{ .block = test_wide_caps.defaultBlock() }, false, test_wide_caps); + try std.testing.expectEqual(convert.BlockSize.@"8x8", renderer.block); + try std.testing.expect(siblingIsCurrent(src, out, renderer.block)); + // ...but NOT for one that resolves 4x4 (the conservative default, or a 4x4 pin), which // re-encodes instead of shipping a block it can't load. - const sokol = resourceOpts(atlasPinning(null), .{ .block = backendCaps(.sokol, .android, true).defaultBlock() }, false, backendCaps(.sokol, .android, true)); - try std.testing.expectEqual(convert.BlockSize.@"4x4", sokol.block); - try std.testing.expect(!siblingIsCurrent(src, out, sokol.block)); + const narrow = resourceOpts(atlasPinning(null), .{ .block = test_narrow_caps.defaultBlock() }, false, test_narrow_caps); + try std.testing.expectEqual(convert.BlockSize.@"4x4", narrow.block); + try std.testing.expect(!siblingIsCurrent(src, out, narrow.block)); } test "a failed re-encode deletes the stale sibling (PNG fallback, never a wrong block)" { @@ -1575,7 +1503,7 @@ test "a current sibling survives a failed-encoder run; a stale one does not" { var buf: [std.fs.max_path_bytes]u8 = undefined; const base = try stageSibling(&tmp, &buf, .@"4x4"); - // Cached path: a 4x4 sibling is current for bgfx web, so convertAtlas + // Cached path: a 4x4 sibling is current for this target, so convertAtlas // never runs the (broken) encoder and keeps the file. var tally = Tally{}; try convertAtlas(a, "/nonexistent/labelle-test/astcenc", base, "assets/rooms.png", .{ .block = .@"4x4" }, &tally); @@ -1611,7 +1539,7 @@ test "an undeletable stale sibling is a fatal error, not a silent PNG fallback" try tmp.dir.setFilePermissions(io, "assets", .fromMode(0o555), .{}); defer tmp.dir.setFilePermissions(io, "assets", .fromMode(0o755), .{}) catch {}; - // bgfx web: the 8x8 sibling is stale, astcenc fails, and the delete is + // A target limited to 4x4: the 8x8 sibling is stale, astcenc fails, and the delete is // refused → the error the pipeline turns into a fatal exit. var tally = Tally{}; try std.testing.expectError( @@ -1628,30 +1556,6 @@ test "an undeletable stale sibling is a fatal error, not a silent PNG fallback" try std.testing.expectError(error.StaleAstcSiblingUndeletable, discardIfNotCurrent(a, job)); } -test "bgfx web stays 4x4-only unless the pinned bgfx samples 8x8 there (Codex on #404)" { - // An older pinned bgfx (before 0.28.1) refuses 8x8 on the web. - try std.testing.expectEqual(convert.BackendCaps.bgfx_web_4x4_only, backendCaps(.bgfx, .wasm, false)); - try std.testing.expect(!backendCaps(.bgfx, .wasm, false).supports(.@"8x8")); - // ...but only on the web: native bgfx is unaffected by the web flag. - try std.testing.expectEqual(convert.BackendCaps.bgfx_4x4_8x8, backendCaps(.bgfx, .android, false)); - try std.testing.expectEqual(convert.BackendCaps.bgfx_4x4_8x8, backendCaps(.bgfx, .wasm, true)); -} - -test "bgfxWebSamples8x8 reads the pinned backend_package" { - const dep = struct { - fn at(repo: []const u8, version: []const u8) project_config.PluginDep { - return .{ .name = "bgfx", .repo = repo, .version = version }; - } - }.at; - const gh = "github.com/labelle-toolkit/labelle-bgfx"; - try std.testing.expect(bgfxWebSamples8x8(dep(gh, "0.28.1"))); - try std.testing.expect(bgfxWebSamples8x8(dep(gh, "0.29.0"))); - try std.testing.expect(bgfxWebSamples8x8(dep(gh, "1.0.0"))); - try std.testing.expect(!bgfxWebSamples8x8(dep(gh, "0.28.0"))); - try std.testing.expect(!bgfxWebSamples8x8(dep(gh, "0.27.0"))); - try std.testing.expect(!bgfxWebSamples8x8(dep(gh, "not-a-version"))); - // No backend_package: the built-in provider pins an old bgfx. - try std.testing.expect(!bgfxWebSamples8x8(null)); - // A local checkout is the developer's own current source. - try std.testing.expect(bgfxWebSamples8x8(dep("local:../labelle-bgfx", "0.20.0"))); -} +const test_narrow_caps: convert.BackendCaps = .{}; +const test_wide_caps: convert.BackendCaps = .{ .blocks = .initMany(&.{ .@"4x4", .@"8x8" }), .default_block = .@"8x8" }; +const test_full_caps: convert.BackendCaps = .{ .blocks = .initFull(), .default_block = .@"8x8" }; diff --git a/src/astc/convert.zig b/src/astc/convert.zig index 9e5fe685..3ddfe908 100644 --- a/src/astc/convert.zig +++ b/src/astc/convert.zig @@ -51,57 +51,18 @@ pub const BlockSize = enum { } }; -/// What ASTC block sizes a backend's *runtime* can actually upload. Emitting a -/// block the target can't load produces `.astc` files that fail at load time -/// with `error.LoadFailed` (surfaced live on the FP sokol trial: an 8×8 default -/// left the game stuck on the loading scene). sokol ships ONLY 4×4; raylib -/// ships 4×4 and 8×8; bgfx/wgpu accept the full astcenc range. This mirrors -/// each backend's `astcFormat` mapping in its `gfx/texture.zig`. -pub const BackendCaps = enum { - sokol_4x4_only, - raylib_4x4_8x8, - /// bgfx maps the whole ASTC LDR set to a `TextureFormat`, but mapping is - /// not uploading: it never checks the RUNTIME's format capabilities, so an - /// unsupported block produces no error and renders garbage. Measured on a - /// Samsung SM-T505 (Adreno 610, GLES 3.2): a 6x6 atlas drew every sprite - /// as a cyan/black checkerboard with a completely clean log, while 4x4 and - /// 8x8 in the same build were perfect. Until bgfx fails loudly on a block - /// it cannot sample (labelle-bgfx#76), only the two blocks verified on - /// hardware are offered — a build error beats silent garbage on a player's - /// device. - /// - /// The same two blocks on the web: labelle-toolkit/zbgfx's emscripten - /// format table now lists the full ASTC LDR set, as WebGL's extension - /// provides, so 8x8 samples natively there too (labelle-bgfx#147; before - /// it, web was 4x4-only). A bgfx older than that fix refuses 8x8 on the - /// web, and the web build's startup pick (assembler >= 0.113.0) falls - /// back to the atlas's PNG. - bgfx_4x4_8x8, - /// bgfx on the web, pinned OLDER than v0.28.1: its WebGL format table - /// omits 8x8, so an 8x8 atlas is refused there (labelle-bgfx#147/#76). - /// Only 4x4 is loadable. `labelle astc` picks this from the project's - /// resolved `backend_package` version. - bgfx_web_4x4_only, - full, +/// Backend-declared upload formats. An absent declaration is conservatively +/// limited to 4x4; no backend identity or release history belongs here. +pub const BackendCaps = struct { + blocks: std.enums.EnumSet(BlockSize) = .initOne(.@"4x4"), + default_block: BlockSize = .@"4x4", - /// Can this backend's runtime upload `block` as-is? pub fn supports(self: BackendCaps, block: BlockSize) bool { - return switch (self) { - .sokol_4x4_only, .bgfx_web_4x4_only => block == .@"4x4", - .raylib_4x4_8x8, .bgfx_4x4_8x8 => block == .@"4x4" or block == .@"8x8", - .full => true, - }; + return self.blocks.contains(block); } - /// Block to use when the caller didn't pass `--block`: the smallest GPU - /// footprint (largest block) the backend can still load. sokol can only do - /// 4×4; everyone else gets the 8×8 - /// sprite-atlas default. pub fn defaultBlock(self: BackendCaps) BlockSize { - return switch (self) { - .sokol_4x4_only, .bgfx_web_4x4_only => .@"4x4", - else => .@"8x8", - }; + return self.default_block; } }; @@ -210,32 +171,13 @@ test "BlockSize.dims parses the tag into x/y (matches .astc header bytes 4/5)" { try std.testing.expectEqual(@as(u8, 10), BlockSize.@"10x10".dims().x); } -test "BackendCaps.supports gates blocks by what the runtime can upload" { - // sokol's astcFormat only maps 4×4 — everything else fails sg.makeImage. - try std.testing.expect(BackendCaps.sokol_4x4_only.supports(.@"4x4")); - try std.testing.expect(!BackendCaps.sokol_4x4_only.supports(.@"8x8")); - try std.testing.expect(!BackendCaps.sokol_4x4_only.supports(.@"6x6")); - // raylib loads 4×4 and 8×8, but not the in-between/large sizes. - try std.testing.expect(BackendCaps.raylib_4x4_8x8.supports(.@"4x4")); - try std.testing.expect(BackendCaps.raylib_4x4_8x8.supports(.@"8x8")); - try std.testing.expect(!BackendCaps.raylib_4x4_8x8.supports(.@"6x6")); - try std.testing.expect(!BackendCaps.raylib_4x4_8x8.supports(.@"12x12")); - // wgpu takes the full astcenc range. - try std.testing.expect(BackendCaps.full.supports(.@"4x4")); - try std.testing.expect(BackendCaps.full.supports(.@"12x12")); -} - -test "BackendCaps.defaultBlock picks the smallest footprint each backend can load" { - // sokol must fall back to 4×4 (the blocker this fix exists for). - try std.testing.expectEqual(BlockSize.@"4x4", BackendCaps.sokol_4x4_only.defaultBlock()); - try std.testing.expectEqual(BlockSize.@"8x8", BackendCaps.bgfx_4x4_8x8.defaultBlock()); - // others keep the 8×8 sprite-atlas default, and it must be loadable. - try std.testing.expectEqual(BlockSize.@"8x8", BackendCaps.raylib_4x4_8x8.defaultBlock()); - try std.testing.expectEqual(BlockSize.@"8x8", BackendCaps.full.defaultBlock()); - // a backend's own default is always one it supports. - inline for (std.enums.values(BackendCaps)) |caps| { - try std.testing.expect(caps.supports(caps.defaultBlock())); - } +test "BackendCaps uses declared blocks and defaults conservatively" { + const fallback: BackendCaps = .{}; + try std.testing.expect(fallback.supports(.@"4x4")); + try std.testing.expect(!fallback.supports(.@"8x8")); + const declared: BackendCaps = .{ .blocks = .initMany(&.{ .@"4x4", .@"8x8" }), .default_block = .@"8x8" }; + try std.testing.expect(declared.supports(declared.defaultBlock())); + try std.testing.expect(!declared.supports(.@"6x6")); } test "Quality/ColorSpace map to astcenc flags" { diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index 63224a12..601d0471 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -1730,13 +1730,13 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // like it worked. So a config error stops the build, while a conversion // failure still degrades to PNG. // - // Only for a target the capability tables know (`target.legacy`: + // Only for a target the asset_compression schema knows (`target.legacy`: // `desktop` or a schema-named provider target). A provider target // outside the enum has `parsed.platform` derived as `.desktop` for the // legacy sites, but it is NOT the desktop target: running the desktop - // prepass for it would encode ASTC siblings by desktop capabilities - // (Codex on #421). Its provider owns its asset pipeline; `cmdAstc` - // itself refuses such a name. + // prepass for it would use the wrong compression setting (Codex on + // #421). Its provider owns its asset pipeline; standalone `cmdAstc` + // can read capabilities for any declared target name. if (target.legacy != null and parsed.asset_compression.formatFor(parsed.platform) == .astc) { // Pass the RESOLVED target: `--platform=wasm`, `labelle ios` (forces // sokol) and the Android backend fallback all differ from what @@ -1748,6 +1748,10 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { "--backend", @tagName(parsed.backend), }) catch |err| switch (err) { + error.InvalidTextureCapabilities => progress.fatalExit( + 1, + "cannot resolve backend texture capabilities — see the error above", + ), error.ConflictingAstcBlocks => progress.fatalExit( 1, "conflicting .astc_block pins compile to one .astc — see the error above",