diff --git a/build.zig b/build.zig index 469a82c8..fd3f65b9 100644 --- a/build.zig +++ b/build.zig @@ -20,14 +20,14 @@ pub fn build(b: *std.Build) void { // these defaults, so a mismatch fails `labelle build` before any user code. // Set verified end-to-end STANDALONE (init → install → generate → full // compile against the published release tarballs, scratch LABELLE_HOME, - // no sibling checkouts) for core 1.32.0 / engine 2.12.2 / gfx 1.30.1 with - // cli 1.61.2, on the bgfx_desktop, null_desktop, raylib_desktop and - // sokol_desktop targets. engine/gfx/cli are exactly `labelle-cli` v1.61.2's - // `versions.zon`, the set its own `labelle upgrade all` writes; core is - // AHEAD of it (that file still says 1.28.0 through cli v1.67.0) — see the - // bgfx paragraph below for why, and bump the cli side to match. Keep the - // two in step: when they drift, `init` scaffolds a project the very next - // `upgrade` rewrites. + // no sibling checkouts) for core 2.0.0 / engine 3.0.0 / gfx 2.0.0 with + // labelle-bgfx 0.21.0 on the bgfx_desktop target (PR #733), after the + // 1.32.0 / 2.12.2 / 1.30.1 predecessor was verified the same way on + // bgfx/null/raylib/sokol (#736). labelle-cli's `versions.zon` (the set + // its `labelle upgrade all` writes) still says 1.28.0 / 2.12.2 / 1.30.1 + // through cli v1.67.0 — BEHIND this trio; bump the cli side to match. + // Keep the two in step: when they drift, `init` scaffolds a project the + // very next `upgrade` rewrites. // // The hard floor inside the trio (#679): gfx >= 1.30.0 re-exports // `core.TextureId` instead of declaring its own (labelle-gfx#328, @@ -56,29 +56,31 @@ pub fn build(b: *std.Build) void { // (v0.96.0 bumped engine 2.7.0 → 2.11.0 while leaving core at 1.26.0, so // every fresh `labelle init` scaffold failed to compile.) // - // core 1.32.0 (#731 follow-up): the builtin `.bgfx` provider default - // (`src/config.zig` builtinProvider) is labelle-bgfx 0.20.0, the lowest - // bgfx that survives core v1.32.0's `MaterialEffect.pixel_water`. Its - // HARD core floor is 1.28.0 (`core.BackendTextureId`, a struct field - // type, bgfx >= 0.15.0) — 0.20.0 does compile against core 1.28.0, - // verified standalone; the v1.32.0 `PixelWaterDraw` / `PIXEL_WATER_*` - // names it takes from `backend_contract` are reached only through - // `@hasField(MaterialEffect, "pixel_water")`-gated paths or lazy - // top-level aliases. 1.32.0 is a CURATED floor like the gfx 1.28 one: it - // is the core bgfx 0.20.0 was released against (its own build.zig.zon), - // the only core on which the pixel_water effect the default backend - // ships is reachable, and the pairing labelle-bgfx's own examples run - // on. A `labelle init --backend=bgfx` scaffold should not pair a backend - // with a core older than the backend's own pin. `src/init_cmd.zig`'s - // "scaffold core default pairs with the builtin bgfx provider" test - // asserts both floors. + // core 2.0.0 / gfx 2.0.0 / engine 3.0.0 (PR #733, game-owned shader + // materials): the builtin `.bgfx` provider default (`src/config.zig` + // builtinProvider) is labelle-bgfx 0.21.0, the first contract-v2 backend. + // It names core's `shader_material` contract UNGATED and its + // `MATERIAL_CONTRACT_VERSION == 2`, both of which only core >= 2.0.0 + // declares — so its HARD core floor is 2.0.0 (a compile break inside the + // backend on any older core), and the assembler's generated materials + // module `@compileError`s on any core below it. gfx 2.0.0 and engine + // 3.0.0 are the releases built against that core (their build.zig.zon + // pins: gfx → core 2.0.0, engine → gfx 2.0.0); the specialized + // PixelWater contract the 1.32.0 core carried is GONE in 2.0.0, so the + // 1.x/2.12.x/1.30.x trio and bgfx 0.20.0 do not mix with any of these. + // The four move TOGETHER (the #731 lesson: bumping the provider without + // the core, or vice versa, ships a default that fails at the first + // `labelle build`). `src/init_cmd.zig`'s `bgfx_core_floors` carries the + // 0.21.0 → 2.0.0 floor and its "scaffold core default pairs with the + // builtin bgfx provider" test asserts the pairing; `checkTrioFloors` + // asserts the trio. // Bump all three together when moving the engine default (their // build.zig.zon pins/floors must agree — read them from the tags), and // keep `src/init_cmd.zig`'s "scaffold pins a MUTUALLY COMPATIBLE trio" // test satisfied — it encodes the floors below as assertions. - const core_version: []const u8 = b.option([]const u8, "core_version", "Default core library version") orelse "1.32.0"; - const engine_version: []const u8 = b.option([]const u8, "engine_version", "Default engine library version") orelse "2.12.2"; - const gfx_version: []const u8 = b.option([]const u8, "gfx_version", "Default gfx library version") orelse "1.30.1"; + const core_version: []const u8 = b.option([]const u8, "core_version", "Default core library version") orelse "2.0.0"; + const engine_version: []const u8 = b.option([]const u8, "engine_version", "Default engine library version") orelse "3.0.0"; + const gfx_version: []const u8 = b.option([]const u8, "gfx_version", "Default gfx library version") orelse "2.0.0"; // Version this assembler binary stamps into a freshly scaffolded // project.labelle's `assembler_version` field. // @@ -181,6 +183,29 @@ pub fn build(b: *std.Build) void { bin_tests.root_module.link_libc = true; // see assembler_exe comment above test_step.dependOn(&b.addRunArtifact(bin_tests).step); + const material_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize }), .filters = &.{ "material_schema", "material_pipeline", "component_collisions" } }); + material_tests.root_module.addOptions("build_options", options); + material_tests.root_module.addImport("flow_codegen", flow_codegen_module); + material_tests.root_module.link_libc = true; + const material_test_run = b.addRunArtifact(material_tests); + const material_test_step = b.step("test-materials", "Validate material JSON, descriptor generation and build wiring"); + material_test_step.dependOn(&material_test_run.step); + test_step.dependOn(&material_test_run.step); + const shaderc_test_exe = b.option([]const u8, "shaderc", "Host shaderc for material integration tests") orelse b.graph.environ_map.get("LABELLE_SHADERC"); + const material_test_core = b.option([]const u8, "material-test-core", "Local core checkout for material integration test"); + const material_test_zbgfx = b.option([]const u8, "material-test-zbgfx", "Pinned zbgfx directory for material integration test"); + const material_integration = b.step("test-material-integration", "Compile shaders/descriptors and verify include cache invalidation with a real shaderc"); + if (shaderc_test_exe != null and material_test_core != null and material_test_zbgfx != null) { + const python = b.option([]const u8, "python", "Python executable for integration tests") orelse (if (@import("builtin").os.tag == .windows) "py" else "python3"); + const integration = b.addSystemCommand(&.{python}); + if (@import("builtin").os.tag == .windows and std.mem.eql(u8, python, "py")) integration.addArg("-3"); + integration.addFileArg(b.path("test/material_pipeline_e2e.py")); + integration.addArgs(&.{ "--zig", b.graph.zig_exe, "--shaderc", shaderc_test_exe.?, "--core", material_test_core.?, "--zbgfx", material_test_zbgfx.? }); + material_integration.dependOn(&integration.step); + } else { + material_integration.dependOn(&b.addFail("Supply -Dshaderc=PATH (or LABELLE_SHADERC), -Dmaterial-test-core=DIR and -Dmaterial-test-zbgfx=DIR").step); + } + // ── `test-cache`: the local-slot cache machinery, alone ───────────── // // The Windows CI job runs THIS, not `test`. `zig build test` on Windows @@ -403,6 +428,7 @@ pub fn build(b: *std.Build) void { }); const run_test = b.addRunArtifact(t); test_step.dependOn(&run_test.step); + if (std.mem.eql(u8, test_file, "test/build_zig_tests.zig") or std.mem.eql(u8, test_file, "test/build_zig_zon_tests.zig")) material_test_step.dependOn(&run_test.step); if (std.mem.eql(u8, test_file, "test/animation_assets_tests.zig")) { b.step("test-animation", "Test JSONC animation asset wiring").dependOn(&run_test.step); } diff --git a/docs/shader-materials.md b/docs/shader-materials.md new file mode 100644 index 00000000..25e66a22 --- /dev/null +++ b/docs/shader-materials.md @@ -0,0 +1,67 @@ +# Game-owned shader materials + +Place each descriptor at `materials//material.json`. Generate with the bgfx backend (the null tests target is also supported). No water/fog/lamp semantics or ECS components are generated by this pipeline. + +```json +{ + "version": 1, + "label": "fog", + "fragment": "fs_fog.sc", + "targets": ["spv", "glsl", "essl", "mtl"], + "parameters": [{"name": "u_tint", "kind": "vec4", "defaults": [1,1,1,1]}], + "textures": [{"name": "s_noise", "sampler": "linear"}], + "blend": "alpha" +} +``` + +`version`, `fragment`, and nonempty `targets` are required. Unknown fields are rejected. Label defaults to the folder name. Parameters/textures default empty, count defaults 1, defaults default empty (core zero-initializes), sampler defaults `point`, blend defaults `alpha`. Kinds: `scalar`, `vec2`, `vec3`, `vec4`, `mat4`; blend: `alpha`, `additive`. Nonempty defaults must have exactly channels × count finite floats. The limits match core contract v2: 16 parameters, 4 extra textures, 64 registers including one reserved for the automatic `u_material_rect`. Matrices use four registers each. Names are ASCII identifiers, maximum 63 bytes. All bindings must be unique, including across parameters and textures. `s_tex`, `u_material_rect`, and BGFX predefined `u_view*`, `u_proj`, `u_inv*`, `u_model*`, `u_alphaRef4` names listed in `material_schema.zig` are reserved. + +Folder names are identifiers and must be unique ignoring case. Generated symbols `core`, `sm`, `parameters`, `textures`, `shaders`, `label`, `blend`, `descriptor`, `bindings`, `_` are reserved folder names. + +Fragments pair with the existing sprite vertex shader. Inputs are `v_color0` and `v_texcoord0`; the build supplies its fixed sprite `varying.def.sc`. The base sprite texture is `s_tex` at slot 0. Additional textures occupy slots 1 onward in descriptor order. `u_material_rect` is supplied per draw. Scalars/vectors follow core's parameter packing; shader code must use the corresponding BGFX-compatible uniform declarations. There is no custom vertex shader option. + +```zig +const materials = @import("materials"); +// Preferred engine API: use catalog-backed engine texture bindings. +try game.createShaderMaterial(entity, .{ + .label = materials.fog.label, + .shaders = materials.fog.shaders, + .parameters = &materials.fog.parameters, + .blend = materials.fog.blend, + // .textures = caller's engine.ShaderTextureBinding slice +}); +``` + +### Texture bindings are catalog `.image` entries only + +The engine resolves a `.catalog` binding through the AssetCatalog and rejects +any entry whose `loader_kind` is not `.image` with `error.InvalidTexture` +(`labelle-engine/src/game/shader_material_mixin.zig`). **An atlas frame is not +an image entry**, so a mask/lookup texture a fragment shader samples must be +declared as a STANDALONE `.{ .name = ..., .image = "..." }` resource in +`project.labelle`, not packed into a sprite atlas. The generic material path +carries the same constraint the retired built-in water effect had; budget the +extra standalone PNGs when migrating. + +A binding that is registered but not yet resident returns `error.TextureNotReady` +(streaming) or `error.AssetLoadFailed`; retry, do not treat either as fatal. +Textures are sampled in the sprite's own UV space, so a mask normally spans +exactly the logical rectangle it describes. + +The module imports the same `labelle-core` instance as the game. It is wired into ordinary and promoted scripts, pack modules, executables, and test roots. It has no backend imports. For lower-level use, `var bindings = materials.fog.textures; bindings[0].texture = backend_id; const d = materials.fog.descriptor(&bindings);` returns a **borrowed** core descriptor; retain the caller's array through creation. The engine uses a distinct texture union for catalog pinning, so compose its descriptor from the exported fields instead of passing the core helper result. + +| Variant | shaderc platform | profile | +|---|---|---| +| spv | linux | spirv | +| glsl | linux | 120 | +| essl | android | 300_es | +| mtl | osx | metal | +| dx11 | windows | s_5_0 | + +Every requested variant is compiled with `--type fragment -O 3`. Desktop Windows/Linux require `spv` for the initial Vulkan path; Apple targets require `mtl`; Android/web require `essl`. Absent variants remain empty; no silent fallback or old binary reuse occurs on compilation failure. DX11 is schema/build supported but requires the pinned compiler's DirectX runtime support; it is not part of the four-variant acceptance fixture. + +Generated `build.zig.zon` adds a separate hash-pinned `material_shaderc` host dependency; framework release pins are unchanged. The host compiler is built automatically by Zig. Set `LABELLE_SHADERC` or `-Dshaderc=` to reuse a compatible host compiler. The pinned package's `shaders/` includes are always used, even with an executable override. Cross-target games still execute the host tool. + +Shader source paths and literal include paths cannot contain parent traversal, absolute paths, Windows drives/backslashes, or NUL. Macro-based include names and symlinks within the source tree are rejected. Includes may use material-local files or the shared tree, e.g. `#include ` from `materials/shared/lamp_footprint.sc`. Keep image assets in `assets/`; material source files are limited to 1 MiB each. All material files plus pinned shader headers are tracked for compilation cache invalidation, conservatively rebuilding all variants on any material-tree edit. Compiler executable/artifact, arguments, source, fixed varyings and includes participate in Zig caching. Outputs live in Zig's cache, never the source tree. Adding/removing descriptor folders requires regeneration; edits to existing descriptors are embedded build inputs and need only `zig build`. + +Verification: `zig build test-materials`; real compiler and generated-source coverage: `zig build test-material-integration -Dshaderc= -Dmaterial-test-core= -Dmaterial-test-zbgfx=`. The integration step checks all four variants, descriptor compilation against real core, cache hits, include edits, compiler failures, escaping includes, duplicate registrations, and missing required variants. diff --git a/docs/shader-migration.md b/docs/shader-migration.md new file mode 100644 index 00000000..f06f9e67 --- /dev/null +++ b/docs/shader-migration.md @@ -0,0 +1,86 @@ +# Migrating to game-owned effects + +The shader implementation is a coordinated change across core, gfx, engine, +bgfx, assembler and CLI. The toolkit side has shipped: core 2.0.0, gfx 2.0.0, +engine 3.0.0 and bgfx 0.21.0 are released, and `examples/condenser` pins those +releases (no local sibling checkouts). Older releases do not carry the generic +material contract (v2); a game that adds `materials/` must resolve those +versions or newer. + +## Ownership + +- Core defines the typed material contract and opaque runtime handles. +- Gfx resolves texture IDs and dispatches generic material draws. +- Engine owns entity bindings, catalog pins and cleanup across scene/GPU lifetimes. +- BGFX owns GPU programs, uniforms, instance values and resource validation. +- Assembler discovers material definitions, compiles variants and generates imports. +- CLI validates an explicitly configured host shader compiler. +- The game owns shader sources, components, prefabs, simulation and effect controls. + +After this shared infrastructure is released, adding another sprite effect is a +game change: add its material definition/source, component and binding system. +No water, fog or lamp effect registration is required in the toolkit repositories. + +## Breaking changes + +The specialized PixelWater contract, facade, engine simulation/authoring and +BGFX implementation have been removed. Existing games must migrate their water +state and update logic into game components, as demonstrated by `WaterShader` +in `examples/condenser`. The generic material ABI is version 2; rebuild the +coordinated packages together. Do not mix old compiled material layouts. + +`WaterShader`, `FogShader` and `LampShader` are ordinary game component names. +Their shader descriptors live separately under `materials//material.json`. +Shader source is BGFX shader language; JSON specifies its interface and targets. +Runtime handles must not be authored in prefabs or serialized as durable state. + +## Release sequence + +1. Release the new core contract. +2. Update the BGFX core dependency pin and release the generic backend and gfx. +3. Release engine's generic entity material API. +4. Release assembler's shader pipeline and CLI integration. +5. Bump the bgfx entry of `ProjectConfig.builtinProvider` (`src/config.zig`) to + the released contract-v2 backend, in the SAME change that adopts the new + core (done: bgfx 0.21.0 with the core 2.0.0 / gfx 2.0.0 / engine 3.0.0 + scaffold trio in `build.zig`). `.backend = .bgfx` with no + `.backend_package` resolves through that default, so bumping either side + alone ships a default that fails at the first `labelle build` — the + failure mode assembler#731 repaired in the other direction. + `src/init_cmd.zig`'s `bgfx_core_floors` carries the bgfx >= 0.21.0 → + core >= 2.0.0 hard floor, and `material_pipeline` rejects an explicit + pre-contract-v2 `.backend_package` / `.core_version` pin at generate time + for a project that owns `materials/`. +6. Replace the example's local dependency selections with those released + versions (done: `examples/condenser` pins core 2.0.0, gfx 2.0.0, engine + 3.0.0 and bgfx 0.21.0), and add it to the examples-integration lane + (assembler#732: that lane only GENERATES bgfx examples today, so a compile + break in a pinned backend passes unnoticed — build it, do not only + generate it). + +All six steps have shipped (core v2.0.0, gfx v2.0.0, engine v3.0.0, bgfx +v0.21.0; assembler defaults in PR #733). A fresh `labelle init --backend=bgfx` +resolves the contract-v2 stack and can own `materials/` with no explicit pins. +To develop against unreleased sibling checkouts, switch the example's four pins +back to `local:../../../labelle-*`. Standalone BGFX tests require +`-Dcore-source=/src/root.zig`. + +## Validation + +The condenser compiles water, fog, lamp and mist to SPIR-V, GLSL, ESSL and +Metal. Windows/Vulkan execution covers the deterministic scene scenarios in the +example README (16 native scenarios, including per-instance isolation and +independent effect disabling). Its generated unit suite passes 20 tests. See the +example README for controls and reproduction. + +Verified live against the released pins: + +- macOS/Metal: standalone `labelle run` of the condenser, four materials live + (2/2 instances each), no degrade lines. +- Android/OpenGL ES: Galaxy Tab A7 (Adreno 610) through the CLI path, all eight + material instances live at 62 fps. + +Core, gfx, BGFX, assembler and CLI suites and engine's focused shader regression +suite pass. The broad engine suite encounters Windows preview/network and time +API failures; it is not reported as passing. Browser (wasm) execution has not +been verified. Direct3D runtime materials are unsupported. diff --git a/examples/condenser/.gitignore b/examples/condenser/.gitignore new file mode 100644 index 00000000..9bb4a13d --- /dev/null +++ b/examples/condenser/.gitignore @@ -0,0 +1,3 @@ +.labelle/ +.test-output/ +__pycache__/ diff --git a/examples/condenser/README.md b/examples/condenser/README.md new file mode 100644 index 00000000..0e617a36 --- /dev/null +++ b/examples/condenser/README.md @@ -0,0 +1,164 @@ +# COND-07: game-owned water, fog and lamps + +Two independently controlled condenser units use production `WaterShader`, +`FogShader`, `LampShader`, and `MistShader` components and the generic shader-material API. +Contained water is still between drop impacts: no travelling wave or reflection +flow. Fog and light replace their contributions in the static art; they do not +lay a second haze over the original plate. + +![Windows/Vulkan capture](preview.png) + +## Dependencies + +`project.labelle` pins the released game-shader-material stack: core 2.0.0, +gfx 2.0.0, engine 3.0.0 and the bgfx backend 0.21.0 (the first contract-v2 +backend; it requires core >= 2.0.0, so the four pins move together). Only the +assembler is still selected as `local:../../`, like every example in this +repository. To develop against unreleased sibling checkouts instead, switch the +four pins to `local:../../../labelle-{core,gfx,engine,bgfx}` and regenerate. + +```sh +# POSIX. `labelle build` from this directory does the same thing. +../../zig-out/bin/labelle-assembler generate --project-root . +( cd .labelle/bgfx_desktop && zig build && zig build test --summary all ) +``` + +```powershell +../../zig-out/bin/labelle-assembler.exe generate --project-root . +Push-Location .labelle/bgfx_desktop +zig build +zig build test --summary all +Pop-Location +``` + +The assembler owns descriptor discovery, compilation, and the generated +`@import("materials")`. Set `LABELLE_SHADERC` to a host shaderc executable when +using an existing compiler. The requested targets are SPIR-V, GLSL, ESSL and +Metal. Game code uses generated `.shaders` / `.parameters` and engine catalog +texture bindings; it never imports a graphics backend. The backend provides +`s_tex` and `u_material_rect`, including atlas remapping. + +## Art and masks + +PR730 artwork stays **618x330 per unit at scale 1**, in a 1236x330 window. Only +the left two columns of the authorized 620x330 `condenser-detail.gif` crop were +removed. No full-room reference is used. The original atlas/layers are retained. +Water uses a separate 93x6 logical grid (six screen pixels per cell), a 558x36 +mask and a fixed supplied reflection. Fog and light use full-canvas standalone +images and an independently adjustable effect grid (default six screen pixels). + +`tools/derive_effect_masks.py` decomposes the existing full-resolution layers +with a bounded inverse-over model. It produces estimated clean interior/frame +plates, a veil/lighting-response mask, and the physical lamp source mask. +This is an estimated reconstruction, not recovered original layered artwork. +The static interior recomposes within two byte values after PNG quantization. +Full-resolution structural texture and alpha boundaries remain intact; only +effect modulation snaps to cells. The emissive tube is reconstructed out of +the clean frame and restored only inside its source mask and live lamp width. + +```sh +python tools/derive_layers.py +python tools/pack_atlas.py +python tools/derive_effect_masks.py +``` + +The cooler and frame continue to occlude the water and falling drops. Fog's +background sample never moves. Both shaders include the same +`materials/shared/lamp_footprint.sc` for exact lamp/fog coupling. + +## Prefab and runtime controls + +Edit `prefabs/reservoir.jsonc`, `prefabs/fog.jsonc`, and `prefabs/lamp.jsonc`. +Scene instances use partial `WaterShader`/`FogShader`/`LampShader` overrides. +Missing fields inherit; explicit `0` and `false` are retained. Tests exercise +the real engine prefab merge and deserializer, including these zero cases. + +| Component | Controls and zero semantics | +|---|---| +| WaterShader | `water_level` 0..1; `reflection_opacity` 0..1; positive `ripple_duration_seconds` and `ripple_radius_pixels`; nonnegative `ripple_strength_pixels`; logical size/grid, colors, mask/reflection catalog keys. Zero fill clears impacts. | +| FogShader | `density` changes optical depth; `opacity` separately fades its result (0..1). Either zero removes the veil. `variation` controls contrast, `wisp_size` the spatial wavelength (screen px; zero removes variation), `turbulence` blends a second evolving octave (0..1). | +| FogShader motion | `speed` scales all motion; zero freezes it. Signed `drift_velocity: [x,y]` gives direction and rate in screen px/s before speed scaling; `[0,0]` disables translation. `light_coupling` controls scattering, zero removes coupling. Color/grid are independent controls. | +| LampShader | `center`, `width`, `reach_up`, `reach_down` in screen px. Zero width/intensity disables source and halo; a zero reach disables only that side of the halo. | +| LampShader shape | `spread` expands the halo away from the source; `softness` feathers horizontal edges (zero is hard); `falloff` is a vertical exponent (zero is flat inside the bounded reach); `glow` scales halo only (zero keeps the physical tube). | +| LampShader animation | `flicker` amplitude 0..1 (default zero, constant lamp); `flicker_speed` cycles/s (zero freezes phase). Flicker is deterministic, bounded and shared with fog scattering. Color/grid and `enabled` remain editable. | + +Simulation clocks wrap/rebase without wall-clock dependence. Water owns eight +bounded impacts, expires them permanently, drops impacts when bounds shrink, +rejects nonfinite/malformed state, and replaces the oldest impact deterministically. +Drops read the live level and emit once on contact. Refill cannot resurrect an +old ripple. Invalid optional-field water patches leave prior state intact. + +Keyboard controls affect **unit A only**: + +- **Space**: switch fill level; **R**: inject a demonstration impact. +- **F**: toggle fog; **L**: toggle lamp; **M**: toggle mist. +- **Q/E**: decrease/increase lamp width by 12 px. +- **S/W**: decrease/increase upward reach by 6 px. +- **A/D**: decrease/increase downward reach by 12 px. +- **G**: alternate the default lamp and width=120/up=0/down=66 preset. + +Startup environment overrides apply to both units. Numeric zero is a real value. +All use the `CONDENSER_` prefix: + +- `WATER_LEVEL`, `WATER_OFF`. +- `FOG_DENSITY`, `FOG_OPACITY`, `FOG_SPEED`, `FOG_VARIATION`, `FOG_WISP_SIZE`, + `FOG_TURBULENCE`, `FOG_DRIFT_X`, `FOG_DRIFT_Y`, `FOG_LIGHT`, `FOG_OFF`. +- `LAMP_WIDTH`, `LAMP_UP`, `LAMP_DOWN`, `LAMP_INTENSITY`, `LAMP_SPREAD`, + `LAMP_SOFTNESS`, `LAMP_FALLOFF`, `LAMP_GLOW`, `LAMP_FLICKER`, + `LAMP_FLICKER_SPEED`, `LAMP_OFF`. + +`CONDENSER_TEST_CONTROL=water|fog|lamp|mist` triggers the corresponding +Space/F/G/M control path at frame 60. `CONDENSER_TEST_LEFT=1` triggers all four. +Both hooks +change only the left unit and preserve normal keyboard input. Capture at two +seconds with `LABELLE_FIXED_DT=0.016666667` for deterministic comparison. + +## Verification + +- `zig build test --summary all` from the generated desktop directory: **20 tests**, + including water lifetime/bounds/rebase/invalid state, directional drift, + zero controls, flicker, actual prefab merge, and real keyboard paths. +- `python tools/verify_effect_assets.py`: **10 checks**, including saved-mask + reconstruction, alpha preservation, independent fog/lamp controls, shared + grid behavior, and descriptor shapes; writes a CPU reference contact sheet. +- `tools/compile_shaders.ps1 -Shaderc -Include `: + compiles all **16 variants** independently of the game build. +- `python tools/verify_runtime.py`: **16 actual native runs** (macOS/Metal and + Windows/Vulkan), eight live generic + materials each, fixed-step captures, frame-60 left-only edits, zero width and + zero fog opacity equivalence, and independent fog/lamp off cases. + +Windows/Vulkan runtime checks pass. The right 618x330 half is byte-identical +across every left-control run. Results, logs and captures are under +`.test-output/runtime/`; the directory is ignored. Other shader targets compile +but have not been executed here. `preview.png` is the current native capture. + +Mist verification also checks clipping above each live water surface, empty +reservoir suppression, zero opacity, moving versus frozen wisps, and a left-only +M toggle. All 16 native scenarios pass; changing the left mist changes 17,886 +left pixels and zero right pixels. The captures and measurements are in +`.test-output/runtime/results.txt`. + +## Floating water mist + +`prefabs/mist.jsonc` configures a separate transparent `MistShader` above each +reservoir. It uses procedural wisps, without new artwork or moving background +samples. The layer sits behind drops and foreground machinery. **M** toggles +only the left mist; **Space** changes the water level and its mist anchor. + +- `height`: vertical band above the water, in screen pixels; zero disables it. +- `density`, `opacity` and `color`: thickness and tint; zero density/opacity removes it. +- `drift_velocity`: signed screen pixels/second; default `[12,-3]` drifts right and rises. + `[0,0]` freezes the pattern. +- `wisp_size`, `turbulence`, `grid_pixels`: shape, detail and pixel scale. +- `light_coupling`: response to the matching lamp; zero removes that response. + +Bounds and surface height come from the matching reservoir in this example; +empty or disabled water emits no mist. Startup overrides: `CONDENSER_MIST_OFF`, +`CONDENSER_MIST_OPACITY`, `CONDENSER_MIST_FREEZE`. The deterministic runtime +hook `CONDENSER_TEST_CONTROL=mist` exercises the same M-key path at frame 60. + +The probe queries actual `game.shaderMaterial(entity)` bindings. Cold catalog +loads retry silently during the first 300 frames; hard failures and prolonged +loading failures are reported. Context loss can recreate materials from the +same game components. GPU and texture lifetime cleanup remains engine-owned. diff --git a/examples/condenser/assets/condenser.json b/examples/condenser/assets/condenser.json new file mode 100644 index 00000000..5990004c --- /dev/null +++ b/examples/condenser/assets/condenser.json @@ -0,0 +1,104 @@ +{ + "frames": { + "machine_interior.png": { + "frame": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "cooler_foreground.png": { + "frame": { + "x": 0, + "y": 334, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "frame_foreground.png": { + "frame": { + "x": 0, + "y": 668, + "w": 618, + "h": 330 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 618, + "h": 330 + }, + "sourceSize": { + "w": 618, + "h": 330 + } + }, + "drop.png": { + "frame": { + "x": 0, + "y": 1002, + "w": 6, + "h": 30 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 6, + "h": 30 + }, + "sourceSize": { + "w": 6, + "h": 30 + } + }, + "reservoir_art.png": { + "frame": { + "x": 0, + "y": 1036, + "w": 558, + "h": 36 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 558, + "h": 36 + }, + "sourceSize": { + "w": 558, + "h": 36 + } + } + } +} \ No newline at end of file diff --git a/examples/condenser/assets/condenser.png b/examples/condenser/assets/condenser.png new file mode 100644 index 00000000..793abd18 Binary files /dev/null and b/examples/condenser/assets/condenser.png differ diff --git a/examples/condenser/assets/effects/fog_mask.png b/examples/condenser/assets/effects/fog_mask.png new file mode 100644 index 00000000..6892decd Binary files /dev/null and b/examples/condenser/assets/effects/fog_mask.png differ diff --git a/examples/condenser/assets/effects/frame_clean.png b/examples/condenser/assets/effects/frame_clean.png new file mode 100644 index 00000000..4c2e79ae Binary files /dev/null and b/examples/condenser/assets/effects/frame_clean.png differ diff --git a/examples/condenser/assets/effects/interior_clean.png b/examples/condenser/assets/effects/interior_clean.png new file mode 100644 index 00000000..46d04833 Binary files /dev/null and b/examples/condenser/assets/effects/interior_clean.png differ diff --git a/examples/condenser/assets/effects/lamp_mask.png b/examples/condenser/assets/effects/lamp_mask.png new file mode 100644 index 00000000..58ce5891 Binary files /dev/null and b/examples/condenser/assets/effects/lamp_mask.png differ diff --git a/examples/condenser/assets/effects/provenance.json b/examples/condenser/assets/effects/provenance.json new file mode 100644 index 00000000..b55ae55b --- /dev/null +++ b/examples/condenser/assets/effects/provenance.json @@ -0,0 +1,21 @@ +{ + "size": [ + 618, + 330 + ], + "effect_grid": 6, + "source": "PR730 full-resolution layers from condenser-detail.gif (620x330, crop left 2px)", + "fog_color": [ + 0.48, + 0.65, + 0.73 + ], + "lamp_color": [ + 0.64, + 0.85, + 0.94 + ], + "fog_mask": "R=estimated baked veil, G=lamp response, A=interior coverage", + "lamp_mask": "R=lamp response, G=physical emissive tube coverage, A=frame coverage", + "method": "bounded inverse-over decomposition; estimated clean background, full-resolution structure retained" +} diff --git a/examples/condenser/assets/layers/cooler_foreground.png b/examples/condenser/assets/layers/cooler_foreground.png new file mode 100644 index 00000000..26687306 Binary files /dev/null and b/examples/condenser/assets/layers/cooler_foreground.png differ diff --git a/examples/condenser/assets/layers/drop.png b/examples/condenser/assets/layers/drop.png new file mode 100644 index 00000000..d8c9f08d Binary files /dev/null and b/examples/condenser/assets/layers/drop.png differ diff --git a/examples/condenser/assets/layers/frame_foreground.png b/examples/condenser/assets/layers/frame_foreground.png new file mode 100644 index 00000000..a6255a29 Binary files /dev/null and b/examples/condenser/assets/layers/frame_foreground.png differ diff --git a/examples/condenser/assets/layers/machine_interior.png b/examples/condenser/assets/layers/machine_interior.png new file mode 100644 index 00000000..1026a326 Binary files /dev/null and b/examples/condenser/assets/layers/machine_interior.png differ diff --git a/examples/condenser/assets/layers/reflection.png b/examples/condenser/assets/layers/reflection.png new file mode 100644 index 00000000..5c948d95 Binary files /dev/null and b/examples/condenser/assets/layers/reflection.png differ diff --git a/examples/condenser/assets/layers/reservoir_mask.png b/examples/condenser/assets/layers/reservoir_mask.png new file mode 100644 index 00000000..c4960388 Binary files /dev/null and b/examples/condenser/assets/layers/reservoir_mask.png differ diff --git a/examples/condenser/assets/layers/reservoir_static.png b/examples/condenser/assets/layers/reservoir_static.png new file mode 100644 index 00000000..d45025f7 Binary files /dev/null and b/examples/condenser/assets/layers/reservoir_static.png differ diff --git a/examples/condenser/assets/reference/condenser-detail.gif b/examples/condenser/assets/reference/condenser-detail.gif new file mode 100644 index 00000000..207ebf47 Binary files /dev/null and b/examples/condenser/assets/reference/condenser-detail.gif differ diff --git a/examples/condenser/assets/reservoir_art.png b/examples/condenser/assets/reservoir_art.png new file mode 100644 index 00000000..b588ea46 Binary files /dev/null and b/examples/condenser/assets/reservoir_art.png differ diff --git a/examples/condenser/assets/reservoir_mask_rect.png b/examples/condenser/assets/reservoir_mask_rect.png new file mode 100644 index 00000000..20bdae05 Binary files /dev/null and b/examples/condenser/assets/reservoir_mask_rect.png differ diff --git a/examples/condenser/components/drop.zig b/examples/condenser/components/drop.zig new file mode 100644 index 00000000..cfd6a04b --- /dev/null +++ b/examples/condenser/components/drop.zig @@ -0,0 +1,34 @@ +//! One condensation drop: it falls under gravity from the machine's coils, +//! contacts the reservoir's CURRENT water surface, emits exactly ONE ripple, +//! and waits before the next release. +//! +//! Geometry uses the original 103x55 EFFECT coordinate system; the script +//! multiplies by six for screen positions. Static artwork is independently +//! stored at reference resolution and drawn at scale 1. +pub const Drop = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + /// The reservoir (`Reservoir.unit`) this drop falls into. + unit: u32 = 0, + /// Impact X in RESERVOIR-LOCAL native px — what WaterShader.impact takes. + /// The five measured emitters are local x 21, 28, 43, 51, 65. + local_x: f32 = 0, + /// Native canvas Y the drop is released from (the reference releases at + /// native y 2..5). + release_y: f32 = 2, + /// Downward acceleration in native px/s^2. The reference's fall is + /// accelerating, not grid-stepped. + gravity: f32 = 230, + /// Seconds between one impact and the next release. + interval: f32 = 1.6, + /// Start-of-run offset so the five emitters do not drip in unison. + phase: f32 = 0, + + // ── Runtime ────────────────────────────────────────────────────── + /// Seconds since this drop was released (negative while waiting). + t: f32 = 0, + /// Set the instant the drop crosses the surface, so exactly one ripple + /// is emitted per fall no matter how many frames the crossing spans. + rippled: bool = false, + /// Whether `t` has been seeded from `phase` yet. + started: bool = false, +}; diff --git a/examples/condenser/components/fog_shader.zig b/examples/condenser/components/fog_shader.zig new file mode 100644 index 00000000..5f54849a --- /dev/null +++ b/examples/condenser/components/fog_shader.zig @@ -0,0 +1,50 @@ +const std = @import("std"); +pub const FogShader = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + unit: u32 = 0, + enabled: bool = true, + density: f32 = 1, + opacity: f32 = 1, + speed: f32 = 0.12, + variation: f32 = 0.18, + wisp_size: f32 = 48, + turbulence: f32 = 0.25, + /// Signed screen px/s, multiplied by speed. Zero stops translation. + drift_velocity: [2]f32 = .{ 6, 0 }, + light_coupling: f32 = 0.35, + grid_pixels: f32 = 6, + color: [3]f32 = .{ 0.48, 0.65, 0.73 }, + phase: f32 = 0, + drift_phase: [2]f32 = .{ 0, 0 }, + + pub fn validate(self: FogShader) !void { + if (!std.math.isFinite(self.phase) or self.phase < 0 or self.phase >= 1) return error.InvalidPhase; + for (self.drift_phase) |v| if (!std.math.isFinite(v) or v < 0 or v >= 1) return error.InvalidPhase; + inline for (.{ self.density, self.speed, self.light_coupling, self.wisp_size }) |v| { + if (!std.math.isFinite(v) or v < 0) return error.InvalidFogSetting; + } + if (!std.math.isFinite(self.variation) or self.variation < 0 or self.variation > 1) return error.InvalidFogSetting; + inline for (.{ self.opacity, self.turbulence }) |v| { + if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidFogSetting; + } + for (self.drift_velocity) |v| if (!std.math.isFinite(v)) return error.InvalidFogSetting; + if (!std.math.isFinite(self.grid_pixels) or self.grid_pixels < 1) return error.InvalidGrid; + for (self.color) |v| if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidFogSetting; + } + + pub fn advance(self: *FogShader, dt: f32) !void { + try self.validate(); + if (!std.math.isFinite(dt) or dt < 0) return error.InvalidDelta; + // A bounded phase prevents long-session precision loss; shader motion + // is periodic in this phase, so the wrap is continuous. + const next = @as(f64, self.phase) + @as(f64, dt) * self.speed; + self.phase = @floatCast(@mod(next, 1)); + if (self.phase >= 1) self.phase = 0; + if (self.wisp_size > 0) { + for (&self.drift_phase, self.drift_velocity) |*phase, velocity| { + phase.* = @floatCast(@mod(@as(f64, phase.*) + @as(f64, dt) * self.speed * velocity / self.wisp_size, 1)); + if (phase.* >= 1) phase.* = 0; + } + } + } +}; diff --git a/examples/condenser/components/lamp_shader.zig b/examples/condenser/components/lamp_shader.zig new file mode 100644 index 00000000..b2f5cb0e --- /dev/null +++ b/examples/condenser/components/lamp_shader.zig @@ -0,0 +1,58 @@ +const std = @import("std"); +pub const LampShader = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + unit: u32 = 0, + enabled: bool = true, + center: [2]f32 = .{ 309, 12 }, + width: f32 = 348, + reach_up: f32 = 12, + reach_down: f32 = 132, + intensity: f32 = 1, + spread: f32 = 0, + softness: f32 = 6, + falloff: f32 = 2, + glow: f32 = 1, + flicker: f32 = 0, + flicker_speed: f32 = 8, + phase: f32 = 0, + color: [3]f32 = .{ 0.64, 0.85, 0.94 }, + grid_pixels: f32 = 6, + + pub fn validate(self: LampShader) !void { + if (!std.math.isFinite(self.phase) or self.phase < 0 or self.phase >= 1) return error.InvalidPhase; + inline for (.{ self.width, self.reach_up, self.reach_down, self.intensity, self.spread, self.softness, self.falloff, self.glow, self.flicker_speed }) |v| { + if (!std.math.isFinite(v) or v < 0) return error.InvalidLampSetting; + } + for (self.center) |v| if (!std.math.isFinite(v)) return error.InvalidLampSetting; + for (self.color) |v| if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidLampSetting; + if (!std.math.isFinite(self.flicker) or self.flicker < 0 or self.flicker > 1) return error.InvalidLampSetting; + if (!std.math.isFinite(self.grid_pixels) or self.grid_pixels < 1) return error.InvalidGrid; + } + + /// CPU counterpart of the shader footprint, useful to gameplay and tests. + pub fn influence(self: LampShader, pixel: [2]f32) f32 { + if (!self.enabled or self.width <= 0 or self.intensity <= 0) return 0; + const x = (@floor(pixel[0] / self.grid_pixels) + 0.5) * self.grid_pixels; + const y = (@floor(pixel[1] / self.grid_pixels) + 0.5) * self.grid_pixels; + const dy = y - self.center[1]; + const reach = if (dy < 0) self.reach_up else self.reach_down; + if (reach <= 0) return 0; + if (@abs(dy) >= reach) return 0; + const half_width = self.width * 0.5 + self.spread * @abs(dy) / reach; + const edge = half_width - @abs(x - self.center[0]); + const horizontal: f32 = if (self.softness == 0) (if (edge >= 0) 1 else 0) else std.math.clamp(edge / self.softness, 0, 1); + const vertical = std.math.clamp(1 - @abs(dy) / reach, 0, 1); + return horizontal * std.math.pow(f32, vertical, self.falloff) * self.intensity * self.glow * self.flickerFactor(); + } + + pub fn flickerFactor(self: LampShader) f32 { + return 1 - self.flicker * (0.5 + 0.5 * @sin(self.phase * 2 * std.math.pi)); + } + + pub fn advance(self: *LampShader, dt: f32) !void { + try self.validate(); + if (!std.math.isFinite(dt) or dt < 0) return error.InvalidDelta; + self.phase = @floatCast(@mod(@as(f64, self.phase) + @as(f64, dt) * self.flicker_speed, 1)); + if (self.phase >= 1) self.phase = 0; + } +}; diff --git a/examples/condenser/components/mist_shader.zig b/examples/condenser/components/mist_shader.zig new file mode 100644 index 00000000..e95418d1 --- /dev/null +++ b/examples/condenser/components/mist_shader.zig @@ -0,0 +1,45 @@ +const std = @import("std"); + +/// A transparent, game-owned layer anchored to the matching reservoir. +pub const MistShader = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + unit: u32 = 0, + enabled: bool = true, + height: f32 = 54, + density: f32 = 1.4, + opacity: f32 = 0.42, + wisp_size: f32 = 96, + turbulence: f32 = 0.45, + drift_velocity: [2]f32 = .{ 12, -3 }, + grid_pixels: f32 = 6, + light_coupling: f32 = 0.35, + color: [3]f32 = .{ 0.48, 0.67, 0.74 }, + phase: [2]f32 = .{ 0, 0 }, + + pub fn validate(self: MistShader) !void { + inline for (.{ self.height, self.density, self.light_coupling }) |v| { + if (!std.math.isFinite(v) or v < 0) return error.InvalidMistSetting; + } + inline for (.{ self.opacity, self.turbulence }) |v| { + if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidMistSetting; + } + if (!std.math.isFinite(self.wisp_size) or self.wisp_size <= 0) return error.InvalidMistSetting; + if (!std.math.isFinite(self.grid_pixels) or self.grid_pixels < 1) return error.InvalidGrid; + for (self.drift_velocity) |v| if (!std.math.isFinite(v)) return error.InvalidMistSetting; + for (self.color) |v| if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidMistSetting; + for (self.phase) |v| if (!std.math.isFinite(v) or v < 0 or v >= 1) return error.InvalidPhase; + } + + pub fn advance(self: *MistShader, dt: f32) !void { + try self.validate(); + if (!std.math.isFinite(dt) or dt < 0) return error.InvalidDelta; + for (&self.phase, self.drift_velocity) |*phase, velocity| { + phase.* = @floatCast(@mod(@as(f64, phase.*) + @as(f64, dt) * velocity / self.wisp_size, 1)); + if (phase.* >= 1) phase.* = 0; + } + } + + pub fn surfaceY(top: f32, height: f32, level: f32) f32 { + return top + height * (1 - std.math.clamp(level, 0, 1)); + } +}; diff --git a/examples/condenser/components/reservoir.zig b/examples/condenser/components/reservoir.zig new file mode 100644 index 00000000..e4e44a24 --- /dev/null +++ b/examples/condenser/components/reservoir.zig @@ -0,0 +1,5 @@ +//! Associates drops and WaterShader state with a condenser unit. +pub const Reservoir = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + unit: u32 = 0, +}; diff --git a/examples/condenser/components/water_shader.zig b/examples/condenser/components/water_shader.zig new file mode 100644 index 00000000..a372fa18 --- /dev/null +++ b/examples/condenser/components/water_shader.zig @@ -0,0 +1,115 @@ +//! Game-owned contained water. Simulation time and impacts never live in gfx. +const std = @import("std"); +pub const WaterShader = struct { + pub const save = @import("labelle-core").Saveable(.transient, @This(), .{}); + enabled: bool = true, + mask: []const u8 = "reservoir_mask", + reflection: []const u8 = "reservoir_reflection", + logical_size: [2]f32 = .{ 93, 6 }, + grid_pixels: f32 = 1, + water_level: f32 = 0.8333, + reflection_opacity: f32 = 0.35, + ripple_duration_seconds: f32 = 0.9, + ripple_radius_pixels: f32 = 14, + ripple_strength_pixels: f32 = 1, + // Linear RGBA: the original shader palette, converted exactly once. + deep_color: [4]f32 = color(0x0F1719), + surface_color: [4]f32 = color(0x7AA5BB), + highlight_color: [4]f32 = color(0x425F6C), + time: f32 = 0, + ripple_count: usize = 0, + ripples: [8][4]f32 = .{.{ 0, 0, 0, 0 }} ** 8, + + pub fn validate(self: WaterShader) !void { + if (self.ripple_count > self.ripples.len) return error.InvalidRippleCount; + if (!std.math.isFinite(self.time)) return error.NonFiniteValue; + for (self.ripples) |r| { + for (r) |v| if (!std.math.isFinite(v)) return error.NonFiniteValue; + if (r[2] < 0 or r[2] > 1) return error.InvalidStrength; + } + inline for (.{ self.logical_size[0], self.logical_size[1], self.grid_pixels, self.ripple_duration_seconds, self.ripple_radius_pixels }) |v| { + if (!std.math.isFinite(v) or v <= 0) return error.InvalidPositiveSetting; + } + inline for (.{ self.water_level, self.reflection_opacity }) |v| { + if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidUnitSetting; + } + if (!std.math.isFinite(self.ripple_strength_pixels) or self.ripple_strength_pixels < 0) return error.InvalidStrength; + inline for (.{ self.deep_color, self.surface_color, self.highlight_color }) |rgba| { + for (rgba) |v| if (!std.math.isFinite(v) or v < 0 or v > 1) return error.InvalidColor; + } + } + + /// Optional-field patch: missing inherits, explicit zero/false replaces. + /// Validate a candidate before committing so a bad edit is atomic. + pub fn patch(self: *WaterShader, changes: anytype) !void { + var candidate = self.*; + inline for (std.meta.fields(@TypeOf(changes))) |field| { + const value = @field(changes, field.name); + if (@typeInfo(@TypeOf(value)) == .optional) { + if (value) |v| @field(candidate, field.name) = v; + } else @field(candidate, field.name) = value; + } + try candidate.validate(); + candidate.expire(); + self.* = candidate; + } + + pub fn setLevel(self: *WaterShader, level: f32) !void { + if (!std.math.isFinite(level)) return error.NonFiniteValue; + try self.patch(.{ .water_level = std.math.clamp(level, 0, 1) }); + } + + /// Expiry is permanent, including duration edits and shrinking bounds. + fn expire(self: *WaterShader) void { + var live: usize = 0; + for (self.ripples[0..self.ripple_count]) |r| { + const age = self.time - r[1]; + if (self.enabled and self.water_level > 0 and age >= 0 and age < self.ripple_duration_seconds and r[0] >= 0 and r[0] < self.logical_size[0]) { + self.ripples[live] = r; + live += 1; + } + } + @memset(self.ripples[live..], .{ 0, 0, 0, 0 }); + self.ripple_count = live; + } + + pub fn advance(self: *WaterShader, dt: f32) !void { + try self.validate(); + const next = self.time + dt; + if (!std.math.isFinite(dt) or !std.math.isFinite(next)) return error.NonFiniteValue; + if (dt < 0) return error.NegativeDelta; + self.time = next; + self.expire(); + // No continuous wave phase exists. Rebase all live timestamps equally. + if (self.time >= 4096) { + for (self.ripples[0..self.ripple_count]) |*r| r[1] -= self.time; + self.time = 0; + } + } + + pub fn impact(self: *WaterShader, x: f32, strength: f32) !void { + try self.validate(); + if (!std.math.isFinite(x) or !std.math.isFinite(strength)) return error.NonFiniteValue; + if (!self.enabled or self.water_level <= 0) return error.EmptyReservoir; + if (x < 0 or x >= self.logical_size[0]) return error.RippleOutOfBounds; + self.expire(); + var slot = self.ripple_count; + if (slot == self.ripples.len) { + slot = 0; + for (self.ripples[1..], 1..) |r, i| { + if (r[1] < self.ripples[slot][1]) slot = i; + } + } else self.ripple_count += 1; + self.ripples[slot] = .{ x, self.time, std.math.clamp(strength, 0, 1), 0 }; + } +}; + +fn color(comptime rgb: u24) [4]f32 { + var out: [4]f32 = .{ 0, 0, 0, 1 }; + inline for (0..3) |i| { + const byte: u8 = @truncate(rgb >> (8 * (2 - i))); + const v = @as(f32, @floatFromInt(byte)) / 255; + out[i] = if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4); + } + return out; +} diff --git a/examples/condenser/labelle.lock b/examples/condenser/labelle.lock new file mode 100644 index 00000000..e60ed5f8 --- /dev/null +++ b/examples/condenser/labelle.lock @@ -0,0 +1,12 @@ +// labelle.lock — resolved dependency versions +// Generated by labelle-cli. Commit this file to git. +.{ + .cli_version = "1.67.0", + .resolved = .{ + .core = .{ .version = "2.0.0" }, + .engine = .{ .version = "3.0.0" }, + .gfx = .{ .version = "2.0.0" }, + .labelle = .{ .version = "1.67.0" }, + .ecs = .{ .name = "zig_ecs" }, + }, +} diff --git a/examples/condenser/materials/fog/fs_fog.sc b/examples/condenser/materials/fog/fs_fog.sc new file mode 100644 index 00000000..cf1443fd --- /dev/null +++ b/examples/condenser/materials/fog/fs_fog.sc @@ -0,0 +1,44 @@ +$input v_texcoord0, v_color0 +#include +SAMPLER2D(s_tex, 0); +SAMPLER2D(s_fog_clean, 1); +SAMPLER2D(s_fog_mask, 2); +uniform vec4 u_material_rect; +// density, bounded phase, variation, light coupling +uniform vec4 u_fog; +uniform vec4 u_fog_motion; +uniform vec4 u_fog_opacity; +uniform vec4 u_fog_color; +// screen width, height, effect grid, enabled +uniform vec4 u_fog_grid; +// center.xy, width, intensity; up, down, grid, enabled; rgb, unused +uniform vec4 u_lamp[5]; +#include + +void main() +{ + vec2 uv = (v_texcoord0 - u_material_rect.xy) / max(u_material_rect.zw-u_material_rect.xy, vec2(0.000001)); + vec2 pixel = uv * u_fog_grid.xy; + vec2 cell = (floor(pixel / u_fog_grid.z) + vec2(0.5)) * u_fog_grid.z; + // Keep the clean art at its original 618x330 pixel density. Only animated + // modulation is cell-snapped; mask coverage retains structural edges. + vec4 clean = texture2D(s_fog_clean, uv); + vec4 mask = texture2D(s_fog_mask, uv); + float light = condenserLampAt(pixel); + vec3 lit = mix(clean.rgb, u_lamp[2].rgb, clamp(mask.g * light, 0.0, 1.0)); + float phase = u_fog.y * 6.2831853; + vec2 p = (cell / max(u_fog_motion.x, 0.001) - u_fog_motion.zw) * 6.2831853; + float coarse = sin(p.x) * cos(p.y); + float fine = sin(2.0*p.x + phase) * cos(2.0*p.y - phase); + float drift = u_fog_motion.x <= 0.0 ? 0.0 : mix(coarse, fine, u_fog_motion.y); + float density = u_fog.x * (1.0 + u_fog.z * drift) * u_fog_grid.w; + // Density changes optical depth; opacity separately fades that result. + float veil = (1.0 - pow(max(1.0-mask.r, 0.001), density)) * u_fog_opacity.x; + veil = clamp(veil, 0.0, 0.95); + // Fog scatters the same lamp footprint used by the frame. The neutral + // reference lamp is calibrated out, so density=1/variation=0 reproduces + // the authored plate instead of adding a second haze layer. + float scatter = (light - condenserReferenceLamp(pixel)) * u_fog.w; + vec3 fogColor = clamp(u_fog_color.rgb + u_lamp[2].rgb * scatter, vec3(0.0), vec3(1.0)); + gl_FragColor = vec4(mix(lit, fogColor, veil) * v_color0.rgb, clean.a * v_color0.a); +} diff --git a/examples/condenser/materials/fog/material.json b/examples/condenser/materials/fog/material.json new file mode 100644 index 00000000..bb80472d --- /dev/null +++ b/examples/condenser/materials/fog/material.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "label": "condenser_fog", + "fragment": "fs_fog.sc", + "targets": [ + "spv", + "glsl", + "essl", + "mtl" + ], + "parameters": [ + { + "name": "u_fog", + "kind": "vec4", + "count": 1, + "defaults": [ + 1, + 0, + 0.18, + 0.35 + ] + }, + { + "name": "u_fog_color", + "kind": "vec3", + "count": 1, + "defaults": [ + 0.48, + 0.65, + 0.73 + ] + }, + { + "name": "u_fog_grid", + "kind": "vec4", + "count": 1, + "defaults": [ + 618, + 330, + 6, + 1 + ] + }, + { + "name": "u_lamp", + "kind": "vec4", + "count": 5 + }, + { + "name": "u_fog_motion", + "kind": "vec4", + "count": 1, + "defaults": [ + 48, + 0.25, + 0, + 0 + ] + }, + { + "name": "u_fog_opacity", + "kind": "scalar", + "count": 1, + "defaults": [ + 1 + ] + } + ], + "textures": [ + { + "name": "s_fog_clean", + "sampler": "point" + }, + { + "name": "s_fog_mask", + "sampler": "point" + } + ], + "blend": "alpha" +} diff --git a/examples/condenser/materials/lamp/fs_lamp.sc b/examples/condenser/materials/lamp/fs_lamp.sc new file mode 100644 index 00000000..1479d655 --- /dev/null +++ b/examples/condenser/materials/lamp/fs_lamp.sc @@ -0,0 +1,24 @@ +$input v_texcoord0, v_color0 +#include +SAMPLER2D(s_tex, 0); +SAMPLER2D(s_lamp_clean, 1); +SAMPLER2D(s_lamp_mask, 2); +uniform vec4 u_material_rect; +uniform vec4 u_lamp[5]; +#include + +void main() +{ + vec2 uv = (v_texcoord0-u_material_rect.xy) / max(u_material_rect.zw-u_material_rect.xy, vec2(0.000001)); + vec2 pixel = uv * vec2(618.0, 330.0); + float light = condenserLampAt(pixel); + vec4 clean = texture2D(s_lamp_clean, uv); + vec4 mask = texture2D(s_lamp_mask, uv); + vec3 lit = mix(clean.rgb, u_lamp[2].rgb, clamp(mask.r*light, 0.0, 1.0)); + // The static tube was reconstructed out of s_lamp_clean. Never restore + // baked light outside its authored source mask or a disabled lamp's width. + vec3 source = texture2D(s_tex, v_texcoord0).rgb * u_lamp[2].rgb / vec3(0.64, 0.85, 0.94); + float emission = mask.g * condenserLampSource(pixel); + lit = mix(lit, source, emission); + gl_FragColor = vec4(lit*v_color0.rgb, clean.a*v_color0.a); +} diff --git a/examples/condenser/materials/lamp/material.json b/examples/condenser/materials/lamp/material.json new file mode 100644 index 00000000..b47e0cd0 --- /dev/null +++ b/examples/condenser/materials/lamp/material.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "label": "condenser_lamp", + "fragment": "fs_lamp.sc", + "targets": [ + "spv", + "glsl", + "essl", + "mtl" + ], + "parameters": [ + { + "name": "u_lamp", + "kind": "vec4", + "count": 5 + } + ], + "textures": [ + { + "name": "s_lamp_clean", + "sampler": "point" + }, + { + "name": "s_lamp_mask", + "sampler": "point" + } + ], + "blend": "alpha" +} diff --git a/examples/condenser/materials/mist/fs_mist.sc b/examples/condenser/materials/mist/fs_mist.sc new file mode 100644 index 00000000..5c51e677 --- /dev/null +++ b/examples/condenser/materials/mist/fs_mist.sc @@ -0,0 +1,37 @@ +$input v_texcoord0, v_color0 +#include +uniform vec4 u_material_rect; +// left, width, live surface Y, height above surface (screen pixels). +uniform vec4 u_mist_bounds; +// density, opacity, pixel grid, enabled (includes live water state). +uniform vec4 u_mist_style; +// wavelength, turbulence, wrapped translation.xy. +uniform vec4 u_mist_motion; +// RGB tint, lamp response. +uniform vec4 u_mist_color; +uniform vec4 u_lamp[5]; +#include + +void main() +{ + vec2 uv = (v_texcoord0-u_material_rect.xy) / max(u_material_rect.zw-u_material_rect.xy, vec2(0.000001)); + vec2 pixel = uv * vec2(618.0, 330.0); + vec2 cell = (floor(pixel/u_mist_style.z)+vec2(0.5))*u_mist_style.z; + float above = u_mist_bounds.z-pixel.y; + float left = pixel.x-u_mist_bounds.x; + float height = max(u_mist_bounds.w, 0.001); + // Exact bounds keep wisps out of the liquid and neighbouring chambers. + float clip = step(0.0, left)*step(left, u_mist_bounds.y)*step(0.0, above)*step(above, u_mist_bounds.w); + vec2 p = ((cell-vec2(u_mist_bounds.x,u_mist_bounds.z))/u_mist_motion.x-u_mist_motion.zw)*6.2831853; + // Periodic domain-warped wisps wrap continuously without scrolling any art. + float warp = u_mist_motion.y*sin(p.x+p.y); + float broad = sin(p.x+warp)*cos(p.y*2.0+warp); + float fine = sin(p.x*3.0-p.y*2.0)*cos(p.y*3.0+p.x); + float wisps = smoothstep(-0.55, 0.85, mix(broad, fine, u_mist_motion.y*0.45)); + float rise = clamp((u_mist_bounds.z-cell.y)/height, 0.0, 1.0); + float fade = (1.0-smoothstep(0.12,1.0,rise))*smoothstep(0.0,0.12,rise); + float edge = smoothstep(0.0,18.0,min(cell.x-u_mist_bounds.x,u_mist_bounds.x+u_mist_bounds.y-cell.x)); + float alpha = (1.0-exp(-wisps*u_mist_style.x))*u_mist_style.y*fade*edge*clip*u_mist_style.w; + vec3 color = clamp(u_mist_color.rgb+u_lamp[2].rgb*condenserLampAt(pixel)*u_mist_color.w,vec3(0.0),vec3(1.0)); + gl_FragColor = vec4(color*v_color0.rgb,alpha*v_color0.a); +} diff --git a/examples/condenser/materials/mist/material.json b/examples/condenser/materials/mist/material.json new file mode 100644 index 00000000..4046942f --- /dev/null +++ b/examples/condenser/materials/mist/material.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "label": "condenser_mist", + "fragment": "fs_mist.sc", + "targets": ["spv", "glsl", "essl", "mtl"], + "parameters": [ + {"name": "u_mist_bounds", "kind": "vec4"}, + {"name": "u_mist_style", "kind": "vec4"}, + {"name": "u_mist_motion", "kind": "vec4"}, + {"name": "u_mist_color", "kind": "vec4"}, + {"name": "u_lamp", "kind": "vec4", "count": 5} + ], + "blend": "alpha" +} diff --git a/examples/condenser/materials/shared/lamp_footprint.sc b/examples/condenser/materials/shared/lamp_footprint.sc new file mode 100644 index 00000000..73b548a1 --- /dev/null +++ b/examples/condenser/materials/shared/lamp_footprint.sc @@ -0,0 +1,35 @@ +// One footprint implementation for direct light and fog scattering. +// Caller declares uniform vec4 u_lamp[5]. All dimensions are screen pixels. +float condenserLampEnvelope(vec2 pixel, vec4 shape, vec4 reach, vec4 style) +{ + vec2 cell = (floor(pixel / reach.z) + vec2(0.5)) * reach.z; + float dy = cell.y - shape.y; + float extent = dy < 0.0 ? reach.x : reach.y; + if (shape.z <= 0.0 || extent <= 0.0 || abs(dy) >= extent) return 0.0; + float edge = shape.z * 0.5 + style.x * abs(dy) / extent - abs(cell.x - shape.x); + float h = style.y <= 0.0 ? step(0.0, edge) : clamp(edge / style.y, 0.0, 1.0); + float v = max(0.0, 1.0 - abs(dy) / extent); + return h * pow(v, style.z); +} +float condenserLampFlicker() +{ + return 1.0 - u_lamp[4].x * (0.5 + 0.5 * sin(u_lamp[4].y * 6.2831853)); +} +float condenserLampAt(vec2 pixel) +{ + return condenserLampEnvelope(pixel, u_lamp[0], u_lamp[1], u_lamp[3]) * + u_lamp[0].w * u_lamp[1].w * u_lamp[3].w * condenserLampFlicker(); +} +float condenserReferenceLamp(vec2 pixel) +{ + return condenserLampEnvelope(pixel, vec4(309.0,12.0,348.0,1.0), + vec4(12.0,132.0,6.0,1.0), vec4(0.0,6.0,2.0,1.0)); +} +float condenserLampSource(vec2 pixel) +{ + if (u_lamp[0].z <= 0.0) return 0.0; + float x = (floor(pixel.x / u_lamp[1].z) + 0.5) * u_lamp[1].z; + float edge = u_lamp[0].z * 0.5 - abs(x - u_lamp[0].x); + float h = u_lamp[3].y <= 0.0 ? step(0.0, edge) : clamp(edge / u_lamp[3].y, 0.0, 1.0); + return h * clamp(u_lamp[0].w * condenserLampFlicker(), 0.0, 1.0) * u_lamp[1].w; +} diff --git a/examples/condenser/materials/water/fs_water.sc b/examples/condenser/materials/water/fs_water.sc new file mode 100644 index 00000000..9314c55b --- /dev/null +++ b/examples/condenser/materials/water/fs_water.sc @@ -0,0 +1,114 @@ +// Game-owned port of the original water shader. Only impacts move the surface. +$input v_texcoord0, v_color0 + +#include + +SAMPLER2D(s_tex, 0); +SAMPLER2D(s_water_mask, 1); +SAMPLER2D(s_water_reflect, 2); + +uniform vec4 u_material_rect; +uniform vec4 u_water_head[2]; +uniform vec4 u_water_color[3]; +uniform vec4 u_water_params[3]; +uniform vec4 u_water_ripples[8]; + +#define WATER_MAX_RIPPLES 8 + +#define WATER_WAVE_CELLS 16.0 + +#define WATER_REFLECT_CELLS 11.0 +#define WATER_TAU 6.2831853 + +float waterQuant(float v, float g) +{ + return floor(v / g + 0.5) * g; +} + +void main() +{ + + vec4 art = texture2D(s_tex, v_texcoord0); + vec3 base_rgb = art.rgb * v_color0.rgb; + float base_a = art.a; + + vec2 span = max(u_material_rect.zw - u_material_rect.xy, vec2(1e-6, 1e-6)); + vec2 local_uv = (v_texcoord0 - u_material_rect.xy) / span; + + vec2 logical = max(u_water_head[0].xy, vec2(1.0, 1.0)); + float grid = max(u_water_head[0].z, 1.0); + float ripple_count = u_water_head[0].w; + + float has_mask = u_water_head[1].y; + float has_reflect = u_water_head[1].z; + + float level = clamp(u_water_params[0].x, 0.0, 1.0); + float time = u_water_params[0].y; + float amplitude = 0.0; + float period = max(u_water_params[0].w, 1e-4); + + float reflect_opacity = clamp(u_water_params[1].y, 0.0, 1.0); + float ripple_duration = max(u_water_params[1].z, 1e-4); + float ripple_radius = max(u_water_params[1].w, 1e-4); + float ripple_strength = u_water_params[2].x; + + vec2 local_px = local_uv * logical; + + vec2 cell = (floor(local_px / grid) + vec2(0.5, 0.5)) * grid; + + float surface_y = logical.y * (1.0 - level); + + float wave = 0.0; + + float ripple = 0.0; + for (int i = 0; i < WATER_MAX_RIPPLES; ++i) + { + if (float(i) >= ripple_count) continue; + vec4 r = u_water_ripples[i]; + float age = time - r.y; + + if (age < 0.0 || age >= ripple_duration) continue; + float dist = abs(cell.x - r.x); + float falloff = max(0.0, 1.0 - dist / ripple_radius); + float fade = 1.0 - age / ripple_duration; + ripple += ripple_strength * r.z * falloff * fade * + sin(WATER_TAU * (dist / ripple_radius - age / ripple_duration)); + } + + float offset = waterQuant(wave + ripple, grid); + float surface = surface_y + offset; + + surface = mix(surface, min(surface, surface_y), step(1.0 - 1e-6, level)); + + vec2 cell_uv = clamp(cell / logical, vec2(0.0, 0.0), vec2(1.0, 1.0)); + vec4 mask_texel = texture2D(s_water_mask, cell_uv); + float mask_a = mix(1.0, mask_texel.a * max(mask_texel.r, max(mask_texel.g, mask_texel.b)), has_mask); + + float below = step(surface, cell.y); + float coverage = mask_a * below * step(1e-6, level); + + float depth_span = max(logical.y - surface, 1e-4); + float depth = clamp((cell.y - surface) / depth_span, 0.0, 1.0); + vec3 body_rgb = mix(u_water_color[1].rgb, u_water_color[0].rgb, depth); + float body_a = mix(u_water_color[1].a, u_water_color[0].a, depth); + + float shift = 0.0; + vec2 reflect_uv = clamp( + vec2((cell.x + shift) / logical.x, cell.y / logical.y), + vec2(0.0, 0.0), vec2(1.0, 1.0)); + vec4 reflect_texel = texture2D(s_water_reflect, reflect_uv); + float reflect_mix = reflect_opacity * has_reflect * reflect_texel.a; + vec3 water_rgb = mix(body_rgb, reflect_texel.rgb, reflect_mix); + + float crest_scale = max(amplitude + abs(ripple_strength), 1e-4); + float crest = clamp(0.5 + 0.5 * (offset / crest_scale), 0.0, 1.0); + float highlight = u_water_color[2].a * (1.0 - step(grid, cell.y - surface)) * crest; + water_rgb = mix(water_rgb, u_water_color[2].rgb, highlight); + + float water_a = coverage * body_a; + float comp_a = water_a + base_a * (1.0 - water_a); + vec3 comp_pre = water_rgb * water_a + base_rgb * base_a * (1.0 - water_a); + vec3 comp_rgb = comp_a > 0.0 ? comp_pre / comp_a : vec3(0.0, 0.0, 0.0); + + gl_FragColor = vec4(comp_rgb, comp_a * v_color0.a); +} diff --git a/examples/condenser/materials/water/material.json b/examples/condenser/materials/water/material.json new file mode 100644 index 00000000..fc89495e --- /dev/null +++ b/examples/condenser/materials/water/material.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "label": "condenser_water", + "fragment": "fs_water.sc", + "targets": [ + "spv", + "glsl", + "essl", + "mtl" + ], + "parameters": [ + { + "name": "u_water_head", + "kind": "vec4", + "count": 2, + "defaults": [ + 93, + 6, + 1, + 0, + 0, + 1, + 1, + 0 + ] + }, + { + "name": "u_water_color", + "kind": "vec4", + "count": 3 + }, + { + "name": "u_water_params", + "kind": "vec4", + "count": 3 + }, + { + "name": "u_water_ripples", + "kind": "vec4", + "count": 8 + } + ], + "textures": [ + { + "name": "s_water_mask", + "sampler": "point" + }, + { + "name": "s_water_reflect", + "sampler": "point" + } + ], + "blend": "alpha" +} diff --git a/examples/condenser/prefabs/fog.jsonc b/examples/condenser/prefabs/fog.jsonc new file mode 100644 index 00000000..679fd2fb --- /dev/null +++ b/examples/condenser/prefabs/fog.jsonc @@ -0,0 +1,29 @@ +{ + "Position": { + "x": 0, + "y": 0 + }, + "Sprite": { + "sprite_name": "machine_interior.png", + "pivot": "top_left", + "layer": "machine", + "scale_x": 1, + "scale_y": 1 + }, + "FogShader": { + "unit": 0, + "enabled": true, + "density": 1, + "speed": 0.12, + "variation": 0.18, + "light_coupling": 0.35, + "grid_pixels": 6, + "opacity": 1, + "wisp_size": 48, + "turbulence": 0.25, + "drift_velocity": [ + 6, + 0 + ] + } +} diff --git a/examples/condenser/prefabs/lamp.jsonc b/examples/condenser/prefabs/lamp.jsonc new file mode 100644 index 00000000..9b251c5f --- /dev/null +++ b/examples/condenser/prefabs/lamp.jsonc @@ -0,0 +1,33 @@ +{ + "Position": { + "x": 0, + "y": 0 + }, + "Sprite": { + "sprite_name": "frame_foreground.png", + "pivot": "top_left", + "layer": "foreground", + "scale_x": 1, + "scale_y": 1, + "z_index": 1 + }, + "LampShader": { + "unit": 0, + "enabled": true, + "center": [ + 309, + 12 + ], + "width": 348, + "reach_up": 12, + "reach_down": 132, + "intensity": 1, + "grid_pixels": 6, + "spread": 0, + "softness": 6, + "falloff": 2, + "glow": 1, + "flicker": 0, + "flicker_speed": 8 + } +} diff --git a/examples/condenser/prefabs/mist.jsonc b/examples/condenser/prefabs/mist.jsonc new file mode 100644 index 00000000..c9d48b53 --- /dev/null +++ b/examples/condenser/prefabs/mist.jsonc @@ -0,0 +1,14 @@ +{ + "Position": {"x": 0, "y": 0}, + "Sprite": { + "sprite_name": "machine_interior.png", + "pivot": "top_left", "layer": "mist", "scale_x": 1, "scale_y": 1 + }, + "MistShader": { + "unit": 0, "enabled": true, "height": 54, + "density": 1.4, "opacity": 0.42, "wisp_size": 96, + "turbulence": 0.45, "drift_velocity": [12, -3], + "grid_pixels": 6, "light_coupling": 0.35, + "color": [0.48, 0.67, 0.74] + } +} diff --git a/examples/condenser/prefabs/reservoir.jsonc b/examples/condenser/prefabs/reservoir.jsonc new file mode 100644 index 00000000..cfd82582 --- /dev/null +++ b/examples/condenser/prefabs/reservoir.jsonc @@ -0,0 +1,32 @@ +// All omitted instance fields inherit; explicit zero disables its setting. +{ + "Position": { + "x": 24, + "y": 288 + }, + "Sprite": { + "sprite_name": "reservoir_art.png", + "pivot": "top_left", + "layer": "water", + "scale_x": 1, + "scale_y": 1 + }, + "WaterShader": { + "enabled": true, + "mask": "reservoir_mask", + "reflection": "reservoir_reflection", + "logical_size": [ + 93, + 6 + ], + "grid_pixels": 1, + "water_level": 0.8333, + "reflection_opacity": 0.35, + "ripple_duration_seconds": 0.9, + "ripple_radius_pixels": 14, + "ripple_strength_pixels": 1 + }, + "Reservoir": { + "unit": 0 + } +} diff --git a/examples/condenser/preview.png b/examples/condenser/preview.png new file mode 100644 index 00000000..be694f33 Binary files /dev/null and b/examples/condenser/preview.png differ diff --git a/examples/condenser/project.labelle b/examples/condenser/project.labelle new file mode 100644 index 00000000..fef59e1e --- /dev/null +++ b/examples/condenser/project.labelle @@ -0,0 +1,51 @@ +// Full-resolution PR730 art. Game-owned shaders discovered under materials/. +.{ + .name = "condenser", + .title = "LaBelle — COND-07 Condenser (game-owned water, fog and lamps)", + .width = 1236, + .height = 330, + .target_fps = 60, + .backend = .bgfx, + // Released game-shader-material stack (docs/shader-migration.md "Release + // sequence" step 6): bgfx 0.21.0 is the first contract-v2 backend and + // requires core >= 2.0.0, so these four pins move together. + .backend_package = .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.21.0" }, + .y_axis = .down, + .ecs = .zig_ecs, + .initial_prefab = "main", + .states = .{"playing"}, + // Every texture a fragment shader samples is a STANDALONE `.image` + // resource, never an atlas frame: the engine resolves a `.catalog` + // shader-texture binding through the AssetCatalog and rejects any entry + // whose `loader_kind` is not `.image` (`error.InvalidTexture`). That was + // true of the retired built-in water effect and is still true of the + // generic material path. The reservoir mask/reflection additionally span + // exactly the 93x6 logical rectangle, because the shader samples them in + // reservoir-local UV. + .resources = .{ + .{ .name = "fog_clean", .image = "assets/effects/interior_clean.png" }, + .{ .name = "fog_mask", .image = "assets/effects/fog_mask.png" }, + .{ .name = "lamp_clean", .image = "assets/effects/frame_clean.png" }, + .{ .name = "lamp_mask", .image = "assets/effects/lamp_mask.png" }, + .{ .name = "condenser", .json = "assets/condenser.json", .texture = "assets/condenser.png" }, + .{ .name = "reservoir_mask", .image = "assets/reservoir_mask_rect.png" }, + .{ .name = "reservoir_reflection", .image = "assets/layers/reflection.png" }, + }, + // SCREEN-space layers, not world: this is a fixed, pixel-exact + // composition, so a scene coordinate must be a window coordinate. On a + // WORLD layer the default camera centres the world origin in the window, + // which puts the measured native-px coordinates half a screen off — + // correct behaviour for a camera, wrong for a machine panel. + .layers = .{ + .{ .name = "machine", .order = 0, .space = .screen }, + .{ .name = "water", .order = 1, .space = .screen }, + .{ .name = "mist", .order = 2, .space = .screen }, + .{ .name = "drops", .order = 3, .space = .screen }, + .{ .name = "foreground", .order = 4, .space = .screen }, + }, + .core_version = "2.0.0", + .engine_version = "3.0.0", + .gfx_version = "2.0.0", + .labelle_version = "1.67.0", + .assembler_version = "local:../../", +} diff --git a/examples/condenser/scenes/main.jsonc b/examples/condenser/scenes/main.jsonc new file mode 100644 index 00000000..270c0415 --- /dev/null +++ b/examples/condenser/scenes/main.jsonc @@ -0,0 +1,354 @@ +// PR730 fullres 618x330 per unit; effect settings are inherited from game prefabs. +{ + "name": "main", + "children": [ + {"name":"mist_a", "prefab":"mist", "MistShader":{"unit":0}}, + {"name":"mist_b", "prefab":"mist", "Position":{"x":618,"y":0}, "MistShader":{"unit":1}}, + { + "name": "interior_a", + "prefab": "fog", + "Position": { + "x": 0, + "y": 0 + }, + "FogShader": { + "unit": 0 + } + }, + { + "prefab": "reservoir", + "name": "reservoir_a", + "Position": { + "x": 24, + "y": 288 + }, + "Reservoir": { + "unit": 0 + }, + "WaterShader": { + "water_level": 0.8333 + } + }, + { + "name": "drop_a0", + "components": { + "Position": { + "x": 150, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 0, + "local_x": 21.0, + "release_y": 2.0, + "interval": 1.4, + "phase": 0.0, + "gravity": 230.0 + } + } + }, + { + "name": "drop_a1", + "components": { + "Position": { + "x": 192, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 0, + "local_x": 28.0, + "release_y": 2.0, + "interval": 1.77, + "phase": 0.31, + "gravity": 230.0 + } + } + }, + { + "name": "drop_a2", + "components": { + "Position": { + "x": 282, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 0, + "local_x": 43.0, + "release_y": 2.0, + "interval": 2.14, + "phase": 0.62, + "gravity": 230.0 + } + } + }, + { + "name": "drop_a3", + "components": { + "Position": { + "x": 330, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 0, + "local_x": 51.0, + "release_y": 2.0, + "interval": 2.51, + "phase": 0.93, + "gravity": 230.0 + } + } + }, + { + "name": "drop_a4", + "components": { + "Position": { + "x": 414, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 0, + "local_x": 65.0, + "release_y": 2.0, + "interval": 2.88, + "phase": 1.24, + "gravity": 230.0 + } + } + }, + { + "name": "cooler_a", + "components": { + "Position": { + "x": 0, + "y": 0 + }, + "Sprite": { + "sprite_name": "cooler_foreground.png", + "pivot": "top_left", + "layer": "foreground", + "z_index": 0, + "scale_x": 1.0, + "scale_y": 1.0 + } + } + }, + { + "name": "frame_a", + "prefab": "lamp", + "Position": { + "x": 0, + "y": 0 + }, + "LampShader": { + "unit": 0 + } + }, + { + "name": "interior_b", + "prefab": "fog", + "Position": { + "x": 618, + "y": 0 + }, + "FogShader": { + "unit": 1 + } + }, + { + "prefab": "reservoir", + "name": "reservoir_b", + "Position": { + "x": 642, + "y": 288 + }, + "Reservoir": { + "unit": 1 + }, + "WaterShader": { + "water_level": 0.5 + } + }, + { + "name": "drop_b0", + "components": { + "Position": { + "x": 768, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 1, + "local_x": 21.0, + "release_y": 2.0, + "interval": 2.0, + "phase": 0.9, + "gravity": 230.0 + } + } + }, + { + "name": "drop_b1", + "components": { + "Position": { + "x": 810, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 1, + "local_x": 28.0, + "release_y": 2.0, + "interval": 2.37, + "phase": 1.21, + "gravity": 230.0 + } + } + }, + { + "name": "drop_b2", + "components": { + "Position": { + "x": 900, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 1, + "local_x": 43.0, + "release_y": 2.0, + "interval": 2.74, + "phase": 1.52, + "gravity": 230.0 + } + } + }, + { + "name": "drop_b3", + "components": { + "Position": { + "x": 948, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 1, + "local_x": 51.0, + "release_y": 2.0, + "interval": 3.11, + "phase": 1.83, + "gravity": 230.0 + } + } + }, + { + "name": "drop_b4", + "components": { + "Position": { + "x": 1032, + "y": -60 + }, + "Sprite": { + "sprite_name": "drop.png", + "pivot": "top_left", + "layer": "drops", + "scale_x": 1.0, + "scale_y": 1.0 + }, + "Drop": { + "unit": 1, + "local_x": 65.0, + "release_y": 2.0, + "interval": 3.48, + "phase": 2.14, + "gravity": 230.0 + } + } + }, + { + "name": "cooler_b", + "components": { + "Position": { + "x": 618, + "y": 0 + }, + "Sprite": { + "sprite_name": "cooler_foreground.png", + "pivot": "top_left", + "layer": "foreground", + "z_index": 0, + "scale_x": 1.0, + "scale_y": 1.0 + } + } + }, + { + "name": "frame_b", + "prefab": "lamp", + "Position": { + "x": 618, + "y": 0 + }, + "LampShader": { + "unit": 1 + } + } + ] +} diff --git a/examples/condenser/scripts/playing/05_effect_settings.zig b/examples/condenser/scripts/playing/05_effect_settings.zig new file mode 100644 index 00000000..268091cd --- /dev/null +++ b/examples/condenser/scripts/playing/05_effect_settings.zig @@ -0,0 +1,116 @@ +//! Runtime controls. Numeric zero is a value, never an unset sentinel. +const std = @import("std"); +const WaterShader = @import("../../components/water_shader.zig").WaterShader; +const FogShader = @import("../../components/fog_shader.zig").FogShader; +const LampShader = @import("../../components/lamp_shader.zig").LampShader; +const Reservoir = @import("../../components/reservoir.zig").Reservoir; +const MistShader = @import("../../components/mist_shader.zig").MistShader; +pub const game_states = .{"playing"}; +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { initialized: bool = false, frame: u32 = 0 }; +} +fn envEquals(comptime name: [:0]const u8, value: []const u8) bool { + const raw = std.c.getenv(name.ptr) orelse return false; + return std.mem.eql(u8, std.mem.span(raw), value); +} +fn envFloat(comptime name: [:0]const u8) ?f32 { + const raw = std.c.getenv(name.ptr) orelse return null; + const value = std.fmt.parseFloat(f32, std.mem.span(raw)) catch return null; + return if (std.math.isFinite(value)) value else null; +} +pub fn tick(game: anytype, state: anytype, _: anytype, _: f32) void { + state.frame +%= 1; + const ecs = &game.active_world.ecs_backend; + const test_all = state.frame == 60 and envEquals("CONDENSER_TEST_LEFT", "1"); + const change_level = game.isKeyPressed(.space) or test_all or (state.frame == 60 and envEquals("CONDENSER_TEST_CONTROL", "water")); + const splash = game.isKeyPressed(.r); + const toggle_fog = game.isKeyPressed(.f) or test_all or (state.frame == 60 and envEquals("CONDENSER_TEST_CONTROL", "fog")); + const toggle_lamp = game.isKeyPressed(.l); + const lamp_preset = game.isKeyPressed(.g) or test_all or (state.frame == 60 and envEquals("CONDENSER_TEST_CONTROL", "lamp")); + const width_step: f32 = (if (game.isKeyPressed(.e)) @as(f32, 12) else 0) - (if (game.isKeyPressed(.q)) @as(f32, 12) else 0); + const up_step: f32 = (if (game.isKeyPressed(.w)) @as(f32, 6) else 0) - (if (game.isKeyPressed(.s)) @as(f32, 6) else 0); + const down_step: f32 = (if (game.isKeyPressed(.d)) @as(f32, 12) else 0) - (if (game.isKeyPressed(.a)) @as(f32, 12) else 0); + var wv = ecs.view(.{ WaterShader, Reservoir }, .{}); + defer wv.deinit(); + while (wv.next()) |entity| { + const water = ecs.getComponent(entity, WaterShader) orelse continue; + const unit = (ecs.getComponent(entity, Reservoir) orelse continue).unit; + if (!state.initialized) { + if (envFloat("CONDENSER_WATER_LEVEL")) |v| water.setLevel(v) catch {}; + if (envFloat("CONDENSER_WATER_OFF")) |v| { + if (v != 0) water.setLevel(0) catch {}; + } + } + if (unit == 0 and change_level) water.setLevel(if (water.water_level > 0.6) 0.5 else 0.8333) catch {}; + if (unit == 0 and splash) water.impact(water.logical_size[0] * 0.5, 1) catch {}; + } + var fv = ecs.view(.{FogShader}, .{}); + defer fv.deinit(); + while (fv.next()) |entity| { + const fog = ecs.getComponent(entity, FogShader) orelse continue; + if (!state.initialized) { + var candidate = fog.*; + if (envFloat("CONDENSER_FOG_DENSITY")) |v| candidate.density = v; + if (envFloat("CONDENSER_FOG_OPACITY")) |v| candidate.opacity = v; + if (envFloat("CONDENSER_FOG_SPEED")) |v| candidate.speed = v; + if (envFloat("CONDENSER_FOG_VARIATION")) |v| candidate.variation = v; + if (envFloat("CONDENSER_FOG_WISP_SIZE")) |v| candidate.wisp_size = v; + if (envFloat("CONDENSER_FOG_TURBULENCE")) |v| candidate.turbulence = v; + if (envFloat("CONDENSER_FOG_DRIFT_X")) |v| candidate.drift_velocity[0] = v; + if (envFloat("CONDENSER_FOG_DRIFT_Y")) |v| candidate.drift_velocity[1] = v; + if (envFloat("CONDENSER_FOG_LIGHT")) |v| candidate.light_coupling = v; + if (envFloat("CONDENSER_FOG_OFF")) |v| candidate.enabled = v == 0; + candidate.validate() catch continue; + fog.* = candidate; + } + if (fog.unit == 0 and toggle_fog) fog.enabled = !fog.enabled; + } + var lv = ecs.view(.{LampShader}, .{}); + defer lv.deinit(); + while (lv.next()) |entity| { + const lamp = ecs.getComponent(entity, LampShader) orelse continue; + if (!state.initialized) { + var candidate = lamp.*; + if (envFloat("CONDENSER_LAMP_WIDTH")) |v| candidate.width = v; + if (envFloat("CONDENSER_LAMP_UP")) |v| candidate.reach_up = v; + if (envFloat("CONDENSER_LAMP_DOWN")) |v| candidate.reach_down = v; + if (envFloat("CONDENSER_LAMP_INTENSITY")) |v| candidate.intensity = v; + if (envFloat("CONDENSER_LAMP_SPREAD")) |v| candidate.spread = v; + if (envFloat("CONDENSER_LAMP_SOFTNESS")) |v| candidate.softness = v; + if (envFloat("CONDENSER_LAMP_FALLOFF")) |v| candidate.falloff = v; + if (envFloat("CONDENSER_LAMP_GLOW")) |v| candidate.glow = v; + if (envFloat("CONDENSER_LAMP_FLICKER")) |v| candidate.flicker = v; + if (envFloat("CONDENSER_LAMP_FLICKER_SPEED")) |v| candidate.flicker_speed = v; + if (envFloat("CONDENSER_LAMP_OFF")) |v| candidate.enabled = v == 0; + candidate.validate() catch continue; + lamp.* = candidate; + } + if (lamp.unit == 0 and toggle_lamp) lamp.enabled = !lamp.enabled; + if (lamp.unit == 0) { + if (lamp_preset) { + const narrow = lamp.width > 200; + lamp.width = if (narrow) 120 else 348; + lamp.reach_up = if (narrow) 0 else 12; + lamp.reach_down = if (narrow) 66 else 132; + } + if (width_step != 0) lamp.width = std.math.clamp(lamp.width + width_step, 0, 618); + if (up_step != 0) lamp.reach_up = std.math.clamp(lamp.reach_up + up_step, 0, 330); + if (down_step != 0) lamp.reach_down = std.math.clamp(lamp.reach_down + down_step, 0, 330); + } + } + var mv = ecs.view(.{MistShader}, .{}); + defer mv.deinit(); + while (mv.next()) |entity| { + const mist = ecs.getComponent(entity, MistShader) orelse continue; + if (!state.initialized) { + if (envFloat("CONDENSER_MIST_OFF")) |v| mist.enabled = v == 0; + if (envFloat("CONDENSER_MIST_OPACITY")) |v| { + if (v >= 0 and v <= 1) mist.opacity = v; + } + if (envEquals("CONDENSER_MIST_FREEZE", "1")) mist.drift_velocity = .{ 0, 0 }; + } + if (mist.unit == 0 and (game.isKeyPressed(.m) or test_all or (state.frame == 60 and envEquals("CONDENSER_TEST_CONTROL", "mist")))) mist.enabled = !mist.enabled; + } + state.initialized = true; +} diff --git a/examples/condenser/scripts/playing/10_drops.zig b/examples/condenser/scripts/playing/10_drops.zig new file mode 100644 index 00000000..ad4f2f5d --- /dev/null +++ b/examples/condenser/scripts/playing/10_drops.zig @@ -0,0 +1,116 @@ +//! Condensation drops emit one impact into the game-owned reservoir state. +const std = @import("std"); + +const Drop = @import("../../components/drop.zig").Drop; +const WaterShader = @import("../../components/water_shader.zig").WaterShader; +const Reservoir = @import("../../components/reservoir.zig").Reservoir; + +pub const game_states = .{"playing"}; + +/// Native art px per screen px — every sprite in this game is `scale 6`. +const SCALE: f32 = 6.0; +/// Measured reservoir rect on the native canvas: x 4, y 48, w 93, h 6. +const RESERVOIR_TOP_Y: f32 = 48.0; +const RESERVOIR_H: f32 = 6.0; +/// The drop trail is 5 native px tall with the bright head at the bottom. +const DROP_HEAD_OFFSET: f32 = 4.0; +/// Scene Y to park a waiting drop at — above the window, out of sight. +const PARKED_Y: f32 = -60.0; + +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { + frame: u32 = 0, + ripples: u32 = 0, + }; +} + +pub fn tick(game: anytype, state: anytype, _: anytype, dt: f32) void { + state.frame += 1; + const ecs = &game.active_world.ecs_backend; + + // Resolve unit -> reservoir entity. Two units, so a fixed-size array + // beats a map; a missing one simply parks its drops. + const Entity = @TypeOf(game.*).EntityType; + var basins: [2]?Entity = .{ null, null }; + { + var rv = ecs.view(.{Reservoir}, .{}); + defer rv.deinit(); + while (rv.next()) |e| { + const res = ecs.getComponent(e, Reservoir) orelse continue; + if (res.unit < basins.len) basins[res.unit] = e; + } + } + + var v = ecs.view(.{Drop}, .{}); + defer v.deinit(); + while (v.next()) |e| { + const drop = ecs.getComponent(e, Drop) orelse continue; + + if (!drop.started) { + // Stagger the first release rather than dropping five at once. + drop.t = -drop.phase; + drop.started = true; + } + drop.t += dt; + + const basin = if (drop.unit < basins.len) basins[drop.unit] else null; + if (basin == null) { + var p = game.getPosition(e); + p.y = PARKED_Y; + game.setPosition(e, p); + continue; + } + + // Waiting between drips: keep it off screen. + if (drop.t < 0) { + var p = game.getPosition(e); + p.y = PARKED_Y; + game.setPosition(e, p); + continue; + } + + // The LIVE fill level, read off the game-owned component every frame. + const water = ecs.getComponent(basin.?, WaterShader) orelse continue; + const level = std.math.clamp(water.water_level, 0.0, 1.0); + const surface_native = RESERVOIR_TOP_Y + RESERVOIR_H * (1.0 - level); + + // Accelerating fall, in native art px. + const y_native = drop.release_y + 0.5 * drop.gravity * drop.t * drop.t; + const head_native = y_native + DROP_HEAD_OFFSET; + + if (!drop.rippled and water.enabled and level > 0 and head_native >= surface_native) { + drop.rippled = true; + // ONE ripple per fall. `strength` is a DIMENSIONLESS [0,1] + // scale on the authored `ripple_strength_pixels`; a faster + // drop hits harder, capped at 1. + const speed = drop.gravity * drop.t; + const strength = std.math.clamp(speed / 140.0, 0.35, 1.0); + water.impact(drop.local_x, strength) catch |err| { + game.log.err("[condenser] impact(unit={d}, x={d}) failed: {s}", .{ + drop.unit, drop.local_x, @errorName(err), + }); + continue; + }; + state.ripples += 1; + } + + if (head_native >= surface_native) { + // Impact consumed: hide and wait out the interval. + drop.t = -drop.interval; + drop.rippled = false; + var p = game.getPosition(e); + p.y = PARKED_Y; + game.setPosition(e, p); + continue; + } + + var p = game.getPosition(e); + p.y = y_native * SCALE; + game.setPosition(e, p); + } + + if (state.frame % 120 == 0) { + game.log.info("[condenser] frame={d} ripples_emitted={d}", .{ state.frame, state.ripples }); + } +} diff --git a/examples/condenser/scripts/playing/20_shader_effects.zig b/examples/condenser/scripts/playing/20_shader_effects.zig new file mode 100644 index 00000000..32acbe3c --- /dev/null +++ b/examples/condenser/scripts/playing/20_shader_effects.zig @@ -0,0 +1,185 @@ +//! Retry late assets and context restoration through the generic game facade. +const materials = @import("materials"); +const WaterShader = @import("../../components/water_shader.zig").WaterShader; +const FogShader = @import("../../components/fog_shader.zig").FogShader; +const LampShader = @import("../../components/lamp_shader.zig").LampShader; +const MistShader = @import("../../components/mist_shader.zig").MistShader; +const Reservoir = @import("../../components/reservoir.zig").Reservoir; +pub const game_states = .{"playing"}; +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { frame: u32 = 0 }; +} + +pub fn tick(game: anytype, state: anytype, _: anytype, dt: f32) void { + state.frame +%= 1; + const ecs = &game.active_world.ecs_backend; + var wv = ecs.view(.{WaterShader}, .{}); + defer wv.deinit(); + while (wv.next()) |entity| { + const water = ecs.getComponent(entity, WaterShader) orelse continue; + water.advance(dt) catch |err| { + report(game, state.frame, "water state", err); + continue; + }; + bindWater(game, entity, water) catch |err| report(game, state.frame, "water material", err); + } + var lv = ecs.view(.{LampShader}, .{}); + defer lv.deinit(); + while (lv.next()) |entity| { + const lamp = ecs.getComponent(entity, LampShader) orelse continue; + lamp.advance(dt) catch |err| { + report(game, state.frame, "lamp state", err); + continue; + }; + bindLamp(game, entity, lamp) catch |err| report(game, state.frame, "lamp material", err); + } + var fv = ecs.view(.{FogShader}, .{}); + defer fv.deinit(); + while (fv.next()) |entity| { + const fog = ecs.getComponent(entity, FogShader) orelse continue; + fog.advance(dt) catch |err| { + report(game, state.frame, "fog state", err); + continue; + }; + var light = LampShader{ .enabled = false }; + var lights = ecs.view(.{LampShader}, .{}); + defer lights.deinit(); + while (lights.next()) |le| { + const candidate = ecs.getComponent(le, LampShader) orelse continue; + if (candidate.unit == fog.unit) { + light = candidate.*; + break; + } + } + bindFog(game, entity, fog, light) catch |err| report(game, state.frame, "fog material", err); + } + var mv = ecs.view(.{MistShader}, .{}); + defer mv.deinit(); + while (mv.next()) |entity| { + const mist = ecs.getComponent(entity, MistShader) orelse continue; + mist.advance(dt) catch |err| { + report(game, state.frame, "mist state", err); + continue; + }; + var bounds: [4]f32 = .{ 0, 0, 0, mist.height }; + var wet = false; + var reservoirs = ecs.view(.{ WaterShader, Reservoir }, .{}); + defer reservoirs.deinit(); + while (reservoirs.next()) |re| { + const res = ecs.getComponent(re, Reservoir) orelse continue; + if (res.unit != mist.unit) continue; + const water = ecs.getComponent(re, WaterShader) orelse continue; + const pos = game.getPosition(re); + const origin = game.getPosition(entity); + // Reservoir art is 558x36 screen pixels, or 93x6 water cells. + bounds = .{ pos.x - origin.x, 558, MistShader.surfaceY(pos.y - origin.y, 36, water.water_level), mist.height }; + wet = water.enabled and water.water_level > 0; + break; + } + var light = LampShader{ .enabled = false }; + var lights = ecs.view(.{LampShader}, .{}); + defer lights.deinit(); + while (lights.next()) |le| { + const candidate = ecs.getComponent(le, LampShader) orelse continue; + if (candidate.unit == mist.unit) { + light = candidate.*; + break; + } + } + bindMist(game, entity, mist, bounds, wet, light) catch |err| report(game, state.frame, "mist material", err); + } +} + +fn report(game: anytype, frame: u32, effect: []const u8, err: anyerror) void { + if (err == error.TextureNotReady and frame <= 300) return; + if (frame == 1 or frame % 120 == 0) game.log.err("[condenser] {s}: {s}", .{ effect, @errorName(err) }); +} + +pub fn lampParameters(lamp: LampShader) [20]f32 { + return .{ lamp.center[0], lamp.center[1], lamp.width, lamp.intensity, lamp.reach_up, lamp.reach_down, lamp.grid_pixels, if (lamp.enabled) 1 else 0, lamp.color[0], lamp.color[1], lamp.color[2], 0, lamp.spread, lamp.softness, lamp.falloff, lamp.glow, lamp.flicker, lamp.phase, 0, 0 }; +} + +pub fn bindWater(game: anytype, entity: anytype, w: *WaterShader) !void { + try w.validate(); + if (game.shaderMaterial(entity) == null) { + try game.createShaderMaterial(entity, .{ + .label = "condenser_water", + .shaders = materials.water.shaders, + .parameters = &materials.water.parameters, + .textures = &.{ + .{ .name = "s_water_mask", .texture = .{ .catalog = w.mask }, .sampler = .point }, + .{ .name = "s_water_reflect", .texture = .{ .catalog = w.reflection }, .sampler = .point }, + }, + }); + } + try game.setShaderTexture(entity, "s_water_mask", w.mask); + try game.setShaderTexture(entity, "s_water_reflect", w.reflection); + try game.setShaderParameter(entity, "u_water_head", &.{ w.logical_size[0], w.logical_size[1], w.grid_pixels, @floatFromInt(w.ripple_count), 0, 1, 1, 0 }); + var colors: [12]f32 = undefined; + @memcpy(colors[0..4], &w.deep_color); + @memcpy(colors[4..8], &w.surface_color); + @memcpy(colors[8..12], &w.highlight_color); + try game.setShaderParameter(entity, "u_water_color", &colors); + try game.setShaderParameter(entity, "u_water_params", &.{ + if (w.enabled) w.water_level else 0, w.time, 0, 3, + 0, w.reflection_opacity, w.ripple_duration_seconds, w.ripple_radius_pixels, + w.ripple_strength_pixels, 0, 0, 0, + }); + const ripples: *const [32]f32 = @ptrCast(&w.ripples); + try game.setShaderParameter(entity, "u_water_ripples", ripples); +} + +pub fn bindFog(game: anytype, entity: anytype, f: *FogShader, light: LampShader) !void { + try f.validate(); + try light.validate(); + if (game.shaderMaterial(entity) == null) { + try game.createShaderMaterial(entity, .{ + .label = "condenser_fog", + .shaders = materials.fog.shaders, + .parameters = &materials.fog.parameters, + .textures = &.{ + .{ .name = "s_fog_clean", .texture = .{ .catalog = "fog_clean" }, .sampler = .point }, + .{ .name = "s_fog_mask", .texture = .{ .catalog = "fog_mask" }, .sampler = .point }, + }, + }); + } + try game.setShaderParameter(entity, "u_fog", &.{ f.density, f.phase, f.variation, f.light_coupling }); + try game.setShaderParameter(entity, "u_fog_motion", &.{ f.wisp_size, f.turbulence, f.drift_phase[0], f.drift_phase[1] }); + try game.setShaderParameter(entity, "u_fog_opacity", &.{f.opacity}); + try game.setShaderParameter(entity, "u_fog_color", &f.color); + try game.setShaderParameter(entity, "u_fog_grid", &.{ 618, 330, f.grid_pixels, if (f.enabled) 1 else 0 }); + try game.setShaderParameter(entity, "u_lamp", &lampParameters(light)); +} + +pub fn bindLamp(game: anytype, entity: anytype, light: *LampShader) !void { + try light.validate(); + if (game.shaderMaterial(entity) == null) { + try game.createShaderMaterial(entity, .{ + .label = "condenser_lamp", + .shaders = materials.lamp.shaders, + .parameters = &materials.lamp.parameters, + .textures = &.{ + .{ .name = "s_lamp_clean", .texture = .{ .catalog = "lamp_clean" }, .sampler = .point }, + .{ .name = "s_lamp_mask", .texture = .{ .catalog = "lamp_mask" }, .sampler = .point }, + }, + }); + } + try game.setShaderParameter(entity, "u_lamp", &lampParameters(light.*)); +} + +pub fn bindMist(game: anytype, entity: anytype, m: *MistShader, bounds: [4]f32, wet: bool, light: LampShader) !void { + try m.validate(); + if (game.shaderMaterial(entity) == null) { + try game.createShaderMaterial(entity, .{ + .label = "condenser_mist", + .shaders = materials.mist.shaders, + .parameters = &materials.mist.parameters, + }); + } + try game.setShaderParameter(entity, "u_mist_bounds", &bounds); + try game.setShaderParameter(entity, "u_mist_style", &.{ m.density, m.opacity, m.grid_pixels, if (m.enabled and wet and m.height > 0) 1 else 0 }); + try game.setShaderParameter(entity, "u_mist_motion", &.{ m.wisp_size, m.turbulence, m.phase[0], m.phase[1] }); + try game.setShaderParameter(entity, "u_mist_color", &.{ m.color[0], m.color[1], m.color[2], m.light_coupling }); + try game.setShaderParameter(entity, "u_lamp", &lampParameters(light)); +} diff --git a/examples/condenser/scripts/playing/30_water_probe.zig b/examples/condenser/scripts/playing/30_water_probe.zig new file mode 100644 index 00000000..034d091f --- /dev/null +++ b/examples/condenser/scripts/playing/30_water_probe.zig @@ -0,0 +1,26 @@ +//! Query actual generic material bindings, not game-side success flags. +const WaterShader = @import("../../components/water_shader.zig").WaterShader; +const FogShader = @import("../../components/fog_shader.zig").FogShader; +const LampShader = @import("../../components/lamp_shader.zig").LampShader; +const MistShader = @import("../../components/mist_shader.zig").MistShader; +pub const game_states = .{"playing"}; +pub fn State(comptime EcsBackend: type) type { + _ = EcsBackend; + return struct { frame: u32 = 0 }; +} +pub fn tick(game: anytype, state: anytype, _: anytype, _: f32) void { + state.frame +%= 1; + if (state.frame != 30 and state.frame % 600 != 0) return; + const ecs = &game.active_world.ecs_backend; + inline for (.{ WaterShader, FogShader, LampShader, MistShader }) |T| { + var view = ecs.view(.{T}, .{}); + defer view.deinit(); + var live: u32 = 0; + var total: u32 = 0; + while (view.next()) |entity| { + total += 1; + if (game.shaderMaterial(entity) != null) live += 1; + } + game.log.info("[condenser] {s}: {d}/{d} generic materials live", .{ @typeName(T), live, total }); + } +} diff --git a/examples/condenser/test_runner.zig b/examples/condenser/test_runner.zig new file mode 100644 index 00000000..d134035f --- /dev/null +++ b/examples/condenser/test_runner.zig @@ -0,0 +1,3 @@ +test { + _ = @import("tests/effects_test.zig"); +} diff --git a/examples/condenser/tests/controls_test.zig b/examples/condenser/tests/controls_test.zig new file mode 100644 index 00000000..d33ed672 --- /dev/null +++ b/examples/condenser/tests/controls_test.zig @@ -0,0 +1,64 @@ +const std = @import("std"); +const controls = @import("../scripts/playing/05_effect_settings.zig"); +const Water = @import("../components/water_shader.zig").WaterShader; +const Fog = @import("../components/fog_shader.zig").FogShader; +const Lamp = @import("../components/lamp_shader.zig").LampShader; +const Reservoir = @import("../components/reservoir.zig").Reservoir; +const Mist = @import("../components/mist_shader.zig").MistShader; +const Key = enum { m, space, r, f, l, g, q, e, w, s, a, d }; +const Ecs = struct { + waters: [2]Water = .{ .{}, .{ .water_level = 0.5 } }, + fogs: [2]Fog = .{ .{}, .{ .unit = 1 } }, + lamps: [2]Lamp = .{ .{}, .{ .unit = 1 } }, + mists: [2]Mist = .{ .{}, .{ .unit = 1 } }, + reservoirs: [2]Reservoir = .{ .{}, .{ .unit = 1 } }, + const View = struct { + index: usize = 0, + pub fn next(self: *@This()) ?usize { + if (self.index == 2) return null; + defer self.index += 1; + return self.index; + } + pub fn deinit(_: *@This()) void {} + }; + pub fn view(_: *@This(), comptime _: anytype, comptime _: anytype) View { + return .{}; + } + pub fn getComponent(self: *@This(), e: usize, comptime T: type) ?*T { + if (T == Mist) return &self.mists[e]; + if (T == Water) return &self.waters[e]; + if (T == Fog) return &self.fogs[e]; + if (T == Lamp) return &self.lamps[e]; + if (T == Reservoir) return &self.reservoirs[e]; + @compileError("unexpected component"); + } +}; +const Game = struct { + active_world: struct { ecs_backend: Ecs = .{} } = .{}, + key: Key = .space, + pub fn isKeyPressed(self: *@This(), key: Key) bool { + return self.key == key; + } +}; + +test "real runtime keyboard paths change left only including lamp geometry" { + var game = Game{}; + var state = controls.State(Ecs){ .initialized = true }; + const right_water = game.active_world.ecs_backend.waters[1]; + const right_fog = game.active_world.ecs_backend.fogs[1]; + const right_lamp = game.active_world.ecs_backend.lamps[1]; + for ([_]Key{ .space, .f, .m, .g, .e, .w, .d }) |key| { + game.key = key; + controls.tick(&game, &state, {}, 0); + } + const ecs = &game.active_world.ecs_backend; + try std.testing.expectEqual(@as(f32, 0.5), ecs.waters[0].water_level); + try std.testing.expect(!ecs.fogs[0].enabled); + try std.testing.expect(!ecs.mists[0].enabled and ecs.mists[1].enabled); + try std.testing.expectEqual(@as(f32, 132), ecs.lamps[0].width); + try std.testing.expectEqual(@as(f32, 6), ecs.lamps[0].reach_up); + try std.testing.expectEqual(@as(f32, 78), ecs.lamps[0].reach_down); + try std.testing.expect(std.meta.eql(right_water, ecs.waters[1])); + try std.testing.expect(std.meta.eql(right_fog, ecs.fogs[1])); + try std.testing.expect(std.meta.eql(right_lamp, ecs.lamps[1])); +} diff --git a/examples/condenser/tests/effects_test.zig b/examples/condenser/tests/effects_test.zig new file mode 100644 index 00000000..8b9611bf --- /dev/null +++ b/examples/condenser/tests/effects_test.zig @@ -0,0 +1,212 @@ +const std = @import("std"); +const expect = std.testing.expect; +const equal = std.testing.expectEqual; +const Water = @import("../components/water_shader.zig").WaterShader; +const Fog = @import("../components/fog_shader.zig").FogShader; +const Lamp = @import("../components/lamp_shader.zig").LampShader; + +test "water rejects bad impacts, clamps strength, and isolates instances" { + var a = Water{}; + const b = Water{}; + try std.testing.expectError(error.RippleOutOfBounds, a.impact(-1, 1)); + try std.testing.expectError(error.RippleOutOfBounds, a.impact(93, 1)); + try std.testing.expectError(error.NonFiniteValue, a.impact(std.math.nan(f32), 1)); + try a.impact(92.999, 2); + try equal(@as(f32, 1), a.ripples[0][2]); + try equal(@as(usize, 0), b.ripple_count); + try equal(b.water_level, a.water_level); +} + +test "expiry boundary is permanent after duration shortens then lengthens" { + var w = Water{}; + try w.impact(20, 1); + try w.advance(0.5); + try w.patch(.{ .ripple_duration_seconds = @as(f32, 0.5) }); + try equal(@as(usize, 0), w.ripple_count); + try w.patch(.{ .ripple_duration_seconds = @as(f32, 2) }); + try equal(@as(usize, 0), w.ripple_count); + try equal([4]f32{ 0, 0, 0, 0 }, w.ripples[0]); +} + +test "shrinking width retires only out of bounds impacts" { + var w = Water{}; + try w.impact(12, 1); + try w.impact(60, 1); + try w.patch(.{ .logical_size = [2]f32{ 60, 6 } }); + try equal(@as(usize, 1), w.ripple_count); + try equal(@as(f32, 12), w.ripples[0][0]); + try w.patch(.{ .logical_size = [2]f32{ 93, 6 } }); + try equal(@as(usize, 1), w.ripple_count); +} + +test "rebase preserves impact ages and pause never advances time" { + var w = Water{ .time = 4095.75 }; + try w.impact(10, 1); + try w.advance(0.5); + try equal(@as(f32, 0), w.time); + try equal(@as(f32, 0.5), w.time - w.ripples[0][1]); + const before = w; + try w.advance(0); + try expect(std.meta.eql(before, w)); + try w.advance(0.4); + try equal(@as(usize, 0), w.ripple_count); +} + +test "oldest ripple replacement has deterministic tie breaks" { + var w = Water{}; + for (0..8) |i| try w.impact(@floatFromInt(i), 1); + try w.advance(0.1); + try w.impact(30, 1); + try equal(@as(f32, 30), w.ripples[0][0]); + try w.impact(31, 1); + try equal(@as(f32, 31), w.ripples[1][0]); + try equal(@as(usize, 8), w.ripple_count); +} + +test "empty water clears impacts and refill does not resurrect" { + var w = Water{}; + try w.impact(3, 1); + try w.setLevel(-1); + try std.testing.expectError(error.EmptyReservoir, w.impact(3, 1)); + try w.setLevel(2); + try equal(@as(f32, 1), w.water_level); + try equal(@as(usize, 0), w.ripple_count); +} + +test "invalid runtime edits and overflow leave previous state intact" { + var w = Water{}; + try w.impact(20, 1); + const before = w; + try std.testing.expectError(error.InvalidPositiveSetting, w.patch(.{ .ripple_duration_seconds = @as(f32, 0) })); + try std.testing.expectError(error.NonFiniteValue, w.advance(std.math.inf(f32))); + try std.testing.expectError(error.NegativeDelta, w.advance(-1)); + try expect(std.meta.eql(before, w)); + w.time = std.math.floatMax(f32); + try std.testing.expectError(error.NonFiniteValue, w.advance(std.math.floatMax(f32))); + try equal(std.math.floatMax(f32), w.time); +} + +test "partial optional patch inherits missing and preserves explicit zero false" { + var w = Water{ .reflection_opacity = 0.7, .water_level = 0.5 }; + const Patch = struct { water_level: ?f32 = null, reflection_opacity: ?f32 = null, enabled: ?bool = null }; + try w.patch(Patch{ .reflection_opacity = 0, .enabled = false }); + try equal(@as(f32, 0.5), w.water_level); + try equal(@as(f32, 0), w.reflection_opacity); + try expect(!w.enabled); +} + +test "lamp width and asymmetric reaches are independent and cell aligned" { + var l = Lamp{ .center = .{ 51, 51 }, .width = 60, .reach_up = 12, .reach_down = 60 }; + try expect(l.influence(.{ 51, 33 }) == 0); + try expect(l.influence(.{ 51, 69 }) > 0); + try equal(l.influence(.{ 49, 67 }), l.influence(.{ 53, 71 })); + try expect(l.influence(.{ 87, 51 }) == 0); + l.reach_down = 0; + try expect(l.influence(.{ 51, 57 }) == 0); + try expect(l.influence(.{ 51, 45 }) > 0); + l.width = 0; + try equal(@as(f32, 0), l.influence(.{ 51, 45 })); + try l.validate(); +} + +test "fog zero speed pauses phase, wrap is bounded, invalid delta atomic" { + var f = Fog{ .phase = 0.95, .speed = 0.1 }; + try f.advance(1); + try std.testing.expectApproxEqAbs(@as(f32, 0.05), f.phase, 0.00001); + f.speed = 0; + const before = f.phase; + try f.advance(100); + try equal(before, f.phase); + try std.testing.expectError(error.InvalidDelta, f.advance(std.math.nan(f32))); + try equal(before, f.phase); + f.density = 0; + f.light_coupling = 0; + try f.validate(); +} + +test "malformed authored runtime water state reports errors before slicing" { + var w = Water{ .ripple_count = 9 }; + try std.testing.expectError(error.InvalidRippleCount, w.advance(0)); + try std.testing.expectError(error.InvalidRippleCount, w.impact(1, 1)); + try std.testing.expectError(error.InvalidRippleCount, w.setLevel(0)); + w.ripple_count = 0; + w.time = std.math.nan(f32); + try std.testing.expectError(error.NonFiniteValue, w.impact(1, 1)); + w.time = 0; + w.ripples[7][1] = std.math.inf(f32); + try std.testing.expectError(error.NonFiniteValue, w.advance(0)); +} + +test "fog drift direction zero negative and zero wisp size are safe" { + var f = Fog{ .speed = 1, .wisp_size = 48, .drift_velocity = .{ 0, 0 } }; + try f.advance(0.5); + try equal([2]f32{ 0, 0 }, f.drift_phase); + f.drift_velocity = .{ 12, -12 }; + try f.advance(1); + try equal([2]f32{ 0.25, 0.75 }, f.drift_phase); + f.wisp_size = 0; + try f.advance(1); + try equal([2]f32{ 0.25, 0.75 }, f.drift_phase); + f.wisp_size = 48; + f.speed = 0; + const before = f; + try f.advance(10); + try expect(std.meta.eql(before, f)); + f.phase = std.math.nan(f32); + try std.testing.expectError(error.InvalidPhase, f.advance(0)); +} + +test "lamp spread expands away from source, zero softness and falloff work" { + var l = Lamp{ .center = .{ 51, 51 }, .width = 24, .reach_down = 60, .softness = 0, .falloff = 0 }; + try equal(@as(f32, 1), l.influence(.{ 51, 99 })); + try equal(@as(f32, 0), l.influence(.{ 81, 99 })); + l.spread = 48; + try equal(@as(f32, 1), l.influence(.{ 81, 99 })); + l.width = 0; + try equal(@as(f32, 0), l.influence(.{ 51, 99 })); + try l.validate(); +} + +test "lamp glow zero disables halo and flicker is bounded and paused" { + var l = Lamp{ .flicker = 0.6, .flicker_speed = 1 }; + for (0..100) |_| { + try l.advance(0.013); + try expect(l.flickerFactor() >= 0.4 - 0.00001 and l.flickerFactor() <= 1); + } + l.flicker = 0; + try equal(@as(f32, 1), l.flickerFactor()); + l.flicker_speed = 0; + const before = l.phase; + try l.advance(100); + try equal(before, l.phase); + l.glow = 0; + try equal(@as(f32, 0), l.influence(.{ 309, 30 })); + l.phase = std.math.inf(f32); + try std.testing.expectError(error.InvalidPhase, l.advance(0)); +} + +const Mist = @import("../components/mist_shader.zig").MistShader; +test "mist anchors to live surface and has bounded independent signed motion" { + try equal(@as(f32, 294), Mist.surfaceY(288, 36, 5.0 / 6.0)); + try equal(@as(f32, 306), Mist.surfaceY(288, 36, 0.5)); + var a = Mist{ .wisp_size = 96, .drift_velocity = .{ 12, -3 } }; + const b = a; + try a.advance(8); + try equal([2]f32{ 0, 0.75 }, a.phase); + try equal([2]f32{ 0, 0 }, b.phase); + a.drift_velocity = .{ 0, 0 }; + const before = a; + try a.advance(100); + try expect(std.meta.eql(before, a)); + try std.testing.expectError(error.InvalidDelta, a.advance(std.math.nan(f32))); + try expect(std.meta.eql(before, a)); +} +test "mist accepts explicit zero controls and rejects malformed state" { + var m = Mist{ .height = 0, .density = 0, .opacity = 0, .light_coupling = 0 }; + try m.validate(); + m.wisp_size = 0; + try std.testing.expectError(error.InvalidMistSetting, m.advance(1)); + m.wisp_size = 96; + m.drift_velocity[0] = std.math.inf(f32); + try std.testing.expectError(error.InvalidMistSetting, m.advance(1)); +} diff --git a/examples/condenser/tests/prefab_test.zig b/examples/condenser/tests/prefab_test.zig new file mode 100644 index 00000000..52364cd1 --- /dev/null +++ b/examples/condenser/tests/prefab_test.zig @@ -0,0 +1,42 @@ +const std = @import("std"); +const engine = @import("labelle-engine"); +const Water = @import("../components/water_shader.zig").WaterShader; +const Fog = @import("../components/fog_shader.zig").FogShader; +const Lamp = @import("../components/lamp_shader.zig").LampShader; + +fn merged(comptime T: type, comptime file: []const u8, key: []const u8, patch: []const u8, allocator: std.mem.Allocator) !T { + var base_parser = engine.jsonc_mod.JsoncParser.init(allocator, @embedFile(file)); + const base = try base_parser.parse(); + var patch_parser = engine.jsonc_mod.JsoncParser.init(allocator, patch); + const override = try patch_parser.parse(); + const value = try engine.unified_format.mergedOverride(base.asObject(), key, override, allocator); + return engine.jsonc_deserializer.deserialize(T, value, allocator) orelse error.InvalidComponent; +} + +test "real prefab merge preserves water defaults and explicit zero" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const water = try merged(Water, "../prefabs/reservoir.jsonc", "WaterShader", "{\"water_level\":0,\"reflection_opacity\":0}", arena.allocator()); + try std.testing.expectEqual(@as(f32, 0), water.water_level); + try std.testing.expectEqual(@as(f32, 0), water.reflection_opacity); + try std.testing.expectEqual(@as(f32, 14), water.ripple_radius_pixels); + try std.testing.expectEqualStrings("reservoir_mask", water.mask); +} + +test "real fog and lamp prefab partial overrides retain omitted fields" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const fog = try merged(Fog, "../prefabs/fog.jsonc", "FogShader", "{\"opacity\":0,\"density\":0,\"wisp_size\":0,\"drift_velocity\":[0,0],\"enabled\":false}", arena.allocator()); + try std.testing.expect(!fog.enabled); + try std.testing.expectEqual(@as(f32, 0), fog.opacity); + try std.testing.expectEqual(@as(f32, 0), fog.density); + try std.testing.expectEqual(@as(f32, 0.25), fog.turbulence); + try std.testing.expectEqual([2]f32{ 0, 0 }, fog.drift_velocity); + const lamp = try merged(Lamp, "../prefabs/lamp.jsonc", "LampShader", "{\"width\":0,\"reach_up\":0,\"glow\":0,\"softness\":0,\"falloff\":0}", arena.allocator()); + try std.testing.expectEqual(@as(f32, 0), lamp.width); + try std.testing.expectEqual(@as(f32, 0), lamp.reach_up); + try std.testing.expectEqual(@as(f32, 132), lamp.reach_down); + try std.testing.expectEqual(@as(f32, 0), lamp.glow); + try std.testing.expectEqual(@as(f32, 0), lamp.softness); + try std.testing.expectEqual(@as(f32, 0), lamp.falloff); +} diff --git a/examples/condenser/tools/compile_shaders.ps1 b/examples/condenser/tools/compile_shaders.ps1 new file mode 100644 index 00000000..8e3265e9 --- /dev/null +++ b/examples/condenser/tools/compile_shaders.ps1 @@ -0,0 +1,26 @@ +param( + [string]$Shaderc = $env:LABELLE_SHADERC, + [string]$Include = $env:LABELLE_SHADER_INCLUDE +) +$ErrorActionPreference = 'Stop' +if (-not $Shaderc -or -not $Include) { throw 'Supply -Shaderc and -Include (directory containing bgfx_shader.sh).' } +$root = Split-Path -Parent $PSScriptRoot +$outDir = Join-Path $root '.test-output/shaders' +New-Item -ItemType Directory -Force -Path $outDir | Out-Null +$varying = Join-Path $PSScriptRoot 'sprite_varying.def.sc' +$profiles = @{ + spv = @('linux', 'spirv'); glsl = @('linux', '120') + essl = @('android', '300_es'); mtl = @('osx', 'metal') + dx11 = @('windows', 's_5_0') +} +foreach ($effect in @('water', 'fog', 'lamp', 'mist')) { + $dir = Join-Path $root "materials/$effect" + $descriptor = Get-Content -Raw -LiteralPath (Join-Path $dir 'material.json') | ConvertFrom-Json + foreach ($target in $descriptor.targets) { + $profile = $profiles[$target] + $output = Join-Path $outDir "$effect.$target.bin" + & $Shaderc -f (Join-Path $dir $descriptor.fragment) -o $output --type fragment --platform $profile[0] -p $profile[1] -O 3 --varyingdef $varying -i $Include -i (Join-Path $root 'materials') + if ($LASTEXITCODE -ne 0) { throw "Shader compile failed: $effect/$target" } + Write-Host "$effect/$target compiled" + } +} diff --git a/examples/condenser/tools/derive_effect_masks.py b/examples/condenser/tools/derive_effect_masks.py new file mode 100644 index 00000000..fb58cc95 --- /dev/null +++ b/examples/condenser/tools/derive_effect_masks.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Deterministic effect decomposition of the PR730 618x330 plate. + +Only existing layers derived from the authorized condenser-detail crop are read. +This is an estimated clean plate, not a claim to recover unseen original art. +The inverse veil preserves full-resolution structure and reconstructs the input +within one byte when recomposited. No blur, resampling, or full-room reference. +""" +from pathlib import Path +import json +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'assets/effects' +FOG_COLOR = np.array([0.48, 0.65, 0.73]) +LAMP_COLOR = np.array([0.64, 0.85, 0.94]) + + +def load(name): + a = np.asarray(Image.open(ROOT / 'assets/layers' / name).convert('RGBA')) + assert a.shape == (330, 618, 4) + return a.astype(np.float64) / 255 + + +def write(name, array): + Image.fromarray(np.rint(np.clip(array, 0, 1) * 255).astype(np.uint8)).save(OUT / name) + + +def derive(): + OUT.mkdir(exist_ok=True) + interior = load('machine_interior.png') + frame = load('frame_foreground.png') + y, x = np.mgrid[:330, :618] + # Masks are aligned to the six-screen-pixel EFFECT grid. Original artwork + # stays at full density; no downsample/upsample of structural edges. + cx = (x // 6 + .5) * 6 + cy = (y // 6 + .5) * 6 + horizontal = np.clip((348 / 2 - abs(cx - 309)) / 6, 0, 1) + reach = np.where(cy < 12, 12, 132) + light = horizontal * np.clip(1 - abs(cy - 12) / reach, 0, 1) ** 2 + # Detach the baked light from both static surfaces. A bounded attenuation + # retains texture detail and keeps the inverse reconstruction in gamut. + glow = light * .32 + lower = np.exp(-((cy - 258) / 30) ** 2) + shoulders = .6 + .4 * np.cos((cx - 270) / 108) ** 2 + upper = .10 * np.exp(-((cy - 60) / 42) ** 2) + veil = np.clip(.36 * lower * shoulders + upper, 0, .46) + veil *= interior[:, :, 3] + # Exact inverse of over: base=(art-color*a)/(1-a). Bound a against the + # observed RGB so no black clipping destroys reconstruction details. + veil = np.minimum(veil, np.min(interior[:, :, :3] / FOG_COLOR, axis=2) * .85) + veil = np.minimum(veil, np.min((1-interior[:, :, :3]) / (1-FOG_COLOR), axis=2) * .85) + fog_clean = (interior[:, :, :3] - FOG_COLOR * veil[:, :, None]) / (1 - veil[:, :, None]) + interior_glow = np.minimum(glow, np.min(fog_clean / LAMP_COLOR, axis=2) * .8) + interior_glow = np.minimum(interior_glow, np.min((1-fog_clean) / (1-LAMP_COLOR), axis=2) * .8) + clean = (fog_clean - LAMP_COLOR * interior_glow[:, :, None]) / (1 - interior_glow[:, :, None]) + frame_glow = np.minimum(glow, np.min(frame[:, :, :3] / LAMP_COLOR, axis=2) * .8) + frame_glow = np.minimum(frame_glow, np.min((1-frame[:, :, :3]) / (1-LAMP_COLOR), axis=2) * .8) + frame_clean = (frame[:, :, :3] - LAMP_COLOR * frame_glow[:, :, None]) / (1 - frame_glow[:, :, None]) + # The physical tube is baked into the top frame, not just its halo. Remove + # it using the unlit casing immediately above; retain its exact artwork as + # the sprite sampler's emissive source. The shader restores it ONLY where + # G is set and the runtime width/intensity permit it. + tube = (x >= 138) & (x < 480) & (y >= 6) & (y < 18) + tube &= (np.max(frame[:, :, :3], axis=2) > .40) & (frame[:, :, 3] > 0) + casing = np.median(frame[:6, :, :3], axis=0) + frame_clean[tube] = np.broadcast_to(casing, frame_clean.shape)[tube] + # The mask stores the reconstruction's response coefficient, independent + # of the default lamp footprint. Width/reaches can therefore change live. + response_i = np.divide(interior_glow, light, out=np.full_like(light, .32), where=light > 0) + response_f = np.divide(frame_glow, light, out=np.full_like(light, .32), where=light > 0) + write('interior_clean.png', np.dstack((clean, interior[:, :, 3]))) + write('frame_clean.png', np.dstack((frame_clean, frame[:, :, 3]))) + write('fog_mask.png', np.dstack((veil, response_i, np.zeros_like(veil), interior[:, :, 3]))) + write('lamp_mask.png', np.dstack((response_f, tube.astype(float), np.zeros_like(veil), frame[:, :, 3]))) + reconstructed_light = clean * (1 - interior_glow[:, :, None]) + LAMP_COLOR * interior_glow[:, :, None] + reconstructed = reconstructed_light * (1 - veil[:, :, None]) + FOG_COLOR * veil[:, :, None] + assert np.max(abs(reconstructed - interior[:, :, :3])) < 1e-12 + metadata = dict(size=[618, 330], effect_grid=6, source='PR730 full-resolution layers from condenser-detail.gif (620x330, crop left 2px)', + fog_color=FOG_COLOR.tolist(), lamp_color=LAMP_COLOR.tolist(), + fog_mask='R=estimated baked veil, G=lamp response, A=interior coverage', + lamp_mask='R=lamp response, G=physical emissive tube coverage, A=frame coverage', + method='bounded inverse-over decomposition; estimated clean background, full-resolution structure retained') + (OUT / 'provenance.json').write_text(json.dumps(metadata, indent=2) + '\n') + print('Effect masks and clean plates: 618x330; inverse reconstruction verified') + + +if __name__ == '__main__': + derive() diff --git a/examples/condenser/tools/derive_layers.py b/examples/condenser/tools/derive_layers.py new file mode 100644 index 00000000..adaedc96 --- /dev/null +++ b/examples/condenser/tools/derive_layers.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Rebuild at reference pixel density (Pillow + numpy), without spatial filtering. + +Temporal median removes animated drops. Only two left border columns are +cropped to retain the existing 618x330 scene geometry. Mist remains static. +""" +from pathlib import Path +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +LAYERS = ROOT / 'assets/layers' +with Image.open(ROOT / 'assets/reference/condenser-detail.gif') as gif: + assert gif.size == (620, 330) + frames = [] + for i in range(gif.n_frames): + gif.seek(i) + frames.append(np.asarray(gif.convert('RGB'))[:, 2:620]) +plate = np.median(np.stack(frames), axis=0).astype(np.uint8) +y, x = np.mgrid[:330, :618] +window = (x >= 24) & (x < 582) & (y >= 18) & (y < 324) +basin = (x >= 24) & (x < 582) & (y >= 288) & (y < 324) +cooler = (x >= 102) & (x < 186) & (y >= 192) & window + +def layer(name, rgb, mask): + rgba = np.zeros((330, 618, 4), dtype=np.uint8) + rgba[:, :, :3] = rgb + rgba[:, :, 3] = mask.astype(np.uint8) * 255 + Image.fromarray(rgba).save(LAYERS / name) + return rgba + +parts = [layer('frame_foreground.png', plate, ~window), + layer('cooler_foreground.png', plate, cooler), + layer('machine_interior.png', plate, window & ~basin & ~cooler)] +reservoir = plate.copy() +# Reconstructed hidden water, covered by the cooler in the final composition. +for column in range(114, 180): + reservoir[288:324, column] = plate[288:324, min(180 + 179 - column, 581)] +parts.append(layer('reservoir_static.png', reservoir, basin)) +layer('reservoir_mask.png', np.full_like(plate, 255), basin) +reflection = plate[108:144, 24:582][::-1].astype(np.float32) +reflection = (reflection * 0.8 + np.array([23, 45, 54]) * 0.2).astype(np.uint8) +Image.fromarray(reflection).convert('RGBA').save(LAYERS / 'reflection.png') +# The animated overlay does use six-pixel-wide drops, unlike the static plate. +ramp = [(82,117,135,170), (82,117,135,205), (82,117,135,215), + (127,171,195,235), (173,202,198,255)] +drop = np.repeat(np.repeat(np.array(ramp, dtype=np.uint8)[:, None], 6, axis=0), 6, axis=1) +Image.fromarray(drop).save(LAYERS / 'drop.png') +composite = np.zeros_like(plate) +for index in [2, 3, 1, 0]: + rgba = parts[index] + opaque = rgba[:, :, 3] == 255 + composite[opaque] = rgba[:, :, :3][opaque] +assert np.array_equal(composite, plate), 'static layers must preserve every plate pixel' +print('618x330 layers; exact full-resolution static-plate reconstruction verified') diff --git a/examples/condenser/tools/pack_atlas.py b/examples/condenser/tools/pack_atlas.py new file mode 100644 index 00000000..e7b829d9 --- /dev/null +++ b/examples/condenser/tools/pack_atlas.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Pack the COND-07 condenser layers into the single `condenser` atlas. + +The layer PNGs in `assets/layers/` are rebuilt by `derive_layers.py` from +the cropped reference at full resolution. Two are cropped to the basin +rectangle (x 24, y 288, w 558, h 36): + + * `reservoir_mask_rect.png` — the pixel-water silhouette MASK. The shader + samples it in reservoir-LOCAL uv, so it must span exactly the logical + rectangle, not the full 618x330 canvas. + * `reservoir_art.png` — the authored static reservoir, which is the + water sprite's own texture (`s_tex`) and the degrade fallback. + +Run from the example root: python3 tools/pack_atlas.py +""" +import json, os +from PIL import Image + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC = os.path.join(HERE, "assets", "layers") +DST = os.path.join(HERE, "assets") + +# Reference-resolution reservoir; shader logical coordinates remain 93x6. +RESERVOIR = (24, 288, 582, 324) +PAD = 4 + +def crop(name, out): + Image.open(os.path.join(SRC, name)).convert("RGBA").crop(RESERVOIR).save(os.path.join(DST, out)) + +crop("reservoir_mask.png", "reservoir_mask_rect.png") +crop("reservoir_static.png", "reservoir_art.png") + +frames = ["machine_interior.png", "cooler_foreground.png", + "frame_foreground.png", "drop.png"] +images = [(n, Image.open(os.path.join(SRC, n)).convert("RGBA")) for n in frames] +images.append(("reservoir_art.png", Image.open(os.path.join(DST, "reservoir_art.png")))) + +width = max(im.width for _, im in images) +height = sum(im.height + PAD for _, im in images) - PAD +sheet = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + +manifest = {"frames": {}} +y = 0 +for name, im in images: + sheet.paste(im, (0, y)) + manifest["frames"][name] = { + "frame": {"x": 0, "y": y, "w": im.width, "h": im.height}, + "rotated": False, "trimmed": False, + "spriteSourceSize": {"x": 0, "y": 0, "w": im.width, "h": im.height}, + "sourceSize": {"w": im.width, "h": im.height}, + } + y += im.height + PAD + +sheet.save(os.path.join(DST, "condenser.png")) +with open(os.path.join(DST, "condenser.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump(manifest, f, indent=1) +print("packed", width, "x", height, "->", list(manifest["frames"])) diff --git a/examples/condenser/tools/sprite_varying.def.sc b/examples/condenser/tools/sprite_varying.def.sc new file mode 100644 index 00000000..7a749149 --- /dev/null +++ b/examples/condenser/tools/sprite_varying.def.sc @@ -0,0 +1,5 @@ +vec4 v_color0 : COLOR0 = vec4(1.0, 1.0, 1.0, 1.0); +vec2 v_texcoord0 : TEXCOORD0 = vec2(0.0, 0.0); +vec2 a_position : POSITION; +vec4 a_color0 : COLOR0; +vec2 a_texcoord0 : TEXCOORD0; diff --git a/examples/condenser/tools/verify_effect_assets.py b/examples/condenser/tools/verify_effect_assets.py new file mode 100644 index 00000000..73c4571c --- /dev/null +++ b/examples/condenser/tools/verify_effect_assets.py @@ -0,0 +1,163 @@ +"""CPU reference checks of saved masks, independent controls, and descriptors. +Writes an inspection sheet to .test-output; this is not a GPU screenshot test. +""" +from pathlib import Path +import json +import re +import unittest +import numpy as np +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +FOG = np.array([.48, .65, .73]) +LAMP = np.array([.64, .85, .94]) +Y, X = np.mgrid[:330, :618] +CX, CY = (X // 6 + .5) * 6, (Y // 6 + .5) * 6 + +def load(path): + return np.asarray(Image.open(ROOT / path).convert('RGBA')).astype(float) / 255 + +def footprint(width=348, up=12, down=132, intensity=1, spread=0, softness=6, falloff=2, glow=1, flicker=0, lamp_phase=0): + reach = np.where(CY < 12, up, down) + edge=width/2+spread*abs(CY-12)/np.maximum(reach,.001)-abs(CX-309) + h=(edge>=0).astype(float) if softness==0 else np.clip(edge/softness,0,1) + v = np.where(reach > 0, np.clip(1-abs(CY-12)/np.maximum(reach, .001), 0, 1), 0) + valid=(width>0)&(reach>0)&(abs(CY-12)=0).astype(float) if softness==0 else np.clip(source_edge/softness,0,1) + return h * v**falloff * valid * intensity * glow * flicker_factor, source_h*(width>0)*min(intensity*flicker_factor,1) + +def render(fog_on=True, lamp_on=True, width=348, up=12, down=132, coupling=.35, phase=0, variation=0, + opacity=1,density=1,wisp_size=48,turbulence=.25,drift=(0,0),**lamp_options): + ci = load('assets/effects/interior_clean.png') + cf = load('assets/effects/frame_clean.png') + fm = load('assets/effects/fog_mask.png') + lm = load('assets/effects/lamp_mask.png') + original_frame = load('assets/layers/frame_foreground.png') + light,h = footprint(width,up,down,int(lamp_on),**lamp_options) + baseline,_ = footprint() + a = np.clip(fm[:,:,1]*light,0,1)[:,:,None] + lit = ci[:,:,:3]*(1-a)+LAMP*a + scatter=(light-baseline)*coupling + fc=np.clip(FOG+LAMP*scatter[:,:,None],0,1) + px=(CX/max(wisp_size,.001)-drift[0])*2*np.pi + py=(CY/max(wisp_size,.001)-drift[1])*2*np.pi + noise=(1-turbulence)*np.sin(px)*np.cos(py)+turbulence*np.sin(2*px+phase*2*np.pi)*np.cos(2*py-phase*2*np.pi) + if wisp_size==0: noise=np.zeros_like(CX) + amount=density*(1+variation*noise)*int(fog_on) + veil=np.clip((1-np.maximum(1-fm[:,:,0],.001)**amount)*opacity,0,.95)[:,:,None] + interior=np.dstack((lit*(1-veil)+fc*veil,ci[:,:,3])) + a=np.clip(lm[:,:,0]*light,0,1)[:,:,None] + frame=cf[:,:,:3]*(1-a)+LAMP*a + emission=(lm[:,:,1]*h*int(lamp_on))[:,:,None] + frame=frame*(1-emission)+original_frame[:,:,:3]*emission + frame=np.dstack((frame,cf[:,:,3])) + canvas=np.zeros((330,618,3)) + for layer in [interior,load('assets/layers/reservoir_static.png'),load('assets/layers/cooler_foreground.png'),frame]: + alpha=layer[:,:,3:4] + canvas=canvas*(1-alpha)+layer[:,:,:3]*alpha + return canvas,interior,frame + +class Effects(unittest.TestCase): + def test_saved_masks_reconstruct_interior_without_double_haze(self): + _,interior,_=render() + original=load('assets/layers/machine_interior.png') + visible=original[:,:,3]>0 + self.assertLessEqual(np.max(abs(interior[:,:,:3][visible]-original[:,:,:3][visible]))*255,2) + + def test_mask_dimensions_alpha_and_foreground_are_preserved(self): + for stem,source in [('interior','machine_interior'),('frame','frame_foreground')]: + clean=load(f'assets/effects/{stem}_clean.png') + art=load(f'assets/layers/{source}.png') + self.assertEqual(clean.shape,(330,618,4)) + np.testing.assert_array_equal(clean[:,:,3],art[:,:,3]) + fm=load('assets/effects/fog_mask.png') + self.assertTrue(np.all(fm[:,:,0][fm[:,:,3]==0]==0)) + + def test_fog_off_leaves_lamp_control_independent(self): + _,lit,_=render(fog_on=False) + _,dark,_=render(fog_on=False,lamp_on=False) + self.assertGreater(np.max(abs(lit-dark)),.01) + np.testing.assert_array_equal(dark,load('assets/effects/interior_clean.png')) + + def test_lamp_off_removes_emissive_tube_and_never_restores_outside_mask(self): + _,_,dark=render(lamp_on=False) + clean=load('assets/effects/frame_clean.png') + np.testing.assert_array_equal(dark,clean) + _,_,lit=render() + tube=load('assets/effects/lamp_mask.png')[:,:,1]>0 + self.assertGreater(np.mean(lit[:,:,:3][tube]-dark[:,:,:3][tube]),.15) + _,_,zero=render(width=0) + np.testing.assert_array_equal(zero,clean) + + def test_fog_scattering_tracks_lamp_and_zero_coupling_is_honored(self): + _,coupled,_=render(lamp_on=False,coupling=.35) + _,uncoupled,_=render(lamp_on=False,coupling=0) + self.assertGreater(np.max(abs(coupled-uncoupled)),.001) + + def test_asymmetric_reaches_and_grid_alignment(self): + a,_=footprint(up=0,down=132) + self.assertTrue(np.all(a[CY<12]==0)) + self.assertGreater(a[CY>12].max(),0) + b,_=footprint(up=12,down=0) + self.assertTrue(np.all(b[CY>12]==0)) + self.assertGreater(b[CY<12].max(),0) + np.testing.assert_array_equal(a.reshape(55,6,103,6),np.broadcast_to(a[::6,::6][:,None,:,None],(55,6,103,6))) + + def test_fog_opacity_density_wisp_and_turbulence_have_distinct_semantics(self): + np.testing.assert_array_equal(render(opacity=0)[0],render(fog_on=False)[0]) + np.testing.assert_array_equal(render(density=0)[0],render(fog_on=False)[0]) + self.assertGreater(np.max(abs(render(opacity=.5,density=2)[0]-render()[0])),.001) + np.testing.assert_array_equal(render(wisp_size=0,variation=.8,phase=.8)[0],render(wisp_size=0,variation=.8,phase=.2)[0]) + a=render(variation=.8,turbulence=0,drift=(.2,0))[0] + b=render(variation=.8,turbulence=1,drift=(.2,0))[0] + self.assertGreater(np.max(abs(a-b)),.01) + + def test_lamp_glow_flicker_and_spread(self): + a,_=footprint(width=60,spread=120) + b,_=footprint(width=60,spread=0) + self.assertGreater(np.sum(a),np.sum(b)) + _,_,source=render(glow=0) + _,_,dark=render(lamp_on=False) + tube=load('assets/effects/lamp_mask.png')[:,:,1]>0 + self.assertGreater(np.max(abs(source[tube]-dark[tube])),.1) + a,_=footprint(flicker=1,lamp_phase=.25) + self.assertTrue(np.all(abs(a)<1e-10)) + + def test_static_art_and_atlas_remain_full_resolution(self): + manifest=json.loads((ROOT/'assets/condenser.json').read_text()) + for name in ['machine_interior.png','frame_foreground.png','cooler_foreground.png']: + self.assertEqual(manifest['frames'][name]['sourceSize'],dict(w=618,h=330)) + + def test_descriptors_use_reserved_automatic_atlas_binding(self): + channels=dict(scalar=1,vec2=2,vec3=3,vec4=4,mat4=16) + for name in ['water','fog','lamp','mist']: + d=json.loads((ROOT/f'materials/{name}/material.json').read_text()) + source=(ROOT/f'materials/{name}/{d["fragment"]}').read_text() + self.assertIn('uniform vec4 u_material_rect;',source) + self.assertNotIn('u_water_rect',source) + self.assertEqual(set(d['targets']),{'spv','glsl','essl','mtl'}) + for p in d['parameters']: + self.assertNotIn(p['name'],['u_material_rect','s_tex']) + if 'defaults' in p: self.assertEqual(len(p['defaults']),p['count']*channels[p['kind']]) + for t in d.get('textures', []): self.assertEqual(t['sampler'],'point') + +def sheet(): + cases=[('Default reconstruction',{}),('Fog OFF; lamp ON',dict(fog_on=False)), + ('Fog ON; lamp OFF',dict(lamp_on=False)),('Both OFF',dict(fog_on=False,lamp_on=False)), + ('Narrow lamp; no down reach',dict(width=120,down=0)),('Animated fog phase 0.5',dict(phase=.5,variation=.18))] + out=Image.new('RGB',(1236,3*354),(18,22,27)) + draw=ImageDraw.Draw(out) + for i,(label,args) in enumerate(cases): + x,y=(i%2)*618,(i//2)*354 + draw.text((x+8,y+5),label,fill='white') + out.paste(Image.fromarray(np.rint(np.clip(render(**args)[0],0,1)*255).astype(np.uint8)),(x,y+24)) + target=ROOT/'.test-output/effect-controls.png' + target.parent.mkdir(exist_ok=True) + out.save(target) + print('CPU reference contact sheet:',target) + +if __name__=='__main__': + sheet() + unittest.main() diff --git a/examples/condenser/tools/verify_runtime.py b/examples/condenser/tools/verify_runtime.py new file mode 100644 index 00000000..97f7052b --- /dev/null +++ b/examples/condenser/tools/verify_runtime.py @@ -0,0 +1,82 @@ +"""Native deterministic integration captures; requires a built condenser. + +Runs the actual keyboard-control branch through frame-60 environment hooks. +Every mode starts a fresh process, uses fixed dt, and captures at two seconds. +""" +from pathlib import Path +import os +import subprocess +import numpy as np +from PIL import Image + +ROOT=Path(__file__).resolve().parents[1] +OUT=ROOT/'.test-output/runtime' +OUT.mkdir(parents=True,exist_ok=True) +import sys +BIN=ROOT/'.labelle/bgfx_desktop/zig-out/bin' +EXE=BIN/('condenser.exe' if sys.platform=='win32' else 'condenser') +assert EXE.exists(), f'build the example first: no {EXE}' +MODES={ + 'default':{}, + 'left_mist':{'CONDENSER_TEST_CONTROL':'mist'}, + 'mist_off':{'CONDENSER_MIST_OFF':'1'}, + 'zero_mist':{'CONDENSER_MIST_OPACITY':'0'}, + 'frozen_mist':{'CONDENSER_MIST_FREEZE':'1'}, + 'empty':{'CONDENSER_WATER_OFF':'1'}, + 'empty_no_mist':{'CONDENSER_WATER_OFF':'1','CONDENSER_MIST_OFF':'1'}, + 'left_water':{'CONDENSER_TEST_CONTROL':'water'}, + 'left_fog':{'CONDENSER_TEST_CONTROL':'fog'}, + 'left_lamp':{'CONDENSER_TEST_CONTROL':'lamp'}, + 'left_all':{'CONDENSER_TEST_LEFT':'1'}, + 'fog_off':{'CONDENSER_FOG_OFF':'1'}, + 'lamp_off':{'CONDENSER_LAMP_OFF':'1'}, + 'both_off':{'CONDENSER_FOG_OFF':'1','CONDENSER_LAMP_OFF':'1'}, + 'zero_width':{'CONDENSER_LAMP_WIDTH':'0'}, + 'zero_fog_opacity':{'CONDENSER_FOG_OPACITY':'0'}, +} +images={} +for mode,changes in MODES.items(): + env={k:v for k,v in os.environ.items() if not k.startswith('CONDENSER_')} + env.update(LABELLE_HEADLESS='1',LABELLE_HEADLESS_SURFACELESS='0',LABELLE_HEADLESS_TICKS='180', + LABELLE_HEADLESS_UNCAPPED='1',LABELLE_FIXED_DT='0.016666667', + LABELLE_SCREENSHOT_PATH=str(OUT/f'{mode}.tga'),LABELLE_SCREENSHOT_AFTER_SEC='2') + env.update(changes) + result=subprocess.run([str(EXE)],cwd=ROOT,env=env,capture_output=True,timeout=60, + creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) + log=(result.stdout+result.stderr).decode('utf-8',errors='replace') + (OUT/f'{mode}.log').write_text(log,encoding='utf-8') + assert result.returncode==0,(mode,result.returncode,log[-3000:]) + for component in ['water_shader.WaterShader','fog_shader.FogShader','lamp_shader.LampShader','mist_shader.MistShader']: + assert f'{component}: 2/2 generic materials live' in log,(mode,component,log[-3000:]) + assert '[condenser] water material: TextureNotReady' not in log,(mode,'unexpected startup ERROR') + image=Image.open(OUT/f'{mode}.tga').convert('RGB') + assert image.size==(1236,330),(mode,image.size) + image.save(OUT/f'{mode}.png') + images[mode]=np.asarray(image) + print(mode,'clean exit, eight live materials, captured',flush=True) + +base=images['default'] +report=[] +for mode in ['left_water','left_fog','left_lamp','left_mist','left_all']: + assert np.array_equal(base[:,618:],images[mode][:,618:]),(mode,'right instance changed') + changed=np.any(base[:,:618]!=images[mode][:,:618],axis=2) + assert changed.any(),(mode,'left control had no rendered effect') + report.append(f'{mode}: {changed.sum()} changed left pixels; right half byte-identical') +assert np.array_equal(images['zero_width'],images['lamp_off']),'width zero must disable source and halo' +assert np.array_equal(images['zero_fog_opacity'],images['fog_off']),'opacity zero must fully remove veil' +assert not np.array_equal(images['fog_off'],images['both_off']),'lamp must work with fog off' +assert not np.array_equal(images['lamp_off'],images['both_off']),'fog must work with lamp off' +assert np.array_equal(images['zero_mist'],images['mist_off']), 'zero opacity must remove mist' +assert np.array_equal(images['empty'],images['empty_no_mist']), 'empty water must not emit mist' +changed=np.any(base!=images['mist_off'],axis=2) +assert changed.any(), 'mist must be visible' +# Both surface heights: A=294.0012, B=306; no mist outside its exact bounds. +for left,surface in [(0,294.0012),(618,306)]: + yy,xx=np.mgrid[:330,:618] + allowed=(xx>=24)&(xx<=582)&(yy+0.5<=surface)&(yy+0.5>=surface-54) + assert not changed[:,left:left+618][~allowed].any(), 'mist escaped reservoir bounds' +assert not np.array_equal(base,images['frozen_mist']), 'mist should drift with time' +report.append('Mist bounds, empty reservoir, zero opacity and moving versus frozen checks passed.') +report.append('16 native runs; independent fog/lamp off, zero width, zero opacity verified.') +(OUT/'results.txt').write_text('\n'.join(report)+'\n') +print('\n'.join(report)) diff --git a/src/build_files/build_zig.zig b/src/build_files/build_zig.zig index 4ad88bce..928f113e 100644 --- a/src/build_files/build_zig.zig +++ b/src/build_files/build_zig.zig @@ -4,6 +4,7 @@ /// split, mirrors #539/#541). Emits the generated project's `build.zig` from the /// embedded template + the resolved backend manifest-v2 data. const std = @import("std"); +const materials = @import("../material_pipeline.zig"); const tpl = @import("../template.zig"); const config = @import("../config.zig"); const plugin_params = @import("../plugin_params.zig"); @@ -792,6 +793,7 @@ fn runtimeOutputsEntry(entries: []const PluginBuildStepsWiring) ?PluginBuildStep } pub const BuildZigOptions = struct { + materials: []const []const u8 = &.{}, /// Emit a test-only build.zig: skip the exe step, the run step, /// and the backend artifact link. Used by `generateTestsTarget` /// in root.zig for `.labelle/tests/build.zig` (issue #83). @@ -942,6 +944,16 @@ fn androidNeedsAppImport(m: manifest_v2.BackendManifestV2, cfg: ProjectConfig) b return false; } +/// Whether an iOS/Android build.zig needs the `const target = _target;` +/// alias. The cross-compile headers define only `ios_target`/`android_target`; +/// every emitted consumer of the bare `target` name is enumerated HERE so the +/// alias can never go missing for one of them (PR #466 Finding 1, PR #733 +/// review: a materials-only game passes `target` to `material_build.zig`). +fn needsTargetAlias(cfg: ProjectConfig, opts: BuildZigOptions) bool { + return cfg.plugins.len > 0 or cfg.ecs != .mock or cfg.hasGui() or opts.promoted_scripts.len > 0 or + opts.constants or opts.i18n or opts.pack_modules.len > 0 or opts.materials.len != 0; +} + pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: BuildZigOptions) ![]const u8 { var alloc_writer: std.Io.Writer.Allocating = .init(allocator); errdefer alloc_writer.deinit(); @@ -1032,10 +1044,10 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // `target` (the alias for `ios_target`) is consumed by the deps/plugin // decls AND by `emitPromotedScriptModules` (`.target = target`). Emit the // alias whenever ANY consumer needs it — including promoted scripts on an - // otherwise plugin/ECS/GUI-free game (PR #466 Finding 1). - if (cfg.plugins.len > 0 or cfg.ecs != .mock or cfg.hasGui() or opts.promoted_scripts.len > 0 or - opts.constants or opts.i18n or opts.pack_modules.len > 0) - { + // otherwise plugin/ECS/GUI-free game (PR #466 Finding 1) and the + // game-owned materials module (`materials.emit` passes `target` to + // `material_build.zig`; PR #733 review). + if (needsTargetAlias(cfg, opts)) { try tpl.writeSection(build_zig_tmpl, "ios_target_alias", w); } // manifest-v2 ios: emit the core/gfx/engine dep decls WITHOUT the unrolled @@ -1048,10 +1060,9 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // decls AND by `emitPromotedScriptModules` (`.target = target`). Include the // promoted-scripts condition so `target` is defined whenever any consumer // needs it — a promoted-scripts + no-plugin/ECS/GUI android game previously - // emitted an undefined `target` (PR #466 Finding 1). - if (cfg.plugins.len > 0 or cfg.ecs != .mock or cfg.hasGui() or opts.promoted_scripts.len > 0 or - opts.constants or opts.i18n or opts.pack_modules.len > 0) - { + // emitted an undefined `target` (PR #466 Finding 1); a materials-only game + // likewise (PR #733 review). + if (needsTargetAlias(cfg, opts)) { try tpl.writeSection(build_zig_tmpl, "android_target_alias", w); } // manifest-v2 android: emit the core/gfx/engine dep decls WITHOUT the @@ -1233,6 +1244,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // compiles under its own module, whose import table does not inherit // game_mod's -- without these entries, valid `@import("constants")` in // exactly the sources the usage scanner covers failed to resolve. + try materials.emit(w, opts.materials, if (opts.is_tests_target or std.mem.eql(u8, cfg.backendName(), "null")) "tests" else @tagName(cfg.platform)); try emitConstantsModule(w, opts.constants); try emitI18nModule(w, opts.i18n); // In-project lib plugin modules pick the data modules up here — after @@ -1243,6 +1255,10 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // Per-pack modules (assembler#498 PR 2) — declared beside the promoted // script modules, before any target artifact that imports them. try emitPackModules(w, cfg, opts.pack_modules, opts.constants, opts.i18n); + if (opts.materials.len != 0) { + for (opts.promoted_scripts) |script| try w.print(" {s}_mod.addImport(\"materials\", materials_mod);\n", .{script.module_name}); + for (opts.pack_modules) |pack| try w.print(" pack__{s}_mod.addImport(\"materials\", materials_mod);\n", .{pack.prefix}); + } if (cfg.platform == .wasm) { // manifest-v2 wasm: no emsdk-helper import in the generated build.zig — the @@ -1269,6 +1285,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // Promoted game-script modules → wasm root module (#240 Gap 2). try emitPromotedScriptImports(w, "wasm", opts.promoted_scripts); try emitConstantsImport(w, "wasm", opts.constants); + try materials.emitImport(w, opts.materials, "wasm"); try emitI18nImport(w, "wasm", opts.i18n); try emitPackImports(w, "wasm", opts.pack_modules); @@ -1339,6 +1356,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // Promoted game-script modules → iOS exe root module (#240 Gap 2). try emitPromotedScriptImports(w, "exe", opts.promoted_scripts); try emitConstantsImport(w, "exe", opts.constants); + try materials.emitImport(w, opts.materials, "exe"); try emitI18nImport(w, "exe", opts.i18n); try emitPackImports(w, "exe", opts.pack_modules); @@ -1405,6 +1423,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // Promoted game-script modules → Android lib root module (#240 Gap 2). try emitPromotedScriptImports(w, "lib", opts.promoted_scripts); try emitConstantsImport(w, "lib", opts.constants); + try materials.emitImport(w, opts.materials, "lib"); try emitI18nImport(w, "lib", opts.i18n); try emitPackImports(w, "lib", opts.pack_modules); @@ -1478,6 +1497,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // `@import("")` (labelle-assembler#240 Gap 2). try emitPromotedScriptImports(w, "exe", opts.promoted_scripts); try emitConstantsImport(w, "exe", opts.constants); + try materials.emitImport(w, opts.materials, "exe"); try emitI18nImport(w, "exe", opts.i18n); try emitPackImports(w, "exe", opts.pack_modules); @@ -1527,6 +1547,7 @@ pub fn generateBuildZig(allocator: std.mem.Allocator, cfg: ProjectConfig, opts: // `__tests_root.zig` reaches the same named modules. try emitPromotedScriptImports(w, "test_root", opts.promoted_scripts); try emitConstantsImport(w, "test_root", opts.constants); + try materials.emitImport(w, opts.materials, "test_root"); try emitI18nImport(w, "test_root", opts.i18n); try emitPackImports(w, "test_root", opts.pack_modules); @@ -2084,3 +2105,48 @@ test "generateBuildZig footer (codex #644 round 2): the C# run env + InstallDir // The helper's own build step still emits — it just doesn't stage/capture. try testing.expect(std.mem.indexOf(u8, out, "plugin_helper_build_step_0") != null); } + +test "generateBuildZig: a materials-only iOS/Android game defines `target` before material_build.zig consumes it (#733 review)" { + // The cross-compile headers define only `ios_target`/`android_target`; the + // bare `target` name exists solely via the guarded alias section. A game + // with NO plugins/ECS/GUI/promoted scripts but a `materials/` directory + // emits `material_build.zig".create(b, target, ...)` — so the alias must be + // emitted for it. Assert the MECHANISM: the alias line is present exactly + // when materials are, and every `target` use is preceded by its definition. + const allocator = testing.allocator; + const cases = [_]struct { platform: config.Platform, pkg: config.PluginDep, alias: []const u8 }{ + .{ .platform = .android, .pkg = .{ .name = "bgfx_v2", .repo = "local:backends/bgfx_v2" }, .alias = " const target = android_target;\n" }, + .{ .platform = .ios, .pkg = .{ .name = "sokol", .repo = "local:backends/sokol" }, .alias = " const target = ios_target;\n" }, + }; + for (cases) |c| { + // `.backend = .bgfx` + a package NOT literally named "bgfx" doubles as the + // enum-as-shorthand shape; generateBuildZig is the pure emitter and does + // not run the materials backend gate (that is `material_pipeline.stage`). + const cfg = ProjectConfig{ + .name = "materials-only", + .backend = if (c.platform == .android) .bgfx else .sokol, + .backend_package = c.pkg, + .platform = c.platform, + .ecs = .mock, + }; + const with = try generateBuildZig(allocator, cfg, .{ + .project_dir = ".", + .backend_manifest_name = "backend.manifest.v2.zon", + .materials = &.{"fog"}, + }); + defer allocator.free(with); + const alias_at = std.mem.indexOf(u8, with, c.alias) orelse return error.TestExpectedAlias; + const consumer_at = std.mem.indexOf(u8, with, "@import(\"material_build.zig\").create(b, target,") orelse return error.TestExpectedMaterialsConsumer; + try testing.expect(alias_at < consumer_at); + + // Control: the same game WITHOUT materials has no consumer of `target` + // and must keep its byte-identical alias-free build.zig. + const without = try generateBuildZig(allocator, cfg, .{ + .project_dir = ".", + .backend_manifest_name = "backend.manifest.v2.zon", + }); + defer allocator.free(without); + try testing.expect(std.mem.indexOf(u8, without, c.alias) == null); + try testing.expect(std.mem.indexOf(u8, without, "material_build.zig") == null); + } +} diff --git a/src/build_files/build_zig_zon.zig b/src/build_files/build_zig_zon.zig index e7b9d767..167feeac 100644 --- a/src/build_files/build_zig_zon.zig +++ b/src/build_files/build_zig_zon.zig @@ -25,6 +25,7 @@ const build_zig_zon_tmpl = @embedFile("../templates/build_zig_zon.txt"); // ============================================================ pub const BuildZigZonOptions = struct { + materials: bool = false, /// True (default) wipes the shared `.labelle/deps/` directory before /// recreating it. The tests target (issue #83) sets this to false so /// the second-pass generation merges its null-backend dep into the @@ -148,6 +149,11 @@ pub fn generateBuildZigZon(allocator: std.mem.Allocator, cfg: ProjectConfig, tar try tpl.renderSection(build_zig_zon_tmpl, "header", .{ .hash = hash_str, .version = cfg.version }, w); + if (opts.materials) { + const material_schema = @import("../material_schema.zig"); + try w.print(" .material_shaderc = .{{ .url = \"{s}\", .hash = \"{s}\" }},\n", .{ material_schema.tool_url, material_schema.tool_hash }); + } + // Manifest-v2 backend dep key: the generated build.zig calls // `b.dependency(m.dep_name, ..)`, so the zon backend entry must be keyed by // `m.dep_name` (see `v2BackendDepName`). Null → keep the `labelle_` diff --git a/src/codegen/blocks/registries.zig b/src/codegen/blocks/registries.zig index 44d48ab6..3dcdb221 100644 --- a/src/codegen/blocks/registries.zig +++ b/src/codegen/blocks/registries.zig @@ -120,6 +120,22 @@ pub fn Mixin(comptime Self: type) type { try w.writeAll("});\n\n"); } + if (has_plugins) { + try w.writeAll(@import("../../component_collisions.zig").plugin_guard); + try w.writeAll("comptime { rejectComponentCollisions(&.{\"VideoComponent\", "); + for (component_names) |name| try w.print("\"{s}\",", .{pathToPascal(name, &pascal_buf)}); + for (self.pack_scans) |pack| { + const prefix = scan.packNamespacePrefix(pack.name, &pack_prefix_buf); + for (pack.component_names) |name| try w.print("\"{s}__{s}\",", .{ prefix, pathToPascal(name, &pascal_buf) }); + } + if (self.scripting) |scripting| for (scripting.declared_components) |comp| { + try w.print("\"{s}\",", .{comp.name}); + }; + try w.writeAll("}, .{ @import(\"labelle-gfx\"),"); + for (cfg.plugins) |plugin| try w.print("@import(\"{s}\"),", .{plugin.name}); + try w.writeAll("}); }\n\n"); + } + // Per-pack registry partition (labelle-engine#652, assembler#498). // Appended into the SAME `{{component_registry_block}}` scalar right // after `Components` — no new template placeholder — so the view diff --git a/src/component_collisions.zig b/src/component_collisions.zig new file mode 100644 index 00000000..5b8da4fb --- /dev/null +++ b/src/component_collisions.zig @@ -0,0 +1,70 @@ +//! Registration keys must have one owner; do not silently shadow plugin types. +const std = @import("std"); +const idents = @import("codegen/idents.zig"); +const scan = @import("codegen/scan.zig"); +const declare = @import("scripting_declare.zig"); +pub fn check(a: std.mem.Allocator, game: []const []const u8, packs: []const scan.PackScan, scripts: []const declare.DeclaredComponent) !void { + var seen: std.StringHashMap([]const u8) = .init(a); + defer { + var it = seen.iterator(); + while (it.next()) |entry| { + a.free(entry.key_ptr.*); + a.free(entry.value_ptr.*); + } + seen.deinit(); + } + try insert(a, &seen, "VideoComponent", "engine VideoComponent"); + var buf: [128]u8 = undefined; + for (game) |stem| try insert(a, &seen, idents.pathToPascal(stem, &buf), stem); + var prefix: [128]u8 = undefined; + for (packs) |pack| for (pack.component_names) |stem| { + const key = try std.fmt.allocPrint(a, "{s}__{s}", .{ scan.packNamespacePrefix(pack.name, &prefix), idents.pathToPascal(stem, &buf) }); + defer a.free(key); + const owner = try std.fmt.allocPrint(a, "pack {s}/{s}", .{ pack.name, stem }); + defer a.free(owner); + try insert(a, &seen, key, owner); + }; + for (scripts) |c| try insert(a, &seen, c.name, "script declaration"); +} +fn insert(a: std.mem.Allocator, map: *std.StringHashMap([]const u8), key: []const u8, owner: []const u8) !void { + if (map.get(key)) |prior| { + if (!@import("builtin").is_test) std.log.err("component registration '{s}' collides: {s} and {s}", .{ key, prior, owner }); + return error.DuplicateComponentRegistration; + } + const k = try a.dupe(u8, key); + errdefer a.free(k); + const v = try a.dupe(u8, owner); + errdefer a.free(v); + try map.put(k, v); +} +// This check is emitted into generated main.zig. Zig reflection is authoritative +// for plugin exports (AST scanning cannot see re-exports or computed declarations). +pub const plugin_guard = + \\fn rejectComponentCollisions(comptime local_names: []const []const u8, comptime plugins: anytype) void { + \\ @setEvalBranchQuota(1000000); + \\ for (plugins, 0..) |plugin, i| { + \\ if (@hasDecl(plugin, "Components")) { + \\ for (@typeInfo(plugin.Components).@"struct".decls) |decl| { + \\ for (local_names) |name| { + \\ if (std.mem.eql(u8, name, decl.name)) @compileError("duplicate component registration '" ++ name ++ "': game and " ++ @typeName(plugin)); + \\ } + \\ for (plugins, 0..) |previous, j| { + \\ if (j >= i) break; + \\ if (@hasDecl(previous, "Components") and @hasDecl(previous.Components, decl.name)) @compileError("duplicate component registration '" ++ decl.name ++ "': " ++ @typeName(previous) ++ " and " ++ @typeName(plugin)); + \\ } + \\ } + \\ } + \\ } + \\} + \\ +; +test "component registration rejects folded names and built-in collision" { + // Disable error logs for negative cases through the test runner's log hook. + try std.testing.expectError(error.DuplicateComponentRegistration, check(std.testing.allocator, &.{ "water_flow", "water/flow" }, &.{}, &.{})); + try std.testing.expectError(error.DuplicateComponentRegistration, check(std.testing.allocator, &.{"video_component"}, &.{}, &.{})); + try check(std.testing.allocator, &.{ "water", "fog", "lamp" }, &.{}, &.{}); + const pack = scan.PackScan{ .name = "effects", .import_prefix = "packs/effects", .component_names = &.{"water"}, .event_names = &.{}, .prefab_names = &.{} }; + try check(std.testing.allocator, &.{"water"}, &.{pack}, &.{}); + try std.testing.expectError(error.DuplicateComponentRegistration, check(std.testing.allocator, &.{}, &.{ pack, pack }, &.{})); + try std.testing.expectError(error.DuplicateComponentRegistration, check(std.testing.allocator, &.{"water"}, &.{}, &.{.{ .name = "Water", .persist = .persistent, .fields = &.{} }})); +} diff --git a/src/config.zig b/src/config.zig index 86328789..9425a7c7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -728,6 +728,48 @@ pub fn isSemverVersion(version: []const u8) bool { return has_dot; } +/// Parse a release-shaped version pin into a `std.SemanticVersion`. +/// +/// Pins reaching the floor checks are NOT all `build.zig` literals — +/// `*_VERSION` carries any `-D*_version=` override, and `project.labelle` +/// carries whatever the user wrote — so this cannot assume the strict +/// `X.Y.Z` form `std.SemanticVersion.parse` demands. `isSemverVersion` +/// (digits-and-dots, >= 1 dot) also admits the abbreviated `X.Y` release +/// form, which the rest of the version-resolution code accepts: +/// `scene_manifest.engineSupportsTargetOverrides` normalizes it by +/// appending `.0`, and this does the same rather than inventing a second +/// convention. +/// +/// A dotted string that is still unparsable after padding (`1.2.3.4`, which +/// `isSemverVersion` also admits) returns `error.UnparsableVersionPin` with +/// the offending value named — a readable failure instead of the +/// `catch unreachable` crash this used to take (#683 review). +pub fn parsePin(version: []const u8) error{UnparsableVersionPin}!std.SemanticVersion { + if (std.SemanticVersion.parse(version)) |v| return v else |_| {} + var buf: [64]u8 = undefined; + const padded = std.fmt.bufPrint(&buf, "{s}.0", .{version}) catch { + std.debug.print("version pin '{s}' is too long to normalize\n", .{version}); + return error.UnparsableVersionPin; + }; + return std.SemanticVersion.parse(padded) catch { + std.debug.print( + "version pin '{s}' is not a usable release version (want `X.Y.Z` or `X.Y`)\n", + .{version}, + ); + return error.UnparsableVersionPin; + }; +} + +/// `pin >= floor` on two release-shaped version strings — the primitive +/// under `init_cmd`'s curated-trio / backend-core floor guards and +/// `material_pipeline`'s contract gate (moved here from `init_cmd.zig`, +/// PR #733, so the generate-time check shares the exact comparison). +pub fn pinAtLeast(pin: []const u8, floor: []const u8) error{UnparsableVersionPin}!bool { + const p = try parsePin(pin); + const f = try parsePin(floor); + return p.order(f) != .lt; +} + /// Map a package `version` string to the git ref to clone. /// /// A semver-shaped version (`1.2.3`) maps to the published release tag @@ -1241,38 +1283,30 @@ pub const ProjectConfig = struct { // crashing at first sprite draw — engine#683) + #31 desktop-video // Windows fix. // - // 0.20.0 is the LOWEST bgfx release that survives labelle-core - // v1.32.0. That core appended `MaterialEffect.pixel_water`, and - // every bgfx up to 0.19.0 switches EXHAUSTIVELY over that enum - // with no arm for the new tag — a project on core >= 1.32.0 - // resolving the old default fails sema with "switch must handle - // all possibilities". 0.20.0 IMPLEMENTS the effect (fs_pixel_water) - // behind `@hasField(MaterialEffect, "pixel_water")` probes, so the - // exhaustive switches carry the arm and it also compiles against a - // core that lacks the tag. + // 0.20.0 was the LOWEST bgfx release that survived labelle-core + // v1.32.0's `MaterialEffect.pixel_water` (exhaustive switches in + // every earlier bgfx lacked the arm; #731 follow-up). Its HARD + // floor was core >= v1.28.0 (`core.BackendTextureId` as a struct + // field type, bgfx >= 0.15.0) and its CURATED pairing core 1.32.0. // - // FLOOR NOTE: this is a LATEST-as-lowest-fixed bump, not a bump for - // its own sake — there is no older bgfx with the fix, because the - // fix shipped the same day as the core release. It does raise the - // implicit runtime floor for a project that takes this default. - // The HARD floor is core >= v1.28.0: bgfx >= 0.15.0 types a struct - // field as core's `BackendTextureId` (core#328 phase 3), analyzed - // eagerly, so an older core fails inside the backend ("root source - // file struct 'root' has no member named 'BackendTextureId'" — - // how the #731 examples on core 1.26.0 failed standalone). The - // v1.32.0 `PixelWaterDraw` / `PIXEL_WATER_*` names bgfx takes from - // `backend_contract` are NOT a compile floor: unlike labelle-sokol, - // which comptime-gates each decl at its use, bgfx aliases them at - // top level — but every path that reaches them sits behind - // `@hasField(MaterialEffect, "pixel_water")` and an unreferenced - // alias is never analyzed, so 0.20.0 compiles against core 1.28.0 - // (verified standalone). The CURATED pairing is still core >= - // v1.32.0: the core 0.20.0 was released against and the only one - // on which its pixel_water effect is reachable. `build.zig`'s - // scaffold trio carries it and `src/init_cmd.zig` asserts it. A + // 0.21.0 is the first CONTRACT-v2 backend (game-owned shader + // materials, PR #733): the specialized PixelWater contract is gone + // and the backend names core's `shader_material` contract UNGATED + // (no `@hasDecl` probe) and asserts `MATERIAL_CONTRACT_VERSION == + // 2`, so its HARD floor is core >= v2.0.0 — an older core fails to + // compile inside the backend. This default moves TOGETHER with + // `build.zig`'s scaffold trio (core 2.0.0 / gfx 2.0.0 / engine + // 3.0.0): a project that adds `materials/` takes this default and + // the generated materials module `@compileError`s on any core + // below 2.0.0, so bumping either side alone recreates the #731 + // failure in one direction or the other. `src/init_cmd.zig`'s + // `bgfx_core_floors` carries the 0.21.0 → 2.0.0 floor (and the + // older ones) and its tests pin this default against the trio. A // project pinned to an older core that must stay there should pin - // `.backend_package` explicitly rather than ride the default. - .bgfx => .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.20.0" }, + // `.backend_package` explicitly (<= 0.20.0) rather than ride the + // default — and cannot own materials (`material_pipeline` + // rejects that pairing at generate time). + .bgfx => .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.21.0" }, .wgpu => .{ .name = "wgpu", .repo = "github.com/labelle-toolkit/labelle-wgpu", .version = "0.3.0" }, .null => .{ .name = "null", .repo = "github.com/labelle-toolkit/labelle-null", .version = "0.3.0" }, .sdl => .{ .name = "sdl", .repo = "github.com/labelle-toolkit/labelle-sdl", .version = "0.3.1" }, diff --git a/src/init_cmd.zig b/src/init_cmd.zig index 646c6358..3112c0d9 100644 --- a/src/init_cmd.zig +++ b/src/init_cmd.zig @@ -115,6 +115,17 @@ pub fn cmdInit(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args std.process.exit(2); }; + // #736 review (CodeRabbit): refuse a backend name the `Backend` enum does + // not know BEFORE anything else. `scaffold` writes the value verbatim as + // `.backend = .`, so `--backend=bgxf` used to produce a project that + // only failed when a later command parsed it — and the floor check below + // silently treated the unknown name as "not my business". + if (checkBackendName(opts.backend)) |_| {} else |_| { + var buf: [512]u8 = undefined; + std.log.err("labelle-assembler init: {s}", .{unknownBackendDiagnostic(opts.backend, &buf)}); + std.process.exit(2); + } + // #736 review: refuse a backend/core pairing that cannot compile BEFORE // a single file is written. Only the hard floor is fatal; a curated // floor warns and scaffolds, since the user asked for that core @@ -134,9 +145,68 @@ pub fn cmdInit(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args } } + // #733 review (round 3): the core/engine/gfx trio must be coherent too — + // an explicit `--engine-version=`/`--gfx-version=`/`--core-version=` + // against the other two defaults used to pass the backend/core check + // above and scaffold a set the trio test declared incompatible. Same + // policy: a compile break refuses, a curated floor warns and scaffolds. + if (trioFloorViolation(opts.core_version, opts.engine_version, opts.gfx_version) catch |err| { + std.log.err("labelle-assembler init: core/engine/gfx version pins: {s}", .{@errorName(err)}); + std.process.exit(2); + }) |v| { + var buf: [1024]u8 = undefined; + switch (v.severity()) { + .compile_break => { + std.log.err("labelle-assembler init: {s}", .{v.describe(&buf)}); + std.process.exit(2); + }, + .curated => std.log.warn("labelle-assembler init: {s}", .{v.describe(&buf)}), + } + } + try scaffold(allocator, io, opts); } +// ── backend name validation ────────────────────────────────────────── + +/// The `--backend=` value as a `config.Backend` tag, or `error.UnknownBackend`. +/// The enum is the single source of truth for what `init` accepts — the +/// same tag set `project.labelle`'s `.backend` parses — so the two cannot +/// drift. +pub fn checkBackendName(name: []const u8) error{UnknownBackend}!config.Backend { + return std.meta.stringToEnum(config.Backend, name) orelse error.UnknownBackend; +} + +/// The diagnostic for an unknown `--backend=` value: names the value and +/// lists every valid backend, derived from the enum (never a literal list). +pub fn unknownBackendDiagnostic(name: []const u8, buf: []u8) []const u8 { + var w: std.Io.Writer = .fixed(buf); + w.print("unknown --backend '{s}'; valid backends: ", .{name}) catch return "unknown --backend (diagnostic too long to render)"; + inline for (std.meta.fieldNames(config.Backend), 0..) |tag, i| { + w.print("{s}{s}", .{ if (i == 0) "" else ", ", tag }) catch return "unknown --backend (diagnostic too long to render)"; + } + return w.buffered(); +} + +test "init refuses an unknown --backend with a diagnostic listing the enum's backends — #736 review (CodeRabbit)" { + // The mechanism: an unknown name is an ERROR (not a silent pass-through + // the scaffold writes verbatim), and every real tag is accepted. + try std.testing.expectError(error.UnknownBackend, checkBackendName("bgxf")); + try std.testing.expectError(error.UnknownBackend, checkBackendName("")); + try std.testing.expectError(error.UnknownBackend, checkBackendName("BGFX")); + for (std.enums.values(config.Backend)) |tag| { + try std.testing.expectEqual(tag, try checkBackendName(@tagName(tag))); + } + // The diagnostic names the offending value and every valid backend. + var buf: [512]u8 = undefined; + const msg = unknownBackendDiagnostic("bgxf", &buf); + try std.testing.expect(std.mem.indexOf(u8, msg, "'bgxf'") != null); + for (std.enums.values(config.Backend)) |tag| { + try std.testing.expect(std.mem.indexOf(u8, msg, @tagName(tag)) != null); + } + try std.testing.expectEqualStrings("unknown --backend 'bgxf'; valid backends: raylib, sokol, sdl, bgfx, wgpu, null", msg); +} + // ── backend ⇄ core floor validation ────────────────────────────────── // // PRODUCTION check, not a test helper (#736 review): `checkTrioFloors` @@ -187,7 +257,20 @@ const BackendCoreFloor = struct { /// pixel_water effect the default backend ships is reachable. The scaffold /// pairs the default backend with the core it was released against, not /// merely one it happens to compile on. +/// +/// bgfx >= 0.21.0 floors core >= 2.0.0 — a COMPILE break (PR #733): the +/// first contract-v2 backend dropped the PixelWater contract and names +/// core's `shader_material` contract with no `@hasDecl` probe, and asserts +/// `MATERIAL_CONTRACT_VERSION == 2`; core 2.0.0 is the release that +/// declares both. The scaffold trio (core 2.0.0 / gfx 2.0.0 / engine +/// 3.0.0) and this default moved together for that reason. const bgfx_core_floors = [_]BackendCoreFloor{ + .{ + .backend_at_least = "0.21.0", + .core_at_least = "2.0.0", + .severity = .compile_break, + .why = "the backend names core's `shader_material` contract ungated and asserts material contract v2, which only core >= 2.0.0 declares — it fails to compile", + }, .{ .backend_at_least = "0.20.0", .core_at_least = "1.32.0", @@ -275,6 +358,206 @@ fn providerCoreFloorViolation(backend: config.Backend, backend_version: []const return worst; } +// ── core ⇄ engine ⇄ gfx trio floor validation ───────────────────────── +// +// PRODUCTION check (#733 review, round 3 — the same shape #736's Major had): +// the trio floors used to live only in a test helper, so +// `labelle-assembler init game --engine-version=2.12.2` combined that explicit +// pin with the core/gfx 2.0.0 defaults, passed the backend/core-only validator +// above, and wrote the exact combination the test declared incompatible. The +// table below is the ONE place the trio floors live; `checkTrioFloors` (the +// test helper) wraps this same function. Scope: `init` only — generate/upgrade +// is labelle-assembler#739's. + +const TrioPackage = enum { + core, + engine, + gfx, + + fn label(self: TrioPackage) []const u8 { + return switch (self) { + .core => "labelle-core", + .engine => "labelle-engine", + .gfx => "labelle-gfx", + }; + } + fn flag(self: TrioPackage) []const u8 { + return switch (self) { + .core => "--core-version", + .engine => "--engine-version", + .gfx => "--gfx-version", + }; + } + fn curatedDefault(self: TrioPackage) []const u8 { + return switch (self) { + .core => config.CORE_VERSION, + .engine => config.ENGINE_VERSION, + .gfx => config.GFX_VERSION, + }; + } +}; + +/// One floor: `subject >= subject_at_least` puts `requires >= floor` on the +/// project. +const TrioFloor = struct { + subject: TrioPackage, + subject_at_least: []const u8, + requires: TrioPackage, + floor: []const u8, + severity: FloorSeverity, + /// Why, in the words the diagnostic prints. + why: []const u8, +}; + +/// Strictest (newest line) first, so the reported violation is the +/// actionable one. The 2.0.0 line (PR #733, game-owned shader materials): +/// gfx 2.0.0 dispatches generic material draws through core 2.0.0's +/// `shader_material` contract (its build.zig.zon pins core 2.0.0), and +/// engine 3.0.0's entity material API is built on gfx 2.0.0 (its +/// build.zig.zon pins gfx 2.0.0). The 1.x core carried the specialized +/// PixelWater contract these replaced, and engine <= 2.12.x still compiles +/// its PixelWater simulation against it — so nothing below the 2.0.0 line +/// mixes with anything on it, in either direction. +/// +/// #679: gfx >= 1.30.0 re-exports `core.TextureId` rather than declaring its +/// own (labelle-gfx#328). That type landed in core 1.28.0, and the assembler +/// unifies every package onto the project's `core_version` — so gfx 1.30.x +/// against core < 1.28.0 dies inside the dependency with "root source file +/// struct 'root' has no member named 'TextureId'". Engine >= 2.12.1 is the +/// matching half (`game.nativeTextureId` compiles against the typed gfx +/// surface, labelle-engine#813 phase 4) — CURATED, the engine takes no +/// direct gfx dependency. +/// +/// engine >= 2.11.0 floors core >= 1.27.0: core's `ChildrenComponent` went +/// ArrayList-backed and `addChild` takes an allocator (labelle-core#65/#66), +/// and the engine takes a REAL module dependency on core — an older core +/// fails to compile. +/// +/// engine >= 2.11.0 also floors gfx >= 1.28.0, but that one is a CURATED +/// floor, not a compile break: the engine module takes no direct gfx +/// dependency (the renderer arrives via `RenderImpl`), and its post-fx +/// passthrough is `@hasDecl`-gated — labelle-engine `src/game/post_fx_mixin.zig` +/// says in so many words that "an older gfx (< v1.28.0) … compiles to a +/// no-op". So the pairing BUILDS; it just silently drops the post-fx stack +/// the engine advertises. A curated set is what `labelle init` stamps and +/// `upgrade all` writes, so it must be the coherent set, not merely one +/// that compiles — hence the assertion, with the distinction recorded here +/// so nobody later "fixes" a legitimate compile failure by relaxing it. +const trio_floors = [_]TrioFloor{ + .{ .subject = .gfx, .subject_at_least = "2.0.0", .requires = .core, .floor = "2.0.0", .severity = .compile_break, .why = "gfx 2.x dispatches material draws through core's `shader_material` contract, which only core >= 2.0.0 declares" }, + .{ .subject = .gfx, .subject_at_least = "2.0.0", .requires = .engine, .floor = "3.0.0", .severity = .compile_break, .why = "an engine below 3.0.0 still compiles the PixelWater simulation the 2.0.0 line removed from core and gfx" }, + .{ .subject = .engine, .subject_at_least = "3.0.0", .requires = .core, .floor = "2.0.0", .severity = .compile_break, .why = "engine 3.x's entity material API is built on core 2.0.0's `shader_material` contract" }, + .{ .subject = .engine, .subject_at_least = "3.0.0", .requires = .gfx, .floor = "2.0.0", .severity = .compile_break, .why = "engine 3.x is built against gfx 2.0.0's generic material draw (its build.zig.zon pins it)" }, + .{ .subject = .gfx, .subject_at_least = "1.30.0", .requires = .core, .floor = "1.28.0", .severity = .compile_break, .why = "gfx >= 1.30.0 re-exports `core.TextureId`, which landed in core 1.28.0 — an older core fails to compile" }, + .{ .subject = .gfx, .subject_at_least = "1.30.0", .requires = .engine, .floor = "2.12.1", .severity = .curated, .why = "engine 2.12.1 is the half released against gfx's typed TextureId surface" }, + .{ .subject = .engine, .subject_at_least = "2.11.0", .requires = .core, .floor = "1.27.0", .severity = .compile_break, .why = "engine >= 2.11.0 takes core's allocator-taking `ChildrenComponent` — an older core fails to compile" }, + .{ .subject = .engine, .subject_at_least = "2.11.0", .requires = .gfx, .floor = "1.28.0", .severity = .curated, .why = "the engine's post-fx passthrough compiles to a no-op on an older gfx, silently dropping the post-fx stack" }, +}; + +/// A trio pin set that violates a floor, with everything the diagnostic names. +pub const TrioViolation = struct { + core_version: []const u8, + engine_version: []const u8, + gfx_version: []const u8, + floor: TrioFloor, + + fn pinOf(self: TrioViolation, pkg: TrioPackage) []const u8 { + return switch (pkg) { + .core => self.core_version, + .engine => self.engine_version, + .gfx => self.gfx_version, + }; + } + + /// What to pass instead: the curated default of the required package + /// when it satisfies the floor (it always does for the shipped table), + /// else the floor itself. + pub fn recommended(self: TrioViolation) []const u8 { + const def = self.floor.requires.curatedDefault(); + const ok = config.isSemverVersion(def) and (config.pinAtLeast(def, self.floor.floor) catch false); + return if (ok) def else self.floor.floor; + } + + pub fn severity(self: TrioViolation) FloorSeverity { + return self.floor.severity; + } + + /// The diagnostic, rendered into `buf`: names the subject pin, the + /// required package and floor, all three pins as given, why, and the + /// flag to pass. + pub fn describe(self: TrioViolation, buf: []u8) []const u8 { + const verb: []const u8 = switch (self.floor.severity) { + .compile_break => "requires", + .curated => "is released against", + }; + return std.fmt.bufPrint( + buf, + "{s} {s} {s} {s} >= {s}; got core {s} / engine {s} / gfx {s} ({s}). Pass {s}={s} (the curated set is core {s} / engine {s} / gfx {s}).", + .{ + self.floor.subject.label(), self.pinOf(self.floor.subject), verb, + self.floor.requires.label(), self.floor.floor, self.core_version, + self.engine_version, self.gfx_version, self.floor.why, + self.floor.requires.flag(), self.recommended(), config.CORE_VERSION, + config.ENGINE_VERSION, config.GFX_VERSION, + }, + ) catch "core/engine/gfx version floor violated (diagnostic too long to render)"; + } +}; + +/// The floor the three pins violate, or null when the trio is coherent. A +/// compile break beats a curated floor; among equals the first (strictest) +/// wins. Pins that are not release versions (`local:…`) are resolved +/// elsewhere and leave the trio unjudged. +pub fn trioFloorViolation(core_version: []const u8, engine_version: []const u8, gfx_version: []const u8) error{UnparsableVersionPin}!?TrioViolation { + if (!config.isSemverVersion(core_version) or !config.isSemverVersion(engine_version) or !config.isSemverVersion(gfx_version)) return null; + var worst: ?TrioViolation = null; + for (trio_floors) |f| { + const v: TrioViolation = .{ .core_version = core_version, .engine_version = engine_version, .gfx_version = gfx_version, .floor = f }; + if (!try pinAtLeast(v.pinOf(f.subject), f.subject_at_least)) continue; + if (try pinAtLeast(v.pinOf(f.requires), f.floor)) continue; + if (worst == null or (f.severity == .compile_break and worst.?.floor.severity != .compile_break)) worst = v; + } + return worst; +} + +test "init refuses --engine-version=2.12.2 under the 2.0.0 core/gfx defaults with a named diagnostic; defaults pass — #733 review round 3" { + var buf: [1024]u8 = undefined; + // The front-door failure: the explicit engine pin against the curated + // core/gfx defaults trips a COMPILE-BREAK floor (refused, not warned), + // and the diagnostic names all three pins, the floor, and the flag. + const v = (try trioFloorViolation(config.CORE_VERSION, "2.12.2", config.GFX_VERSION)) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.compile_break, v.severity()); + try std.testing.expectEqual(TrioPackage.engine, v.floor.requires); + try std.testing.expectEqualStrings("3.0.0", v.floor.floor); + const msg = v.describe(&buf); + try std.testing.expect(std.mem.startsWith(u8, msg, "labelle-gfx 2.0.0 requires labelle-engine >= 3.0.0; got core 2.0.0 / engine 2.12.2 / gfx 2.0.0 (")); + try std.testing.expect(std.mem.indexOf(u8, msg, "PixelWater") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "Pass --engine-version=3.0.0") != null); + + // The other direction and the other package: an old gfx or core under + // the new engine is refused too, naming the right flag. + const g = (try trioFloorViolation("2.0.0", "3.0.0", "1.30.1")) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.compile_break, g.severity()); + try std.testing.expect(std.mem.indexOf(u8, g.describe(&buf), "Pass --gfx-version=2.0.0") != null); + const c = (try trioFloorViolation("1.32.0", "3.0.0", "2.0.0")) orelse return error.TestUnexpectedResult; + try std.testing.expect(std.mem.indexOf(u8, c.describe(&buf), "Pass --core-version=2.0.0") != null); + + // A CURATED floor is reported as a warning-shaped violation, and a + // compile break wins over it when both are tripped. + const w = (try trioFloorViolation("1.28.0", "2.12.0", "1.30.1")) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.curated, w.severity()); + try std.testing.expect(std.mem.indexOf(u8, w.describe(&buf), "is released against labelle-engine >= 2.12.1") != null); + const both = (try trioFloorViolation("1.27.0", "2.12.0", "1.30.1")) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.compile_break, both.severity()); + + // Accepted: the defaults, every earlier curated set, and non-release pins. + try std.testing.expect((try trioFloorViolation(config.CORE_VERSION, config.ENGINE_VERSION, config.GFX_VERSION)) == null); + try std.testing.expect((try trioFloorViolation("2.0.0", "3.0.0", "2.0.0")) == null); + try std.testing.expect((try trioFloorViolation("1.32.0", "2.12.2", "1.30.1")) == null); + try std.testing.expect((try trioFloorViolation("local:../labelle-core", "2.12.2", "2.0.0")) == null); + try std.testing.expectError(error.UnparsableVersionPin, trioFloorViolation("1.2.3.4", "3.0.0", "2.0.0")); +} + /// Materialize a new project directory from `opts`. Exits the process /// non-zero on a filesystem failure (the CLI propagates the exit code). /// Split out from `cmdInit` so it can be unit-tested without building an @@ -550,82 +833,16 @@ test "scaffold pins real fetchable framework versions — #159 regression" { try std.testing.expect(config.isSemverVersion(cfg.gfx_version)); } -/// Parse a release-shaped version pin into a `std.SemanticVersion`. -/// -/// The pins reaching `checkTrioFloors` are NOT all `build.zig` literals — -/// `config.*_VERSION` carries any `-D*_version=` override — so this cannot -/// assume the strict `X.Y.Z` form `std.SemanticVersion.parse` demands. -/// `config.isSemverVersion` (digits-and-dots, >= 1 dot) also admits the -/// abbreviated `X.Y` release form, which the rest of the version-resolution -/// code accepts: `scene_manifest.engineSupportsTargetOverrides` normalizes -/// it by appending `.0`, and this does the same rather than inventing a -/// second convention. -/// -/// A dotted string that is still unparsable after padding (`1.2.3.4`, which -/// `isSemverVersion` also admits) returns `error.UnparsableVersionPin` with -/// the offending value named — a readable test failure instead of the -/// `catch unreachable` crash this used to take (#683 review). -fn parsePin(version: []const u8) error{UnparsableVersionPin}!std.SemanticVersion { - if (std.SemanticVersion.parse(version)) |v| return v else |_| {} - var buf: [64]u8 = undefined; - const padded = std.fmt.bufPrint(&buf, "{s}.0", .{version}) catch { - std.debug.print("version pin '{s}' is too long to normalize\n", .{version}); - return error.UnparsableVersionPin; - }; - return std.SemanticVersion.parse(padded) catch { - std.debug.print( - "version pin '{s}' is not a usable release version (want `X.Y.Z` or `X.Y`)\n", - .{version}, - ); - return error.UnparsableVersionPin; - }; -} - -/// `pin >= floor` on two release-shaped version strings, for the -/// curated-trio floor guard below. -fn pinAtLeast(pin: []const u8, floor: []const u8) error{UnparsableVersionPin}!bool { - const p = try parsePin(pin); - const f = try parsePin(floor); - return p.order(f) != .lt; -} +/// The pin comparison lives in `config` (PR #733) so the generate-time +/// materials contract gate shares it; the guards below keep the short name. +const pinAtLeast = config.pinAtLeast; -/// The cross-package floors the curated trio must satisfy, as a pure -/// function of the three pins so the rules can be exercised against -/// synthetic trios (see the table test below) and not only against -/// whatever `build.zig` currently defaults to. -/// -/// #679: gfx >= 1.30.0 re-exports `core.TextureId` rather than declaring -/// its own (labelle-gfx#328). That type landed in core 1.28.0, and the -/// assembler unifies every package onto the project's `core_version` — so -/// gfx 1.30.x against core < 1.28.0 dies inside the dependency with -/// "root source file struct 'root' has no member named 'TextureId'". -/// Engine >= 2.12.1 is the matching half (`game.nativeTextureId` compiles -/// against the typed gfx surface, labelle-engine#813 phase 4). -/// -/// engine >= 2.11.0 floors core >= 1.27.0: core's `ChildrenComponent` went -/// ArrayList-backed and `addChild` takes an allocator (labelle-core#65/#66), -/// and the engine takes a REAL module dependency on core — an older core -/// fails to compile. -/// -/// engine >= 2.11.0 also floors gfx >= 1.28.0, but that one is a CURATED -/// floor, not a compile break: the engine module takes no direct gfx -/// dependency (the renderer arrives via `RenderImpl`), and its post-fx -/// passthrough is `@hasDecl`-gated — labelle-engine `src/game/post_fx_mixin.zig` -/// says in so many words that "an older gfx (< v1.28.0) … compiles to a -/// no-op". So the pairing BUILDS; it just silently drops the post-fx stack -/// the engine advertises. A curated set is what `labelle init` stamps and -/// `upgrade all` writes, so it must be the coherent set, not merely one -/// that compiles — hence the assertion, with the distinction recorded here -/// so nobody later "fixes" a legitimate compile failure by relaxing it. +/// Test-shaped wrapper over the PRODUCTION trio validator `init` runs +/// (`trioFloorViolation`): a curated set must be COHERENT, so any floor +/// violation — compile break or curated — fails the assertion. The table +/// the tests pin is the table the user hits (#733 review, round 3). fn checkTrioFloors(core_version: []const u8, engine_version: []const u8, gfx_version: []const u8) !void { - if (try pinAtLeast(gfx_version, "1.30.0")) { - try std.testing.expect(try pinAtLeast(core_version, "1.28.0")); - try std.testing.expect(try pinAtLeast(engine_version, "2.12.1")); - } - if (try pinAtLeast(engine_version, "2.11.0")) { - try std.testing.expect(try pinAtLeast(core_version, "1.27.0")); - try std.testing.expect(try pinAtLeast(gfx_version, "1.28.0")); - } + if (try trioFloorViolation(core_version, engine_version, gfx_version)) |_| return error.TestUnexpectedResult; } test "scaffold pins a MUTUALLY COMPATIBLE trio — #679 regression" { @@ -650,9 +867,18 @@ test "curated-trio floors reject each incoherent combination — #683 review" { // The floors themselves, against synthetic trios: the test above can // only ever see whatever `build.zig` defaults to today, so without // these a floor could be silently dropped and still go green. - try checkTrioFloors("1.32.0", "2.12.2", "1.30.1"); // the current curated set + try checkTrioFloors("2.0.0", "3.0.0", "2.0.0"); // the current curated set (PR #733) + try checkTrioFloors("1.32.0", "2.12.2", "1.30.1"); // the #736 curated set — still coherent try checkTrioFloors("1.28.0", "2.12.2", "1.30.1"); // the pre-#731 curated set — still coherent + // The 2.0.0 line does not mix with the 1.x line in any direction: + // gfx 2.0.0 needs core 2.0.0 + engine 3.0.0; engine 3.0.0 needs + // core 2.0.0 + gfx 2.0.0. + try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("1.32.0", "3.0.0", "2.0.0")); + try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("2.0.0", "2.12.2", "2.0.0")); + try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("2.0.0", "3.0.0", "1.30.1")); + try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("1.32.0", "3.0.0", "1.30.1")); + // gfx >= 1.30.0 needs core >= 1.28.0 and engine >= 2.12.1. try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("1.27.0", "2.12.2", "1.30.1")); try std.testing.expectError(error.TestUnexpectedResult, checkTrioFloors("1.28.0", "2.12.0", "1.30.1")); @@ -685,7 +911,12 @@ test "scaffold core default pairs with the builtin bgfx provider — #731 review } test "bgfx-provider floors reject the #731 pairings" { - try checkBgfxProviderFloors("1.32.0", "0.20.0"); // the curated pairing + try checkBgfxProviderFloors("2.0.0", "0.21.0"); // the curated pairing (PR #733) + try checkBgfxProviderFloors("1.32.0", "0.20.0"); // the #736 curated pairing — still coherent + // bgfx >= 0.21.0 is contract-v2: the hard floor is core 2.0.0, so the + // #736 core is rejected under the new provider default. + try std.testing.expectError(error.TestUnexpectedResult, checkBgfxProviderFloors("1.32.0", "0.21.0")); + try std.testing.expectError(error.TestUnexpectedResult, checkBgfxProviderFloors("1.28.0", "0.21.0")); // What #731 shipped: the bgfx 0.20.0 default over the core 1.28.0 scaffold default. try std.testing.expectError(error.TestUnexpectedResult, checkBgfxProviderFloors("1.28.0", "0.20.0")); // What #731's examples pinned: bgfx 0.20.0 over core 1.26.0 — the compile break. @@ -695,36 +926,45 @@ test "bgfx-provider floors reject the #731 pairings" { try checkBgfxProviderFloors("1.26.0", "0.13.1"); } -test "init refuses --backend=bgfx --core-version=1.26.0 with a named diagnostic, accepts 1.32.0 — #736 review" { +test "init refuses --backend=bgfx --core-version=1.26.0 with a named diagnostic, accepts 2.0.0 — #736 review" { const bgfx = config.ProjectConfig.builtinProvider(.bgfx) orelse return error.TestUnexpectedResult; var buf: [512]u8 = undefined; var want_buf: [128]u8 = undefined; // The front-door failure: the hard floor is the one reported (it is the // actionable one), the diagnostic names backend + REAL provider version, - // the floor, the requested core, and recommends the curated core. + // the floor, the requested core, and recommends the curated core. Under + // the contract-v2 default (bgfx 0.21.0, PR #733) the strictest hard + // floor is core 2.0.0 and every 1.x core trips it. const v = (try backendCoreFloorViolation("bgfx", "1.26.0")) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(FloorSeverity.compile_break, v.severity); - try std.testing.expectEqualStrings("1.28.0", v.core_floor); - try std.testing.expectEqualStrings("1.32.0", v.recommended_core); + try std.testing.expectEqualStrings("2.0.0", v.core_floor); + try std.testing.expectEqualStrings("2.0.0", v.recommended_core); const msg = v.describe(&buf); - const want = try std.fmt.bufPrint(&want_buf, "bgfx {s} requires labelle-core >= 1.28.0; got 1.26.0 (", .{bgfx.version}); + const want = try std.fmt.bufPrint(&want_buf, "bgfx {s} requires labelle-core >= 2.0.0; got 1.26.0 (", .{bgfx.version}); try std.testing.expect(std.mem.startsWith(u8, msg, want)); - try std.testing.expect(std.mem.indexOf(u8, msg, "BackendTextureId") != null); - try std.testing.expect(std.mem.indexOf(u8, msg, "--core-version=1.32.0") != null); - - // What #731's scaffold stamped: builds, so it is a WARNING, not a refusal. - const c = (try backendCoreFloorViolation("bgfx", "1.28.0")) orelse return error.TestUnexpectedResult; - try std.testing.expectEqual(FloorSeverity.curated, c.severity); - try std.testing.expectEqualStrings("1.32.0", c.core_floor); - const cmsg = c.describe(&buf); - try std.testing.expect(std.mem.indexOf(u8, cmsg, "is released against labelle-core >= 1.32.0; got 1.28.0") != null); - try std.testing.expect(std.mem.indexOf(u8, cmsg, "requires") == null); + try std.testing.expect(std.mem.indexOf(u8, msg, "shader_material") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "--core-version=2.0.0") != null); + + // The #736 curated core is now a compile break too (not a warning): the + // default provider no longer compiles on it. + const c = (try backendCoreFloorViolation("bgfx", "1.32.0")) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.compile_break, c.severity); + try std.testing.expectEqualStrings("2.0.0", c.core_floor); + + // The CURATED (warn-and-scaffold) severity still exists for the older + // provider: bgfx 0.20.0 over core 1.28.0 builds, so it is a WARNING. + const w = (try providerCoreFloorViolation(.bgfx, "0.20.0", "1.28.0")) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(FloorSeverity.curated, w.severity); + try std.testing.expectEqualStrings("1.32.0", w.core_floor); + const wmsg = w.describe(&buf); + try std.testing.expect(std.mem.indexOf(u8, wmsg, "is released against labelle-core >= 1.32.0; got 1.28.0") != null); + try std.testing.expect(std.mem.indexOf(u8, wmsg, "requires") == null); // Accepted: the curated core, the scaffold's own default, and anything newer. - try std.testing.expect((try backendCoreFloorViolation("bgfx", "1.32.0")) == null); + try std.testing.expect((try backendCoreFloorViolation("bgfx", "2.0.0")) == null); try std.testing.expect((try backendCoreFloorViolation("bgfx", config.CORE_VERSION)) == null); - try std.testing.expect((try backendCoreFloorViolation("bgfx", "1.40")) == null); + try std.testing.expect((try backendCoreFloorViolation("bgfx", "2.1")) == null); // Not this validator's business: other backends, a non-release core pin, // a backend name the enum does not know (init's own parser rejects it). diff --git a/src/material_build.zig b/src/material_build.zig new file mode 100644 index 00000000..5497a74d --- /dev/null +++ b/src/material_build.zig @@ -0,0 +1,67 @@ +//! Staged beside generated build.zig. Shader compilation belongs to Zig's graph. +const std = @import("std"); +const schema = @import("material_schema.zig"); +pub const Input = struct { name: []const u8, json: []const u8 }; +pub fn create(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, core: *std.Build.Module, inputs: []const Input, platform: []const u8) *std.Build.Module { + // Always a HOST executable, even for Android, Emscripten and iOS. + const tool = b.dependency("material_shaderc", .{ .target = b.graph.host, .with_shaderc = true }); + const override = b.option([]const u8, "shaderc", "Override the pinned host shaderc executable") orelse b.graph.environ_map.get("LABELLE_SHADERC"); + if (override) |exe| if (!std.fs.path.isAbsolute(exe)) { + std.debug.panic("shaderc override must be an absolute host executable path: {s}", .{exe}); + }; + const files = b.addWriteFiles(); + const varying = b.addWriteFiles().add("sprite-varying.def.sc", schema.varying); + var source = std.Io.Writer.Allocating.init(b.allocator); + source.writer.writeAll("comptime { if (!@hasDecl(@import(\"labelle-core\").backend_contract, \"MATERIAL_CONTRACT_VERSION\") or @import(\"labelle-core\").backend_contract.MATERIAL_CONTRACT_VERSION != 2) @compileError(\"game-owned materials require material contract v2\"); }\n") catch @panic("OOM"); + for (inputs) |input| { + if (!schema.materialName(input.name)) std.debug.panic("materials/{s}: invalid or reserved material name", .{input.name}); + const parsed = schema.parse(b.allocator, input.json) catch |err| std.debug.panic("materials/{s}/material.json: {s}", .{ input.name, @errorName(err) }); + const d = parsed.value; + var has_required = false; + const required: schema.Target = if (std.mem.eql(u8, platform, "wasm") or std.mem.eql(u8, platform, "android")) .essl else if (target.result.os.tag.isDarwin()) .mtl else .spv; + for (d.targets) |variant| if (variant == required) { + has_required = true; + }; + if (!has_required and !std.mem.eql(u8, platform, "tests")) std.debug.panic("materials/{s}/material.json: target requires '{s}' in targets", .{ input.name, @tagName(required) }); + schema.render(&source.writer, input.name, d) catch @panic("OOM"); + for (d.targets) |variant| { + const run = if (override) |exe| b.addSystemCommand(&.{exe}) else b.addRunArtifact(tool.artifact("shaderc")); + run.step.name = b.fmt("shader {s} ({s})", .{ input.name, @tagName(variant) }); + if (override) |exe| run.addFileInput(.{ .cwd_relative = exe }); + run.addArgs(&.{ "--type", "fragment", "--platform", schema.platform(variant), "-p", schema.profile(variant), "-O", "3", "-f" }); + run.addFileArg(b.path(b.fmt("materials/{s}/{s}", .{ input.name, d.fragment }))); + run.addArg("--varyingdef"); + run.addFileArg(varying); + run.addArg("-i"); + run.addDirectoryArg(tool.path("shaders")); + run.addArg("-i"); + run.addDirectoryArg(b.path(b.fmt("materials/{s}", .{input.name}))); + run.addArg("-i"); + run.addDirectoryArg(b.path("materials")); + // shaderc's include search must invalidate the cache as well as its -f input. + trackTree(b, run, tool.path("shaders"), false); + trackTree(b, run, b.path("materials"), true); + run.addArg("-o"); + const out_name = b.fmt("{s}.{s}.bin", .{ input.name, @tagName(variant) }); + _ = files.addCopyFile(run.addOutputFileArg(out_name), out_name); + } + } + return b.createModule(.{ .root_source_file = files.add("materials.zig", source.written()), .target = target, .optimize = optimize, .imports = &.{.{ .name = "labelle-core", .module = core }} }); +} +fn trackTree(b: *std.Build, run: *std.Build.Step.Run, path: std.Build.LazyPath, validate: bool) void { + const io = b.graph.io; + var dir = std.Io.Dir.cwd().openDir(io, path.getPath(b), .{ .iterate = true }) catch |err| std.debug.panic("shader include directory: {s}", .{@errorName(err)}); + defer dir.close(io); + var walker = dir.walk(b.allocator) catch @panic("OOM"); + defer walker.deinit(); + while (walker.next(io) catch |err| std.debug.panic("shader include scan: {s}", .{@errorName(err)})) |entry| { + if (entry.kind == .sym_link) std.debug.panic("shader package cannot contain symlinks: {s}", .{entry.path}); + if (entry.kind == .file) { + run.addFileInput(path.path(b, entry.path)); + if (validate) { + const bytes = dir.readFileAlloc(io, entry.path, b.allocator, .limited(1024 * 1024)) catch |err| std.debug.panic("shader source {s}: {s}", .{ entry.path, @errorName(err) }); + schema.validateIncludes(bytes) catch |err| std.debug.panic("shader source {s}: {s}; includes must be package-local literal paths", .{ entry.path, @errorName(err) }); + } + } + } +} diff --git a/src/material_pipeline.zig b/src/material_pipeline.zig new file mode 100644 index 00000000..3cda6a9e --- /dev/null +++ b/src/material_pipeline.zig @@ -0,0 +1,271 @@ +//! Discover game-owned materials, validate before emission, stage build support. +const std = @import("std"); +const config = @import("config.zig"); +const scanner = @import("scanner.zig"); +pub const schema = @import("material_schema.zig"); +/// Whether the selected backend can consume game-owned `.sc` materials. +/// +/// Keyed off the `.bgfx` ENUM TAG, never `backendName()` (PR #733 review, +/// same reasoning as `ProjectConfig.effectiveGamepad`): the name is the +/// resolved PACKAGE name, which a `.backend = .bgfx` project can override to +/// anything (`.backend_package = .{ .name = "bgfx_v2", .. }` — the in-tree v2 +/// fixture — or a fork named `labelle-bgfx`). A literal `"bgfx"` string match +/// rejected every such compatible provider with `UnsupportedMaterialBackend`. +/// The tag survives the enum-as-shorthand resolution, so it is the reliable +/// "is bgfx" signal. `.null` is accepted because the tests target +/// (`testsTargetConfig`) force-substitutes it while the game still ships the +/// materials module. +/// +/// LIMITATION (documented, like `effectiveGamepad`): a third-party bgfx-shaped +/// provider selected purely via `.backend_package` with `.backend` left at its +/// `.raylib` default is rejected; declare `.backend = .bgfx` alongside the +/// package. There is no material capability in the provider manifest yet. +pub fn requireBackend(cfg: config.ProjectConfig) error{UnsupportedMaterialBackend}!void { + switch (cfg.backend) { + .bgfx, .null => {}, + else => return error.UnsupportedMaterialBackend, + } +} +/// The first labelle-bgfx release that implements material contract v2 +/// (what `material_build.zig`'s generated module `@compileError`s without), +/// and the first labelle-core that declares it. Both pins must be at least +/// these for a project that owns `materials/`. +pub const min_bgfx_for_materials = "0.21.0"; +pub const min_core_for_materials = "2.0.0"; + +pub const ContractError = error{ MaterialContractUnsupported, UnparsableVersionPin }; + +/// Reject, at GENERATE time, a project that owns materials but pins a +/// backend or core release that predates material contract v2 (PR #733 P1, +/// the "reject incompatible release pins" half). With the defaults +/// (`builtinProvider` bgfx 0.21.0 + the core 2.0.0 scaffold trio) this is +/// unreachable; an EXPLICIT `.backend_package` / `.core_version` that pins +/// an older release used to generate successfully and always fail at the +/// generated module's `@compileError` — a deep compile break instead of a +/// diagnostic naming the pin. Only release-shaped (semver) pins are +/// judged: a `local:…` checkout is resolved elsewhere and left alone. The +/// general generate-time floor check is labelle-assembler#739; this is the +/// materials-specific gate only. +/// +/// Returns the offending side so the caller can name it; the tests target +/// (`.backend = .null`) is skipped like `requireBackend` skips it. +pub const ContractViolation = struct { what: []const u8, pinned: []const u8, floor: []const u8 }; +pub fn contractViolation(cfg: config.ProjectConfig) error{UnparsableVersionPin}!?ContractViolation { + if (cfg.backend != .bgfx) return null; + if (cfg.effectiveBackendPackage()) |bp| { + // The 0.21.0 floor is a fact about the OFFICIAL labelle-bgfx release + // train only. A custom provider (`.backend_package` on another repo) + // carries its OWN semver — comparing e.g. acme/bgfx 0.1.0 against + // 0.21.0 would reject a contract-v2-capable provider as an obsolete + // labelle-bgfx (#733 review, round 3). Such a provider is validated + // by the generated module's `MATERIAL_CONTRACT_VERSION == 2` guard, + // which covers arbitrary providers. + if (isOfficialBgfx(bp) and config.isSemverVersion(bp.version) and !try config.pinAtLeast(bp.version, min_bgfx_for_materials)) + return .{ .what = "labelle-bgfx", .pinned = bp.version, .floor = min_bgfx_for_materials }; + } + if (config.isSemverVersion(cfg.core_version) and !try config.pinAtLeast(cfg.core_version, min_core_for_materials)) + return .{ .what = "labelle-core", .pinned = cfg.core_version, .floor = min_core_for_materials }; + return null; +} +/// The resolved provider IS the official labelle-bgfx release train: the +/// builtin provider's repo (the enum-as-shorthand default, or an explicit +/// `.backend_package` that pins that same repo). Keyed off the REPO, never +/// the package name — the name is configurable (`requireBackend`). +pub fn isOfficialBgfx(bp: config.PluginDep) bool { + const official = config.ProjectConfig.builtinProvider(.bgfx) orelse return false; + return std.mem.eql(u8, bp.repo, official.repo); +} +pub fn requireContract(cfg: config.ProjectConfig) ContractError!void { + if (try contractViolation(cfg)) |_| return error.MaterialContractUnsupported; +} +pub fn stage(a: std.mem.Allocator, game_dir: []const u8, target_dir: []const u8, cfg: config.ProjectConfig) ![][]const u8 { + const io = config.globalIo(); + const root = try std.fs.path.join(a, &.{ game_dir, "materials" }); + defer a.free(root); + var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return a.alloc([]const u8, 0), + else => return err, + }; + defer dir.close(io); + var names: std.ArrayList([]const u8) = .empty; + errdefer { + for (names.items) |n| a.free(n); + names.deinit(a); + } + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .directory or entry.name[0] == '.') continue; + const rel = try std.fs.path.join(a, &.{ entry.name, "material.json" }); + defer a.free(rel); + const bytes = dir.readFileAlloc(io, rel, a, .limited(1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + defer a.free(bytes); + if (!schema.materialName(entry.name)) { + std.log.err("materials/{s}: expected identifier directory name (max 63 bytes; generated-symbol names are reserved)", .{entry.name}); + return error.InvalidMaterialName; + } + for (names.items) |n| if (std.ascii.eqlIgnoreCase(n, entry.name)) { + // Case-insensitive: the generated module exports one Zig decl per + // folder, and a case-only difference collides on the many hosts + // whose filesystems fold case. + std.log.err("materials/{s}: collides with materials/{s} (material folder names must be unique ignoring case)", .{ entry.name, n }); + return error.DuplicateMaterialName; + }; + const parsed = schema.parse(a, bytes) catch |err| { + std.log.err("materials/{s}: {s}", .{ rel, @errorName(err) }); + return err; + }; + defer parsed.deinit(); + const fragment = try std.fs.path.join(a, &.{ entry.name, parsed.value.fragment }); + defer a.free(fragment); + dir.access(io, fragment, .{}) catch |err| { + std.log.err("materials/{s}: fragment '{s}' unavailable ({s})", .{ rel, parsed.value.fragment, @errorName(err) }); + return error.MissingMaterialFragment; + }; + const name = try a.dupe(u8, entry.name); + errdefer a.free(name); + try names.append(a, name); + } + if (names.items.len != 0) { + requireBackend(cfg) catch |err| { + std.log.err("game-owned .sc materials require bgfx (selected backend: {s})", .{cfg.backendName()}); + return err; + }; + if (try contractViolation(cfg)) |v| { + std.log.err("materials/: game-owned materials require material contract v2 (labelle-bgfx >= {s} on labelle-core >= {s}); project pins {s} {s}. Bump the pin, or drop it to take the default.", .{ min_bgfx_for_materials, min_core_for_materials, v.what, v.pinned }); + return error.MaterialContractUnsupported; + } + std.mem.sort([]const u8, names.items, {}, struct { + fn less(_: void, x: []const u8, y: []const u8) bool { + return std.mem.lessThan(u8, x, y); + } + }.less); + try scanner.linkDir(a, game_dir, target_dir, "materials"); + try scanner.writeFile(target_dir, "material_build.zig", @embedFile("material_build.zig")); + try scanner.writeFile(target_dir, "material_schema.zig", @embedFile("material_schema.zig")); + } + return names.toOwnedSlice(a); +} +pub fn emit(w: *std.Io.Writer, names: []const []const u8, platform: []const u8) !void { + if (names.len == 0) return; + try w.writeAll(" const materials_mod = @import(\"material_build.zig\").create(b, target, optimize, core_mod, &.{\n"); + for (names) |name| try w.print(" .{{ .name = \"{s}\", .json = @embedFile(\"materials/{s}/material.json\") }},\n", .{ name, name }); + try w.print(" }}, \"{s}\");\n overrideImport(game_mod, \"materials\", materials_mod);\n", .{platform}); +} +pub fn emitImport(w: *std.Io.Writer, names: []const []const u8, artifact: []const u8) !void { + if (names.len != 0) try w.print(" {s}.root_module.addImport(\"materials\", materials_mod);\n", .{artifact}); +} + +test "materials build emission has explicit embedded descriptors and empty no-op" { + _ = schema; + var out = std.Io.Writer.Allocating.init(std.testing.allocator); + defer out.deinit(); + try emit(&out.writer, &.{}, "desktop"); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + try emit(&out.writer, &.{ "fog", "lamp" }, "desktop"); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "materials/fog/material.json") != null); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "materials/lamp/material.json") != null); +} + +test "requireBackend: keyed off the .bgfx enum tag, not the resolved package name (#733 P2)" { + // A `.backend = .bgfx` project whose provider package is NOT literally named + // "bgfx" — the in-tree v2 fixture spelling this repo itself uses — must be + // accepted: the tag is the identity, the name is configurable. + try requireBackend(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx_v2", .repo = "local:backends/bgfx_v2" }, + }); + // Plain enum-as-shorthand default and the tests-target `.null` substitution. + try requireBackend(.{ .name = "g", .backend = .bgfx }); + try requireBackend(.{ .name = "g", .backend = .null }); + // A non-bgfx backend is still rejected — including one whose PACKAGE is + // named "bgfx" (the name must not be the signal in either direction). + try std.testing.expectError(error.UnsupportedMaterialBackend, requireBackend(.{ .name = "g", .backend = .sokol })); + try std.testing.expectError(error.UnsupportedMaterialBackend, requireBackend(.{ + .name = "g", + .backend = .sokol, + .backend_package = .{ .name = "bgfx", .repo = "local:backends/bgfx_v2" }, + })); +} + +test "contractViolation: an explicit pre-contract-v2 bgfx or core pin is rejected at generate time; defaults and local pins pass (#733 P1)" { + // The defaults: builtinProvider bgfx + the scaffold core — unreachable. + try std.testing.expect((try contractViolation(.{ .name = "g", .backend = .bgfx })) == null); + try requireContract(.{ .name = "g", .backend = .bgfx }); + // An explicit OLD backend pin (what material-demo / pack-city carry) with + // materials/ → named, not a deep @compileError. + const old_bgfx = try contractViolation(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.20.0" }, + .core_version = "2.0.0", + }) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("labelle-bgfx", old_bgfx.what); + try std.testing.expectEqualStrings("0.20.0", old_bgfx.pinned); + try std.testing.expectEqualStrings("0.21.0", old_bgfx.floor); + // An explicit OLD core under a new backend. + const old_core = try contractViolation(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.21.0" }, + .core_version = "1.32.0", + }) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("labelle-core", old_core.what); + try std.testing.expectEqualStrings("1.32.0", old_core.pinned); + try std.testing.expectError(error.MaterialContractUnsupported, requireContract(.{ .name = "g", .backend = .bgfx, .core_version = "1.32.0" })); + // The released pair, and anything newer, passes; an abbreviated pin too. + try requireContract(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.21.0" }, + .core_version = "2.0.0", + }); + try requireContract(.{ .name = "g", .backend = .bgfx, .core_version = "2.1" }); + // Non-release pins are resolved elsewhere — not judged here. + try requireContract(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx", .repo = "local:../labelle-bgfx", .version = "local:../labelle-bgfx" }, + .core_version = "local:../labelle-core", + }); + // The tests target substitutes `.null`; other backends are `requireBackend`'s business. + try requireContract(.{ .name = "g", .backend = .null, .core_version = "1.26.0" }); + try requireContract(.{ .name = "g", .backend = .sokol, .core_version = "1.26.0" }); + // A dotted-but-unparsable pin surfaces as the named error, not a crash. + try std.testing.expectError(error.UnparsableVersionPin, requireContract(.{ .name = "g", .backend = .bgfx, .core_version = "1.2.3.4" })); +} + +test "contractViolation: the 0.21.0 floor applies to the OFFICIAL labelle-bgfx only — a custom provider's own semver is not judged (#733 round 3)" { + // A compatible custom provider with its own version line, selected by a + // `.backend = .bgfx` project: ACCEPTED at generate (the generated + // MATERIAL_CONTRACT_VERSION guard validates it instead). + try requireContract(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx_v2", .repo = "github.com/acme/bgfx", .version = "0.1.0" }, + .core_version = "2.0.0", + }); + try std.testing.expect(!isOfficialBgfx(.{ .name = "bgfx_v2", .repo = "github.com/acme/bgfx", .version = "0.1.0" })); + // The official train, pinned explicitly or via the default, is judged: + // 0.20.0 still rejected, whatever the package is CALLED. + try std.testing.expect(isOfficialBgfx(config.ProjectConfig.builtinProvider(.bgfx).?)); + const v = (try contractViolation(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx_v2", .repo = "github.com/labelle-toolkit/labelle-bgfx", .version = "0.20.0" }, + .core_version = "2.0.0", + })) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("labelle-bgfx", v.what); + try std.testing.expectEqualStrings("0.20.0", v.pinned); + // The core floor is about labelle-core itself and still applies under a + // custom provider. + try std.testing.expectError(error.MaterialContractUnsupported, requireContract(.{ + .name = "g", + .backend = .bgfx, + .backend_package = .{ .name = "bgfx_v2", .repo = "github.com/acme/bgfx", .version = "0.1.0" }, + .core_version = "1.32.0", + })); +} diff --git a/src/material_schema.zig b/src/material_schema.zig new file mode 100644 index 00000000..b70f056e --- /dev/null +++ b/src/material_schema.zig @@ -0,0 +1,234 @@ +//! Versioned, backend-neutral JSON authoring contract. Shared by generate and build. +const std = @import("std"); +pub const Target = enum { spv, glsl, essl, mtl, dx11 }; +pub const Kind = enum { scalar, vec2, vec3, vec4, mat4 }; +pub const Parameter = struct { name: []const u8, kind: Kind, count: u16 = 1, defaults: []const f32 = &.{} }; +pub const Texture = struct { name: []const u8, sampler: enum { point, linear } = .point }; +pub const Descriptor = struct { + version: u32, + label: ?[]const u8 = null, + fragment: []const u8, + targets: []const Target, + parameters: []const Parameter = &.{}, + textures: []const Texture = &.{}, + blend: enum { alpha, additive } = .alpha, +}; +pub const tool_url = "https://github.com/labelle-toolkit/zbgfx/archive/934372f13b92e651c9e43613af6dff96d231e782.tar.gz"; +pub const tool_hash = "zbgfx-0.12.0-Sm4IxBGjywYbjOha8_dczGXQq53qynqGka3vWDhKI3pD"; +pub const varying = + \\vec4 v_color0 : COLOR0 = vec4(1.0, 1.0, 1.0, 1.0); + \\vec2 v_texcoord0 : TEXCOORD0 = vec2(0.0, 0.0); + \\vec2 a_position : POSITION; + \\vec4 a_color0 : COLOR0; + \\vec2 a_texcoord0 : TEXCOORD0; + \\ +; +pub fn identifier(name: []const u8) bool { + if (name.len == 0 or name.len > 63) return false; + if (!std.ascii.isAlphabetic(name[0]) and name[0] != '_') return false; + for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_') return false; + return true; +} +pub fn materialName(name: []const u8) bool { + if (!identifier(name)) return false; + for ([_][]const u8{ "_", "core", "sm", "parameters", "textures", "shaders", "label", "blend", "descriptor", "bindings" }) |reserved| { + if (std.mem.eql(u8, name, reserved)) return false; + } + return true; +} +pub fn relativePath(path: []const u8) bool { + if (path.len == 0 or std.fs.path.isAbsolute(path)) return false; + // Portable project paths, including on Windows; reject drive/UNC paths. + if (std.mem.indexOfAny(u8, path, "\\:\x00") != null) return false; + var parts = std.mem.splitScalar(u8, path, '/'); + while (parts.next()) |p| if (p.len == 0 or std.mem.eql(u8, p, ".") or std.mem.eql(u8, p, "..")) return false; + return true; +} +pub fn parse(a: std.mem.Allocator, json: []const u8) !std.json.Parsed(Descriptor) { + const parsed = try std.json.parseFromSlice(Descriptor, a, json, .{ .allocate = .alloc_always }); + errdefer parsed.deinit(); + const d = parsed.value; + if (d.version != 1) return error.UnsupportedMaterialVersion; + if (!relativePath(d.fragment) or !std.mem.endsWith(u8, d.fragment, ".sc")) return error.InvalidFragmentPath; + if (d.targets.len == 0) return error.MissingShaderTargets; + for (d.targets, 0..) |t, i| for (d.targets[0..i]) |prev| { + if (t == prev) return error.DuplicateShaderTarget; + }; + if (d.parameters.len > 16 or d.textures.len > 4) return error.TooManyMaterialBindings; + var registers: usize = 1; // automatic u_material_rect + for (d.parameters, 0..) |p, i| { + try bindingName(p.name); + if (p.count == 0) return error.InvalidParameterCount; + registers += @as(usize, p.count) * (if (p.kind == .mat4) @as(usize, 4) else 1); + if (registers > 64) return error.TooManyMaterialRegisters; + const channels: usize = switch (p.kind) { + .scalar => 1, + .vec2 => 2, + .vec3 => 3, + .vec4 => 4, + .mat4 => 16, + }; + if (p.defaults.len != 0 and p.defaults.len != channels * p.count) return error.InvalidParameterDefaults; + for (p.defaults) |v| if (!std.math.isFinite(v)) return error.NonFiniteParameterDefault; + for (d.parameters[0..i]) |prev| if (std.mem.eql(u8, prev.name, p.name)) return error.DuplicateMaterialBinding; + for (d.textures) |t| if (std.mem.eql(u8, t.name, p.name)) return error.DuplicateMaterialBinding; + } + for (d.textures, 0..) |t, i| { + try bindingName(t.name); + for (d.textures[0..i]) |prev| if (std.mem.eql(u8, prev.name, t.name)) return error.DuplicateMaterialBinding; + } + return parsed; +} +fn bindingName(name: []const u8) !void { + if (!identifier(name)) return error.InvalidMaterialBindingName; + for ([_][]const u8{ "s_tex", "u_material_rect", "u_viewRect", "u_viewTexel", "u_view", "u_invView", "u_proj", "u_invProj", "u_viewProj", "u_invViewProj", "u_model", "u_modelView", "u_modelViewProj", "u_alphaRef4" }) |reserved| { + if (std.mem.eql(u8, name, reserved)) return error.ReservedMaterialBinding; + } +} +/// Includes are literal paths within the tracked materials/header trees. +pub fn validateIncludes(source: []const u8) !void { + // C preprocessing splices escaped newlines before removing comments. Do + // likewise so comments/continuations cannot hide an escaping include. + const clean = try std.heap.page_allocator.alloc(u8, source.len); + defer std.heap.page_allocator.free(clean); + var n: usize = 0; + var i: usize = 0; + while (i < source.len) { + if (source[i] == '\\' and i + 1 < source.len) { + if (source[i + 1] == '\n') { + i += 2; + continue; + } + if (i + 2 < source.len and source[i + 1] == '\r' and source[i + 2] == '\n') { + i += 3; + continue; + } + } + clean[n] = source[i]; + n += 1; + i += 1; + } + i = 0; + var quote_char: u8 = 0; + while (i < n) : (i += 1) { + if (quote_char != 0) { + if (clean[i] == '\\' and i + 1 < n) { + i += 1; + continue; + } + if (clean[i] == quote_char) quote_char = 0; + continue; + } + if (clean[i] == '"' or clean[i] == '\'') { + quote_char = clean[i]; + continue; + } + if (clean[i] != '/' or i + 1 >= n) continue; + if (clean[i + 1] == '/') { + while (i < n and clean[i] != '\n') : (i += 1) clean[i] = ' '; + } else if (clean[i + 1] == '*') { + clean[i] = ' '; + clean[i + 1] = ' '; + i += 2; + while (i < n) : (i += 1) { + if (clean[i] == '*' and i + 1 < n and clean[i + 1] == '/') { + clean[i] = ' '; + clean[i + 1] = ' '; + i += 1; + break; + } + if (clean[i] != '\n') clean[i] = ' '; + } + } + } + var lines = std.mem.splitScalar(u8, clean[0..n], '\n'); + while (lines.next()) |line| { + var rest = std.mem.trimStart(u8, line, " \t\r"); + if (!std.mem.startsWith(u8, rest, "#")) continue; + rest = std.mem.trimStart(u8, rest[1..], " \t"); + if (!std.mem.startsWith(u8, rest, "include")) continue; + rest = std.mem.trimStart(u8, rest[7..], " \t"); + if (rest.len < 3 or (rest[0] != '"' and rest[0] != '<')) return error.NonLiteralShaderInclude; + const close: u8 = if (rest[0] == '"') '"' else '>'; + const end = std.mem.indexOfScalar(u8, rest[1..], close) orelse return error.InvalidShaderInclude; + if (!relativePath(rest[1 .. end + 1])) return error.EscapingShaderInclude; + } +} +pub fn profile(target: Target) []const u8 { + return switch (target) { + .spv => "spirv", + .glsl => "120", + .essl => "300_es", + .mtl => "metal", + .dx11 => "s_5_0", + }; +} +pub fn platform(target: Target) []const u8 { + return switch (target) { + .spv, .glsl => "linux", + .essl => "android", + .mtl => "osx", + .dx11 => "windows", + }; +} +// Zig string escaping is shared by descriptors and build-file generation. +pub fn quote(w: *std.Io.Writer, s: []const u8) !void { + try w.print("\"{f}\"", .{std.zig.fmtString(s)}); +} +pub fn render(w: *std.Io.Writer, name: []const u8, d: Descriptor) !void { + try w.print("pub const @\"{s}\" = struct {{\n", .{name}); + try w.writeAll("const sm = @import(\"labelle-core\").shader_material;\npub const label = "); + try quote(w, d.label orelse name); + try w.print(";\npub const blend: sm.Blend = .{s};\n", .{@tagName(d.blend)}); + try w.writeAll("pub const parameters = [_]sm.Parameter{\n"); + for (d.parameters) |p| { + try w.writeAll(".{ .name = "); + try quote(w, p.name); + try w.print(", .kind = .{s}, .count = {d}, .defaults = &.{{", .{ @tagName(p.kind), p.count }); + for (p.defaults) |v| try w.print("{e},", .{v}); + try w.writeAll("} },\n"); + } + try w.writeAll("};\npub const textures = [_]sm.TextureBinding{\n"); + for (d.textures) |t| { + try w.writeAll(".{ .name = "); + try quote(w, t.name); + try w.print(", .sampler = .{s} }},\n", .{@tagName(t.sampler)}); + } + try w.writeAll("};\npub const shaders = sm.ShaderVariants{\n"); + for (d.targets) |t| try w.print(".{s} = @embedFile(\"{s}.{s}.bin\"),\n", .{ @tagName(t), name, @tagName(t) }); + try w.writeAll("};\n/// Bindings are borrowed until create returns; keep the caller's array alive.\npub fn descriptor(bindings: *const [textures.len]sm.TextureBinding) sm.Descriptor {\nreturn .{ .label = "); + try quote(w, d.label orelse name); + try w.print(", .blend = .{s}, .parameters = ¶meters, .textures = bindings, .shaders = shaders }};\n}}\n}};\n", .{@tagName(d.blend)}); +} + +test "material schema rejects invalid paths versions reserved bindings and duplicate targets" { + const a = std.testing.allocator; + try std.testing.expectError(error.UnsupportedMaterialVersion, parse(a, "{\"version\":2,\"fragment\":\"f.sc\",\"targets\":[\"spv\"]}")); + try std.testing.expectError(error.InvalidFragmentPath, parse(a, "{\"version\":1,\"fragment\":\"../f.sc\",\"targets\":[\"spv\"]}")); + try std.testing.expectError(error.DuplicateShaderTarget, parse(a, "{\"version\":1,\"fragment\":\"f.sc\",\"targets\":[\"spv\",\"spv\"]}")); + try std.testing.expectError(error.ReservedMaterialBinding, parse(a, "{\"version\":1,\"fragment\":\"f.sc\",\"targets\":[\"spv\"],\"parameters\":[{\"name\":\"u_material_rect\",\"kind\":\"vec4\"}]}")); + try std.testing.expect(!relativePath("C:/f.sc")); + try std.testing.expect(!relativePath("a\\f.sc")); + try std.testing.expect(!materialName("sm")); + try std.testing.expect(!materialName("core")); + try std.testing.expect(!materialName("textures")); + try std.testing.expectError(error.ReservedMaterialBinding, bindingName("u_viewProj")); + try std.testing.expectError(error.EscapingShaderInclude, validateIncludes("#include \"../shared/f.sc\"")); + try std.testing.expectError(error.NonLiteralShaderInclude, validateIncludes("#include HEADER")); + try std.testing.expectError(error.EscapingShaderInclude, validateIncludes("/* prefix */ #/**/include \"../outside.sc\"")); + try std.testing.expectError(error.EscapingShaderInclude, validateIncludes("#inc\\\nlude \"../outside.sc\"")); + try validateIncludes("#include \n#include \"lib/color.sh\""); +} +test "material schema validates shapes and generates caller-owned textures and explicit variants" { + const a = std.testing.allocator; + const p = try parse(a, "{\"version\":1,\"fragment\":\"fog.sc\",\"targets\":[\"spv\",\"essl\"],\"parameters\":[{\"name\":\"u_density\",\"kind\":\"scalar\",\"defaults\":[0.5]}],\"textures\":[{\"name\":\"s_noise\",\"sampler\":\"linear\"}]}"); + defer p.deinit(); + var out = std.Io.Writer.Allocating.init(a); + defer out.deinit(); + try render(&out.writer, "fog", p.value); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "*const [textures.len]sm.TextureBinding") != null); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "fog.spv.bin") != null); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "fog.essl.bin") != null); + try std.testing.expect(std.mem.indexOf(u8, out.written(), "fog.mtl.bin") == null); + try std.testing.expectError(error.InvalidParameterDefaults, parse(a, "{\"version\":1,\"fragment\":\"f.sc\",\"targets\":[\"spv\"],\"parameters\":[{\"name\":\"u_x\",\"kind\":\"vec4\",\"defaults\":[1]}]}")); +} diff --git a/src/root.zig b/src/root.zig index ec02bfa8..505782f7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -5,6 +5,8 @@ const builtin = @import("builtin"); // ── Submodules ───────────────────────────────────────────────────────── const config = @import("config.zig"); +pub const material_pipeline = @import("material_pipeline.zig"); +pub const component_collisions = @import("component_collisions.zig"); const cache = @import("cache.zig"); const backend_registry = @import("backend_registry.zig"); pub const scanner = @import("scanner.zig"); @@ -1283,6 +1285,15 @@ pub fn generate( var flow_result = try flow_scanner.scanAndEmit(allocator, game_dir, target_dir, plugin_flow_decls.flow_nodes); defer flow_result.deinit(); + const material_names = try material_pipeline.stage(allocator, game_dir, target_dir, cfg); + defer scanner.freeNames(allocator, material_names); + if (material_names.len != 0) for (cfg.plugins) |plugin| { + if (std.mem.eql(u8, plugin.name, "materials")) { + std.log.err("plugin 'materials' collides with the generated game-owned materials module", .{}); + return error.MaterialModuleCollision; + } + }; + // Generate build.zig.zon // `cfg_modules` (not `cfg`): a light pack has no `build.zig`/module, so it // must not become a `.labelle_ = .{ .path }` dep (#481). @@ -1290,6 +1301,7 @@ pub fn generate( // The tests target runs second — additive merge so the exe // target's deps (chosen-backend, plugins) survive. Issue #83. .recreate_deps = !is_tests_target, + .materials = material_names.len != 0, // manifest-v2 cutover: when the backend ships a v2 manifest, key the // backend dep entry off its `dep_name` + drive its `root_build_deps` // (design §3). Null → v1/enum, byte-unchanged. @@ -1542,6 +1554,7 @@ pub fn generate( break :blk names; }; defer allocator.free(declared_component_names); + try component_collisions.check(allocator, component_names, pack_scans.items, if (maybe_scripting) |s| s.declared_components else &.{}); const tilemap_registrations = try tilemap_phase.collectRegistrations(allocator, target_dir, scene_manifests, component_names, declared_component_names, prefab_names, pack_scans.items, cfg.engine_version); defer tilemap_scan.freeRegistrations(allocator, tilemap_registrations); @@ -1982,6 +1995,7 @@ pub fn generate( // instead — a third wiring category driven by `pack_scans`, never by // `cfg.plugins`. const build_zig = try build_files.generateBuildZig(allocator, cfg_modules, .{ + .materials = material_names, .is_tests_target = is_tests_target, .constants = has_constants, .i18n = has_i18n, diff --git a/test/build_zig_tests.zig b/test/build_zig_tests.zig index 56082e28..6806a0c4 100644 --- a/test/build_zig_tests.zig +++ b/test/build_zig_tests.zig @@ -31,6 +31,18 @@ test { zspec.runAll(@This()); } +test "materials are one shared core module in desktop game and test imports" { + const out = try h.genBgfxV2BuildZig(std.testing.allocator, .{ .name = "materials-game", .ecs = .mock }, .{ .materials = &.{ "water", "fog", "lamp" } }); + defer std.testing.allocator.free(out); + for ([_][]const u8{ + "material_build.zig", + "materials/fog/material.json", + "overrideImport(game_mod, \"materials\", materials_mod)", + "exe.root_module.addImport(\"materials\", materials_mod)", + "test_root.root_module.addImport(\"materials\", materials_mod)", + }) |expected| try std.testing.expect(std.mem.indexOf(u8, out, expected) != null); +} + // ── manifest-v2 desktop byte anchor (epic #453 item 3, PR 3, design §7) ── // The critical proof-of-concept: the v2 desktop codegen path // (`backend.manifest.v2.zon` → `manifest_v2_splice`) must generate a build.zig diff --git a/test/material_fixture/build.zig b/test/material_fixture/build.zig new file mode 100644 index 00000000..5495a8f5 --- /dev/null +++ b/test/material_fixture/build.zig @@ -0,0 +1,9 @@ +const std = @import("std"); +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const core = b.dependency("core", .{ .target = target, .optimize = optimize }).module("labelle-core"); + const materials = @import("material_build.zig").create(b, target, optimize, core, &.{ .{ .name = "fog", .json = @embedFile("materials/fog/material.json") }, .{ .name = "water", .json = @embedFile("materials/water/material.json") }, .{ .name = "lamp", .json = @embedFile("materials/lamp/material.json") } }, "desktop"); + const t = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("test.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "materials", .module = materials }, .{ .name = "labelle-core", .module = core } } }) }); + b.default_step.dependOn(&b.addRunArtifact(t).step); +} diff --git a/test/material_fixture/test.zig b/test/material_fixture/test.zig new file mode 100644 index 00000000..27db2085 --- /dev/null +++ b/test/material_fixture/test.zig @@ -0,0 +1,13 @@ +const std = @import("std"); +const m = @import("materials"); +const core = @import("labelle-core"); +test "compiled descriptors use shared core and caller owned binding storage" { + var textures = m.fog.textures; + textures[0].texture = @enumFromInt(1); + const d = m.fog.descriptor(&textures); + try core.shader_material.validateDescriptor(d); + try std.testing.expect(d.textures.ptr == &textures); + try std.testing.expect(d.shaders.spv.len > 0 and d.shaders.glsl.len > 0 and d.shaders.essl.len > 0 and d.shaders.mtl.len > 0); + try std.testing.expectEqual(@as(usize, 0), d.shaders.dx11.len); + try std.testing.expect(m.water.shaders.spv.len > 0 and m.lamp.shaders.spv.len > 0); +} diff --git a/test/material_pipeline_e2e.py b/test/material_pipeline_e2e.py new file mode 100644 index 00000000..561be6e2 --- /dev/null +++ b/test/material_pipeline_e2e.py @@ -0,0 +1,91 @@ +"""Real shaderc + generated Zig fixture; writes only under this checkout's cache.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zlib + +p = argparse.ArgumentParser() +for option in ("zig", "shaderc", "core", "zbgfx"): + p.add_argument("--" + option, required=True) +args = p.parse_args() +repo = Path(__file__).resolve().parents[1] +cache = repo / ".zig-cache" +cache.mkdir(exist_ok=True) +root = Path(tempfile.mkdtemp(prefix="material-e2e-", dir=cache)) +for name in ("material_build.zig", "material_schema.zig"): + shutil.copy2(repo / "src" / name, root / name) +for name in ("build.zig", "test.zig"): + shutil.copy2(repo / "test/material_fixture" / name, root / name) +# Compile the exact guard emitted into game main.zig, not a rewritten facsimile. +guard_text = (repo / "src/component_collisions.zig").read_text(encoding="utf-8").split("pub const plugin_guard =", 1)[1].split("\n;\n", 1)[0] +guard = "\n".join(line.lstrip()[2:] for line in guard_text.splitlines() if line.lstrip().startswith("\\\\")) +(root / "guard.zig").write_text('const std = @import("std");\n' + guard.replace("fn rejectComponentCollisions", "pub fn rejectComponentCollisions"), encoding="utf-8") +test_source = (root / "test.zig").read_text(encoding="utf-8") +test_source += '\nconst guard = @import("guard.zig");\nconst Plugin = struct { pub const Components = struct { pub const Other = struct {}; }; };\ncomptime { guard.rejectComponentCollisions(&.{"Water"}, .{Plugin}); }\n' +(root / "test.zig").write_text(test_source, encoding="utf-8") +relative = lambda path: os.path.relpath(Path(path).resolve(), root).replace("\\", "/") +fingerprint = (zlib.crc32(b"material_fixture") << 32) | 12345 +(root / "build.zig.zon").write_text( + '.{ .name = .material_fixture, .version = "0.0.0", .fingerprint = 0x%x, ' + '.minimum_zig_version = "0.16.0", .dependencies = .{ ' + '.material_shaderc = .{ .path = "%s" }, .core = .{ .path = "%s" } }, .paths = .{""}, }' + % (fingerprint, relative(args.zbgfx), relative(args.core)), encoding="utf-8") +descriptor = {"version": 1, "fragment": "fs.sc", "targets": ["spv", "glsl", "essl", "mtl"], + "parameters": [{"name": "u_tint", "kind": "vec4", "defaults": [1, 1, 1, 1]}], + "textures": [{"name": "s_mask", "sampler": "linear"}]} +shader = ('$input v_color0, v_texcoord0\n#include \n#include "color.sh"\n' + '#include \n' + 'SAMPLER2D(s_tex, 0);\nuniform vec4 u_tint;\n' + 'void main() { gl_FragColor = texture2D(s_tex, v_texcoord0) * v_color0 * u_tint * TINT * SHARED; }\n') +(root / "materials/shared").mkdir(parents=True) +(root / "materials/shared/footprint.sc").write_text("#define SHARED vec4(1.0)\n", encoding="utf-8") +for name in ("water", "fog", "lamp"): + folder = root / "materials" / name + folder.mkdir(parents=True) + (folder / "material.json").write_text(json.dumps(descriptor), encoding="utf-8") + (folder / "fs.sc").write_text(shader, encoding="utf-8") + (folder / "color.sh").write_text("#define TINT vec4(1.0)\n", encoding="utf-8") + +def build(ok=True, diagnostic=None): + result = subprocess.run([args.zig, "build", "-Dshaderc=" + str(Path(args.shaderc).resolve()), + "--summary", "all"], cwd=root, text=True, capture_output=True) + output = result.stdout + result.stderr + if (result.returncode == 0) != ok or (diagnostic and diagnostic not in output): + raise AssertionError(output) + return output + +initial = build() +assert "tests passed" in initial or "1 pass" in initial, initial +cached = build() +for variant in descriptor["targets"]: + assert f"shader fog ({variant}) (fog.{variant}.bin) cached" in cached, cached +(root / "materials/fog/color.sh").write_text("#define TINT vec4(0.5)\n", encoding="utf-8") +changed = build() +for variant in descriptor["targets"]: + assert f"shader fog ({variant}) (fog.{variant}.bin) success" in changed, changed + assert f"shader water ({variant}) (water.{variant}.bin) success" in changed, changed +(root / "materials/shared/footprint.sc").write_text("#define SHARED vec4(0.75)\n", encoding="utf-8") +shared_changed = build() +for variant in descriptor["targets"]: + assert f"shader fog ({variant}) (fog.{variant}.bin) success" in shared_changed, shared_changed + assert f"shader lamp ({variant}) (lamp.{variant}.bin) success" in shared_changed, shared_changed +(root / "test.zig").write_text(test_source + '\ncomptime { guard.rejectComponentCollisions(&.{"Other"}, .{Plugin}); }\n', encoding="utf-8") +build(False, "duplicate component registration 'Other'") +(root / "test.zig").write_text(test_source + '\ncomptime { guard.rejectComponentCollisions(&.{}, .{Plugin, Plugin}); }\n', encoding="utf-8") +build(False, "duplicate component registration 'Other'") +(root / "test.zig").write_text(test_source, encoding="utf-8") +(root / "materials/fog/color.sh").write_text('#include "../outside.sc"\n', encoding="utf-8") +build(False, "EscapingShaderInclude") +(root / "materials/fog/color.sh").write_text("#define TINT vec4(1.0)\n", encoding="utf-8") +(root / "materials/fog/fs.sc").write_text(shader + "\ninvalid shader syntax!\n", encoding="utf-8") +build(False, "shader fog") +(root / "materials/fog/fs.sc").write_text(shader, encoding="utf-8") +descriptor["targets"] = ["glsl"] +(root / "materials/fog/material.json").write_text(json.dumps(descriptor), encoding="utf-8") +build(False, "target requires 'spv'") +print("PASS: 12 real shader variants, generated core descriptors, cached rebuild, include invalidation, escaping include rejection, compiler failure, missing target diagnostic") +print("Evidence fixture:", root)