diff --git a/SHADER-MATERIALS.md b/SHADER-MATERIALS.md new file mode 100644 index 00000000..485933b0 --- /dev/null +++ b/SHADER-MATERIALS.md @@ -0,0 +1,37 @@ +# Game-owned shader materials + +Game components such as WaterShader, FogShader, and LampShader own effect state and immutable shader definitions. The engine provides no effect-specific component or simulation. + +```zig +try game.createShaderMaterial(entity, .{ + .label = "water", + .shaders = materials.water.shaders, + .parameters = &materials.water.parameters, + .textures = &.{.{ + .name = "s_water_mask", + .texture = .{ .catalog = water.mask }, + .sampler = .point, + }}, +}); +try game.setShaderParameter(entity, "u_time", &.{time}); +try game.setShaderTexture(entity, "s_water_mask", "replacement_mask"); +game.clearShaderMaterial(entity); +``` + +`ShaderMaterialDescriptor` mirrors `core.shader_material.Descriptor`, except `textures` contains `ShaderTextureBinding` values. Each texture is `ShaderTexture{ .catalog = key }` or `{ .id = TextureId }`. The setter also accepts a catalog string or a typed `TextureId` directly. No backend import or backend texture id is needed. Parameters and shader fragments use core's shared types. Fragment variants pair with the backend sprite vertex shader; the backend supplies the reserved `u_material_rect` atlas uniform. + +Creation requires a live sprite and returns `!void`; `shaderMaterial(entity)` returns the current optional runtime id. Creation/replacement commits atomically after validation, texture resolution, and backend creation succeed. Descriptor slices are borrowed for the call only. Backends own their retained shader data. Game components retain authored definitions for recreation, never serialized runtime handles. + +Catalog keys must be registered images. A cold registered image is acquired automatically; `TextureNotReady` asks the caller to retry on a later frame. One pending pin is retained per entity/binding across retries, even when scene asset inference did not request the image. Successful creation transfers ownership to committed pins and sweeps the entity's pending requests. Superseding a pending binding releases its old request only once the replacement request is secured, without canceling other bindings. A failed create or texture update — `TextureNotReady` included — drops nothing the entity already held: the previous material and every pending request survive until a later attempt commits, the binding is superseded, or the material is cleared. Clear, entity destruction, scene reset, world destruction, and shutdown release all pending and committed pins. `AssetLoadFailed`, `AssetDecodeNotQueued`, `AssetNotRegistered`, and invalid descriptors are actionable failures, not indefinite readiness waits. Unsupported renderers return `Unsupported`. + +A direct `TextureId` is borrowed; its original owner remains responsible for its lifetime. Gfx resolves registry ids to backend ids. Unloading, invalidating, or replacing a bound texture invalidates dependent materials before the backend texture slot is recycled. Subsequent setters return `InvalidHandle`; the game facade clears the stale entity binding so the component can recreate it. An invalidated material does not submit its old shader; the sprite uses the plain draw path. + +Named shelved worlds preserve their own materials and pins. Scene reset and actual entity deletion destroy them. `surfaceLost` invalidates catalog GPU resources first, then FORGETS every world's runtime materials through the renderer's `invalidateShaderMaterials` (labelle-gfx#361) — no backend destroy ever runs against the lost context, and a texture-less material is retired here rather than left to per-texture invalidation. Pins are released, bound sprites fall back to the plain draw path, `shaderMaterial(entity)` becomes null, and the engine logs a warning with the count so the fallback is never silent. Once the surface is restored, game systems recreate from their own definitions; cold catalog resources are requested again automatically. Calls while the GPU is unavailable return `GpuSurfaceUnavailable`. + +JSONC and `.zon` authored sprite materials cannot install a live shader id. Effects are authored as game-owned components and create materials only at runtime. Explicit `clearMaterial` and `removeSprite` release owned shader resources; the engine tick also reaps raw ECS removals. + +Validation: + +- `zig build test-shader-material`: executed shader API/lifecycle tests with recording renderers and the real asynchronous asset catalog. +- `zig build test-shader-regressions`: shader tests plus scene lifecycle, prefab refresh, deserialization, and existing material tests. +- Gfx: `zig build test` includes typed texture resolution, draw dispatch, unsupported backends, stale ids, and texture replacement invalidation. diff --git a/SHADER-VALIDATION.md b/SHADER-VALIDATION.md new file mode 100644 index 00000000..03197249 --- /dev/null +++ b/SHADER-VALIDATION.md @@ -0,0 +1,99 @@ +# Shader migration: engine validation evidence + +Environment: Windows, Zig 0.16.0. Commands: `zig build test -j4 --summary all`, plus direct `zig test` invocations for the baseline preview binaries. + +Baseline snapshots: engine `6b5a448` and core `b734e0c`, extracted without edits. The current engine uses the completed generic-shader core. Both broad comparison runs were stopped after collecting the relevant failure evidence; they are **not completed suite runs**. + +The earlier completed current full run reported 237/258 build steps succeeded, 1246/1293 tests passed, 47 runtime failures, and 12 failed steps. Its raw log was removed during cleanup. The retained partial rerun and baseline logs below provide narrower, explicit evidence; no claim is made that every broad-suite issue has been baseline-reproduced. + +Passing targeted evidence: engine `test-shader-regressions` 78/78 (including 13 shader tests); gfx `test` 181/181. Independent integration validation passes 20 example tests, 16 shader compilations, and 16 runtime scenarios, including the subsequently added game-owned mist effect. + +## Runtime failures: baseline reproduced + +All 47 preview-family runtime failures have now been reproduced on the original baseline. Each fails during loopback harness initialization with `GetSockNameFailed`, before the behavior under test. Baseline preview-mode: 10 pass / 29 fail; frame-stream: 0 pass / 10 fail; handshake: 0 pass / 7 fail; flows API: 6 pass / 1 fail. The direct baseline commands for the last three groups completed before the broader comparison was stopped. + +### test/preview_mode_test.zig (29 failures) + +- Preview: hello round-trip over loopback TCP — `GetSockNameFailed`. +- Preview: heartbeats arrive at the listener in order — `GetSockNameFailed`. +- Preview: tickHeartbeat respects the rate limit — `GetSockNameFailed`. +- Preview: full lifecycle — hello, heartbeats, bye, EOF — `GetSockNameFailed`. +- emitEntityCreated: writes magic+kind+length+payload with optional prefab name — `GetSockNameFailed`. +- emitEntityDestroyed: writes magic+kind+length+entity_id — `GetSockNameFailed`. +- emitComponentChanged: only emits when component is subscribed — `GetSockNameFailed`. +- pollSubscription: decodes subscribe and unsubscribe from editor — `GetSockNameFailed`. +- Preview: JSON heartbeats and binary frames multiplex on one socket — `GetSockNameFailed`. +- pollSubscription: malformed JSON surfaces MalformedSubscription — `GetSockNameFailed`. +- Game lifecycle: createEntity + addComponent emit telemetry; destroy + filter respected (#520) — `GetSockNameFailed`. +- emitNodeEntered: emits one binary frame when flow is subscribed — `GetSockNameFailed`. +- emitNodeEntered: no-op when flow is not subscribed — `GetSockNameFailed`. +- emitNodeEntered: stops firing after unsubscribe_flow — `GetSockNameFailed`. +- emitNodeEntered: exact wire-format guard against drift — `GetSockNameFailed`. +- pollSubscription: subscribe_flow / unsubscribe_flow update subscribed_flows — `GetSockNameFailed`. +- emitPinValue: no-op when flow is not pin-subscribed — `GetSockNameFailed`. +- emitPinValue: subscribed flow emits a frame with the expected layout — `GetSockNameFailed`. +- emitPinValue: unsubscribe_pin_values stops emission — `GetSockNameFailed`. +- emitPinValue: subscribing to flow A doesn't enable emission for flow B — `GetSockNameFailed`. +- emitPinValue: pin_name with non-ASCII and quotes round-trips through the wire — `GetSockNameFailed`. +- emitPinValue: f64 value preserves precision through encode/decode — `GetSockNameFailed`. +- emitPinValue: exact wire-format guard against drift — `GetSockNameFailed`. +- pollSubscription: subscribe_pin_values / unsubscribe_pin_values update subscribed_pin_flows — `GetSockNameFailed`. +- watch_entity: adds id to watched_entities and filters subsequent emits — `GetSockNameFailed`. +- unwatch_entity: empty set restores Phase 2 'watch everything' behaviour — `GetSockNameFailed`. +- emitEntitySnapshot: writes one component_changed frame per (entity, component) — `GetSockNameFailed`. +- emitEntitySnapshot: skips components the editor did not subscribe to — `GetSockNameFailed`. +- emitEntitySnapshot: bypasses watched_entities filter for unwatched ids — `GetSockNameFailed`. + +### test/preview_frame_stream_test.zig (10 failures) + +- beginFrameStream emits frame_offer + leaves producer in .offered — `GetSockNameFailed`. +- publishFrame writes pixels into the SHM ring; consumer reads them back — `GetSockNameFailed`. +- publishFrame returns StreamNotActive when state is .offered (editor hasn't accepted) — `GetSockNameFailed`. +- publishFrame returns StreamNotActive when called without beginFrameStream — `GetSockNameFailed`. +- beginFrameStream resets frame_state even when re-offer is in progress — `GetSockNameFailed`. +- publishFrame returns InvalidFrameSize when buffer size mismatches dims — `GetSockNameFailed`. +- publishFrame increments frame_idx monotonically across multiple publishes — `GetSockNameFailed`. +- beginFrameStream after frame_resize re-offers at new dims, old ring is torn down — `GetSockNameFailed`. +- endFrameStream tears down ring, subsequent publishFrame is StreamNotActive — `GetSockNameFailed`. +- endFrameStreamIOSurface is a no-op when SHM mode is active — `GetSockNameFailed`. + +### test/preview_handshake_test.zig (7 failures) + +- sendFrameOffer serializes expected JSON and flips state to offered — `GetSockNameFailed`. +- sendFramePublished carries frame_idx and produce_ns — `GetSockNameFailed`. +- frame_accept transitions offered → accepted; isFrameAccepted reports it — `GetSockNameFailed`. +- frame_accept from not_offered is ignored (no spurious transition) — `GetSockNameFailed`. +- frame_resize sets pending_resize, takeResize pops it once and resets state — `GetSockNameFailed`. +- frame_resize with missing dim fields surfaces MalformedSubscription — `GetSockNameFailed`. +- unknown control frame is still rejected (regression guard) — `GetSockNameFailed`. + +### test/flows_game_api_test.zig (1 failures) + +- preview: codegen pattern `if (game.preview) |*_p| _p.emitNodeEntered(...)` resolves — `GetSockNameFailed`. + +## Compilation failures + +| Location | Error | Baseline evidence | +|---|---|---| +| `test/font_loader_test.zig:143:57` | `expected type 'void', found 'u64'` | Reproduced in baseline broad run; also observed in current rerun. | +| `test/asset_catalog_test.zig:128:57 and :186:57` | `expected type 'void', found 'u64'` | Reproduced in baseline broad run; also reported by the earlier current full run. | +| `test/atlas_surface_crosswire_test.zig:239:41` | `expected integer or vector, found 'void'` | Reproduced in baseline broad run. Current comparison did not reach this binary before interruption. | +| `test/preview_capture_test.zig → std/Io/Writer.zig:1803:5` | `invalid format string 'd' for type '*anyopaque'` | Reproduced in baseline broad run; also reported by the earlier current full run. | +| `test/jsonc/image_component_test.zig:183:58` | `expected type 'void', found 'u64'` | Observed in current rerun; baseline execution not established. | +| `test/root_test.zig:589:57` | `expected type 'void', found 'u64'` | Observed in current rerun; baseline execution not established. | +| `test/load_deadline.zig:81:20` | `expected integer or vector, found 'void'` | Observed twice in current rerun; baseline execution not established. | + +The `timespec` errors arise in POSIX sleep helpers whose time fields resolve to `void` on this Windows target. For the three current-only locations, the test source is byte-identical to engine `6b5a448`; that is source evidence, **not proof from a baseline execution**. They are therefore classified as existing-source/platform failures with baseline runtime verification incomplete, not demonstrated shader regressions. + +### Current-only compile roots involving load_deadline + +- `test/asset_streaming_shim_test.zig` +- `test/image_load_shim_test.zig` + +## Transient compiler failure + +The current broad rerun once failed loading Zig's `std/crypto/codecs/asn1/der.zig` with `Unexpected`, while compiling `test/set_sprite_flip_test.zig`. A direct rerun of that current binary passed all 4 tests. This error did not reproduce; it is not classified as an introduced source failure. + +## Limits and process cleanup + +No introduced failure was established by the evidence gathered. The broad suite is not certified green. All current/baseline suite process trees were stopped; a process inventory found no `zig.exe`, `build.exe`, or `test.exe` remaining. Demo PID 32672 was preserved. Raw logs were moved outside the repository into the local temporary evidence directory; no scratch scripts or logs are included in the commit. diff --git a/build.zig b/build.zig index 81a6fc1e..0774f9d8 100644 --- a/build.zig +++ b/build.zig @@ -87,11 +87,7 @@ pub fn build(b: *std.Build) void { // Test files in test/ directory const test_files = [_][]const u8{ "test/root_test.zig", - // COND-07 / labelle-bgfx#100 — the `PixelWater` authoring component, - // its game-facing helpers, the bounded-strength rule at BOTH - // boundaries, and the stage-before-commit ordering. Mock-backed and - // deterministic (simulation time only, never a wall clock). - "test/pixel_water_test.zig", + "test/shader_material_test.zig", // #855 — typed hook context: `engine.HookContext` injection by // field type, `ctx.game()` / `ctx.gameAs(G)`, the bound-Game // identity check, and a regression proving the legacy @@ -469,6 +465,7 @@ pub fn build(b: *std.Build) void { const legacy_animation_step = b.step("test-animation-legacy", "Run all animation package and engine adapter tests"); legacy_animation_step.dependOn(&run_animation_tests.step); + const shader_regressions = b.step("test-shader-regressions", "Execute shader, scene, prefab, and material regressions"); for (test_files) |test_file| { const t = b.addTest(.{ .root_module = b.createModule(.{ @@ -485,6 +482,10 @@ pub fn build(b: *std.Build) void { }); const run = b.addRunArtifact(t); test_step.dependOn(&run.step); + for ([_][]const u8{ "test/shader_material_test.zig", "test/scene_test.zig", "test/scene_lifecycle_events_test.zig", "test/scene_teardown_test.zig", "test/prefab_refresh_test.zig", "test/jsonc/deserializer_test.zig", "test/set_material_test.zig" }) |related| { + if (std.mem.eql(u8, test_file, related)) shader_regressions.dependOn(&run.step); + } + if (std.mem.eql(u8, test_file, "test/shader_material_test.zig")) b.step("test-shader-material", "Execute shader ownership and lifecycle tests").dependOn(&run.step); if (std.mem.startsWith(u8, test_file, "animation/test/")) legacy_animation_step.dependOn(&run.step); } diff --git a/build.zig.zon b/build.zig.zon index 68ab9acf..ffb97c11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -76,8 +76,8 @@ // enforced at the consuming game's build; this pin only feeds the // tilemap sub-package test. .labelle_gfx = .{ - .url = "https://github.com/labelle-toolkit/labelle-gfx/archive/refs/tags/v1.36.0.tar.gz", - .hash = "labelle_gfx-1.36.0-nMpCPGPLDwCNBg59oCHDWvtTKeOgZiIQfU4wc1FCWVau", + .url = "https://github.com/labelle-toolkit/labelle-gfx/archive/refs/tags/v2.0.0.tar.gz", + .hash = "labelle_gfx-2.0.0-nMpCPELoDgBTsyFFwWitkzyGOIYWAv0eTfPhqq-87FJB", .lazy = true, }, }, diff --git a/scene/src/entity_writer.zig b/scene/src/entity_writer.zig index c0eeba2d..d4971fe3 100644 --- a/scene/src/entity_writer.zig +++ b/scene/src/entity_writer.zig @@ -34,19 +34,6 @@ pub fn EntityWriter( const EcsImpl = GameType.EcsBackend; const Sprite = GameType.SpriteComp; - // `PixelWater` (COND-07, labelle-bgfx#100) is an engine BUILT-IN, not a - // `ComponentRegistry` entry, so — like `Sprite` / `Shape` — the comptime - // `.zon` writer needs its own branch or a `.zon`-authored reservoir would - // be silently dropped. It routes through `game.addPixelWater` rather than - // `addComponent` for the same reason the JSONC branch does: that helper is - // the validated, stage-before-commit path, so BOTH authoring formats land - // on one synchronization operation instead of two that can drift. - // - // A project-registered `PixelWater` still wins (the branch compiles out, - // routing the name to the registry dispatch below), and the whole thing - // folds away on a duck-typed GameType without the helper. - const has_builtin_pixel_water = @hasDecl(GameType, "addPixelWater") and - @hasDecl(GameType, "PixelWaterComp") and !Components.has("PixelWater"); const Shape = GameType.ShapeComp; const RefCtx = ReferenceContext(Entity); @@ -55,6 +42,14 @@ pub fn EntityWriter( // Component addition with entity reference support // ===================================================================== + fn addAuthoredSprite(game: *GameType, entity: Entity, sprite: Sprite) void { + var authored = sprite; + if (comptime @hasField(Sprite, "material")) { + if (comptime @FieldType(Sprite, "material") == labelle_core.Material) authored.material.shader = .none; + } + game.addSprite(entity, authored); + } + /// Add components from a comptime component tuple to an entity. /// Handles Sprite/Shape visuals, custom components from the ComponentRegistry, /// and entity references (deferred to Phase 2 via ref_ctx). @@ -67,15 +62,11 @@ pub fn EntityWriter( const value = @field(comps, field.name); if (comptime std.mem.eql(u8, field.name, "Sprite")) { - game.addSprite(entity, coerce(Sprite, value)); + addAuthoredSprite(game, entity, coerce(Sprite, value)); vtype = .sprite; } else if (comptime std.mem.eql(u8, field.name, "Shape")) { game.addShape(entity, coerce(Shape, value)); vtype = .shape; - } else if (comptime has_builtin_pixel_water and - std.mem.eql(u8, field.name, "PixelWater")) - { - _ = game.addPixelWater(entity, coerce(GameType.PixelWaterComp, value)); } else if (comptime Components.has(field.name)) { const T = Components.getType(field.name); addCustomComponent(T, field.name, entity, game, value, ref_ctx); @@ -106,21 +97,12 @@ pub fn EntityWriter( if (comptime std.mem.eql(u8, field.name, "Sprite")) { const val = comptime if (has_override) merge(Sprite, prefab_val, scene_comps.Sprite) else coerce(Sprite, prefab_val); - game.addSprite(entity, val); + addAuthoredSprite(game, entity, val); vtype = .sprite; } else if (comptime std.mem.eql(u8, field.name, "Shape")) { const val = comptime if (has_override) merge(Shape, prefab_val, scene_comps.Shape) else coerce(Shape, prefab_val); game.addShape(entity, val); vtype = .shape; - } else if (comptime has_builtin_pixel_water and - std.mem.eql(u8, field.name, "PixelWater")) - { - const PW = GameType.PixelWaterComp; - const val = comptime if (has_override) - merge(PW, prefab_val, @field(scene_comps, field.name)) - else - coerce(PW, prefab_val); - _ = game.addPixelWater(entity, val); } else if (comptime Components.has(field.name)) { const T = Components.getType(field.name); if (has_override) { @@ -140,15 +122,11 @@ pub fn EntityWriter( const value = @field(scene_comps, field.name); if (comptime std.mem.eql(u8, field.name, "Sprite")) { - game.addSprite(entity, coerce(Sprite, value)); + addAuthoredSprite(game, entity, coerce(Sprite, value)); vtype = .sprite; } else if (comptime std.mem.eql(u8, field.name, "Shape")) { game.addShape(entity, coerce(Shape, value)); vtype = .shape; - } else if (comptime has_builtin_pixel_water and - std.mem.eql(u8, field.name, "PixelWater")) - { - _ = game.addPixelWater(entity, coerce(GameType.PixelWaterComp, value)); } else if (comptime Components.has(field.name)) { const T = Components.getType(field.name); addCustomComponent(T, field.name, entity, game, value, ref_ctx); @@ -368,6 +346,7 @@ pub fn EntityWriter( /// Deep-coerce a .zon anonymous value into target type T. /// Handles structs (field-by-field), tagged unions (by active field), and enums. pub fn coerce(comptime T: type, comptime zon_val: anytype) T { + if (T == labelle_core.shader_material.Id) return .none; const Src = @TypeOf(zon_val); const src_info = @typeInfo(Src); const dst_info = @typeInfo(T); diff --git a/src/game.zig b/src/game.zig index 00ab47c7..959f5572 100644 --- a/src/game.zig +++ b/src/game.zig @@ -80,8 +80,6 @@ const image_component_mod = @import("image_component.zig"); const camera_mixin = @import("game/camera_mixin.zig"); const emitter_mod = @import("emitter.zig"); const emitter_mixin = @import("game/emitter_mixin.zig"); -const pixel_water_mod = @import("pixel_water.zig"); -const pixel_water_mixin = @import("game/pixel_water_mixin.zig"); const particles_mod = @import("particles.zig"); const frame_profiler_mod = @import("frame_profiler.zig"); @@ -244,8 +242,7 @@ pub fn GameConfigWithYAxis( pub const HooksIsMergedExport = HooksIsMerged; pub const EcsBackend = EcsImpl; /// The renderer plugin type itself. Mixins that must reflect on the - /// renderer's optional seams (`game/pixel_water_mixin.zig` reads its - /// `WaterConfig` / `WaterInstanceId`) need the TYPE, and `renderer` is + /// renderer's optional seams need the TYPE, and `renderer` is /// a `*RenderImpl` field rather than a decl. pub const RendererType = RenderImpl; pub const SpriteComp = Sprite; @@ -358,18 +355,6 @@ pub fn GameConfigWithYAxis( pub const emitter_is_builtin = !(@hasDecl(ComponentsType, "has") and ComponentsType.has("Emitter")); - // ── Pixel water (COND-07, labelle-bgfx#100) ────────────────── - /// Engine built-in `PixelWater` component (authors a reservoir for - /// the built-in `pixel_water` material; see `src/pixel_water.zig`). - /// Handled by dedicated built-in channels (scene loader, side-table - /// runtime) — NOT a `ComponentRegistry` component, unless a project - /// registers its own `PixelWater`, which takes precedence. - pub const PixelWaterComp = pixel_water_mod.PixelWater; - /// True when the project did NOT register its own `PixelWater`. - /// Mirrors `emitter_is_builtin`. - pub const pixel_water_is_builtin = - !(@hasDecl(ComponentsType, "has") and ComponentsType.has("PixelWater")); - pub const Input = @import("input.zig").InputInterface(InputImpl); /// True when the active input backend itself declares @@ -448,7 +433,7 @@ pub fn GameConfigWithYAxis( const TilemapMixin = tilemap_mixin.Mixin(Self); const CameraMixin = camera_mixin.Mixin(Self); const EmitterMixin = emitter_mixin.Mixin(Self); - const PixelWaterMixin = pixel_water_mixin.Mixin(Self); + const ShaderMaterialMixin = @import("game/shader_material_mixin.zig").Mixin(Self); // VideoImpl/AudioImpl are `GameConfig` fn params (not `Self` // decls), so the construction mixin takes them explicitly. const InitMixin = game_init_mod.Mixin(Self, VideoImpl, AudioImpl); @@ -530,28 +515,8 @@ pub fn GameConfigWithYAxis( /// Living here, the retention travels with the renderer it /// belongs to and is torn down with the world. direct_textures: atlas_mixin.DirectTextureStore = .empty, - /// Entity → the catalog references that world's reservoirs hold - /// (COND-07), plus each one's late-reflection resolution state. - /// Separate from `water_instances` because the two have different - /// lifetimes: the references are taken when the component is - /// AUTHORED, the instance only once the mask is resident — and a - /// reservoir whose mask never arrives still owes the catalog a - /// `release`. - /// - /// PER-WORLD, for the same reason as `direct_textures` above and - /// unlike `water_instances`. The instance table is EMPTIED on a - /// world swap, so an `Entity` key can never outlive the ECS that - /// minted it; the asset table deliberately SURVIVES one (the - /// shelved world's components live on and still own their - /// references). Keyed game-globally on `Entity` alone that is - /// unsound twice over: independent world ECS instances hand out - /// the same ids, so world B's reservoir would be mistaken for - /// world A's and never acquire at all — and `reapGhostPixelWater` - /// checks every record against the ACTIVE ECS, so a shelved - /// world's records look like orphans and are released out from - /// under live components on the next tick. Living here, the - /// records travel with the ECS whose ids key them. - water_assets: std.AutoHashMap(Entity, pixel_water_mixin.WaterAssets), + shader_pending: std.AutoHashMapUnmanaged(Entity, std.ArrayList(@import("shader_material.zig").Pending)) = .{}, + shader_materials: std.AutoHashMapUnmanaged(Entity, @import("shader_material.zig").Record) = .{}, /// Retained so `deinit` can free heap-owning components (the /// `ChildrenComponent` ArrayLists) before the ECS is torn down — /// the backend drops components by value with no destructor. @@ -563,32 +528,18 @@ pub fn GameConfigWithYAxis( .renderer = RenderImpl.init(allocator), .sprite_cache = atlas_mod.SpriteCache.init(allocator), .nested_entity_arena = std.heap.ArenaAllocator.init(allocator), - .water_assets = std.AutoHashMap(Entity, pixel_water_mixin.WaterAssets).init(allocator), .allocator = allocator, }; } pub fn deinit(self: *World) void { + std.debug.assert(self.shader_pending.count() == 0); + self.shader_pending.deinit(self.allocator); + std.debug.assert(self.shader_materials.count() == 0); + self.shader_materials.deinit(self.allocator); // Retained direct uploads (#820) — the CPU copies; their // GPU side goes with `self.renderer.deinit()` below. AtlasMixin.freeWorldDirectTextures(self); - // Water asset records (COND-07). The OWNED name copies are - // freed here; the catalog `release` is NOT owed from this - // path. `Game.deinit` tears the catalog down before it - // reaches any world teardown, so a release here would read a - // freed hash map (this ordering already cost one panic). The - // mid-game teardowns — `destroyWorld` and the unnamed-world - // destroy in `setActiveWorld` — run - // `releaseWorldWaterAssets` first, while the catalog is - // still alive, and leave this loop nothing to do. - { - var wa_it = self.water_assets.valueIterator(); - while (wa_it.next()) |rec| { - if (rec.mask.len != 0) self.allocator.free(rec.mask); - if (rec.reflection.len != 0) self.allocator.free(rec.reflection); - } - self.water_assets.deinit(); - } // Free every `ChildrenComponent`'s backing allocation before // the ECS wipe drops the components by value (no destructor) // — otherwise the child lists leak on final teardown. @@ -750,12 +701,6 @@ pub fn GameConfigWithYAxis( /// this is always present. `resetEcsBackend` clears it via /// `clearParticleSystems` (entity ids die with the ECS reset). particle_systems: std.AutoHashMap(Entity, *particles_mod.ParticleSystem), - /// Entity → gfx water-instance id (COND-07). The LIFETIME record for - /// reservoirs: `Sprite.water` is the draw binding, but a destroyed - /// entity's sprite is exactly what is gone when the release is owed, - /// so the id is tracked here too (mirrors `particle_systems`). - /// Emptied by `clearPixelWaterInstances` on an ECS reset. - water_instances: std.AutoHashMap(Entity, pixel_water_mixin.WaterInstanceIdOf(RenderImpl)), /// Runtime scene-source overrides (labelle-studio Play mode / /// `editor_api`). Keyed by scene NAME (e.g. `"main"`); the JSONC /// loader consults this map BEFORE the embedded/compiled source @@ -1009,12 +954,6 @@ pub fn GameConfigWithYAxis( /// `setDriveParticles`. Gated on `scaled_dt != 0` in the tick, so a /// hard pause (`time_scale == 0`) freezes particles. drive_particles: bool = false, - /// Opt into the engine-driven pixel-water phase (COND-07). Off by - /// default so a game with no reservoir is byte-identical; the scene - /// loader flips it on when it loads a `PixelWater`. Gated on - /// `scaled_dt != 0`-aware handling in the tick, so a hard pause - /// freezes the water clock and slow-mo slows it. - drive_pixel_water: bool = false, frame_number: u64 = 0, /// Current game state (e.g. "menu", "playing", "paused"). /// Set via setState() or queueStateChange(). Default is "running". @@ -1332,6 +1271,15 @@ pub fn GameConfigWithYAxis( pub const setSpriteFlip = Visuals.setSpriteFlip; pub const setSpriteFrame = Visuals.setSpriteFrame; pub const setTextFont = Visuals.setTextFont; + pub const createShaderMaterial = ShaderMaterialMixin.createShaderMaterial; + pub const shaderMaterial = ShaderMaterialMixin.shaderMaterial; + pub const setShaderParameter = ShaderMaterialMixin.setShaderParameter; + pub const setShaderTexture = ShaderMaterialMixin.setShaderTexture; + pub const clearShaderMaterial = ShaderMaterialMixin.clearShaderMaterial; + pub const clearWorldShaderMaterials = ShaderMaterialMixin.clearWorldShaderMaterials; + pub const clearAllShaderMaterials = ShaderMaterialMixin.clearAllShaderMaterials; + pub const invalidateAllShaderMaterials = ShaderMaterialMixin.invalidateAllShaderMaterials; + pub const reapShaderMaterials = ShaderMaterialMixin.reapShaderMaterials; pub const setMaterial = Visuals.setMaterial; pub const clearMaterial = Visuals.clearMaterial; @@ -1360,25 +1308,6 @@ pub fn GameConfigWithYAxis( pub const reapGhostEmitters = EmitterMixin.reapGhostEmitters; pub const deinitParticleSystems = EmitterMixin.deinitParticleSystems; - // ── Pixel water (COND-07) — `game/pixel_water_mixin.zig` ────── - pub const addPixelWater = PixelWaterMixin.addPixelWater; - pub const pixelWater = PixelWaterMixin.pixelWater; - pub const waterInstance = PixelWaterMixin.waterInstance; - pub const resolvePixelWaterInstance = PixelWaterMixin.resolvePixelWaterInstance; - pub const setWaterSettings = PixelWaterMixin.setWaterSettings; - pub const setWaterLevel = PixelWaterMixin.setWaterLevel; - pub const setWaterWavesEnabled = PixelWaterMixin.setWaterWavesEnabled; - pub const addWaterRipple = PixelWaterMixin.addWaterRipple; - pub const setDrivePixelWater = PixelWaterMixin.setDrivePixelWater; - pub const releasePixelWaterInstance = PixelWaterMixin.releasePixelWaterInstance; - pub const releasePixelWater = PixelWaterMixin.releasePixelWater; - pub const releaseWaterAssets = PixelWaterMixin.releaseWaterAssets; - pub const releaseAllWaterAssets = PixelWaterMixin.releaseAllWaterAssets; - pub const releaseWorldWaterAssets = PixelWaterMixin.releaseWorldWaterAssets; - pub const clearPixelWaterInstances = PixelWaterMixin.clearPixelWaterInstances; - pub const reapGhostPixelWater = PixelWaterMixin.reapGhostPixelWater; - pub const deinitPixelWaterInstances = PixelWaterMixin.deinitPixelWaterInstances; - // ── Roster cache (#653, #657) — `game/roster.zig` ───────── // Borrowed-slice lifetime contract + design rationale live in // the module header there. diff --git a/src/game/entity_mixin.zig b/src/game/entity_mixin.zig index ac953bc3..a0ae04c6 100644 --- a/src/game/entity_mixin.zig +++ b/src/game/entity_mixin.zig @@ -292,13 +292,9 @@ pub fn Mixin(comptime Game: type) type { self.detachFromParent(entity); untrackSceneEntity(self, entity); self.active_world.sprite_cache.invalidate(@intCast(entity)); - // Water instance + catalog references, released SYNCHRONOUSLY - // (#100) rather than left to the tick's reaper: on a backend - // that recycles entity ids, a reservoir created later in the - // same frame can land on this very key and inherit the dead - // entity's instance. Before `untrackEntity`, because the release - // unbinds the sprite and marks the visual dirty. - self.releasePixelWater(entity); + // Release runtime materials and pending/committed catalog pins + // before entity ids can be recycled and before renderer untracking. + self.clearShaderMaterial(entity); self.renderer.untrackEntity(entity); self.releaseTilemap(entity); // Free this entity's own `Children` backing allocation just before @@ -350,13 +346,9 @@ pub fn Mixin(comptime Game: type) type { if (self.ecs_backend.getComponent(entity, Children)) |cc| cc.deinit(self.allocator); untrackSceneEntity(self, entity); self.active_world.sprite_cache.invalidate(@intCast(entity)); - // Water instance + catalog references, released SYNCHRONOUSLY - // (#100) rather than left to the tick's reaper: on a backend - // that recycles entity ids, a reservoir created later in the - // same frame can land on this very key and inherit the dead - // entity's instance. Before `untrackEntity`, because the release - // unbinds the sprite and marks the visual dirty. - self.releasePixelWater(entity); + // Release runtime materials and pending/committed catalog pins + // before entity ids can be recycled and before renderer untracking. + self.clearShaderMaterial(entity); self.renderer.untrackEntity(entity); self.releaseTilemap(entity); self.ecs_backend.destroyEntity(entity); diff --git a/src/game/game_init.zig b/src/game/game_init.zig index 4f173ce2..add4dfc8 100644 --- a/src/game/game_init.zig +++ b/src/game/game_init.zig @@ -91,7 +91,6 @@ pub fn Mixin(comptime Game: type, comptime VideoImpl: type, comptime AudioImpl: std.AutoHashMap(Entity, *Game.TilemapRuntimeType).init(allocator) else {}, .particle_systems = std.AutoHashMap(Entity, *@import("../particles.zig").ParticleSystem).init(allocator), - .water_instances = std.AutoHashMap(Entity, @import("pixel_water_mixin.zig").WaterInstanceIdOf(Game.RendererType)).init(allocator), .scene_source_overrides = std.StringHashMap([]const u8).init(allocator), .runtime_anim_defs = animation_def_runtime.RuntimeAnimDefs.init(allocator), .animation_library = @import("animation").Library.init(allocator), diff --git a/src/game/lifecycle_mixin.zig b/src/game/lifecycle_mixin.zig index a4a141d3..d312b0df 100644 --- a/src/game/lifecycle_mixin.zig +++ b/src/game/lifecycle_mixin.zig @@ -63,6 +63,7 @@ pub fn Mixin(comptime Game: type) type { // through this whole call. if (has_events) self.event_buffer.deinit(self.allocator); self.teardownActiveScene(); + self.clearAllShaderMaterials(); self.scene_entities.deinit(self.allocator); self.assets.deinit(); if (self.current_scene_name) |name| { @@ -102,7 +103,6 @@ pub fn Mixin(comptime Game: type) type { // Particle sims (#750) — free the per-emitter pools + the table. self.deinitParticleSystems(); - self.deinitPixelWaterInstances(); // Clean up active world self.active_world.deinit(); @@ -420,7 +420,19 @@ pub fn Mixin(comptime Game: type) type { /// loop lives on, so the usual `emitSync` re-entrancy caveats /// apply (no nested emits into the same drain). pub fn surfaceLost(self: *Game) void { + // Catalog first: it flips `gpu_alive` and drops every `.ready` + // image to `.registered` without freeing, so the material pin + // releases below can never reach `loader.free` on the dead + // context (a refcount hitting zero on a still-`.ready` entry + // would). Then FORGET the materials (labelle-gfx#361 + // `invalidateShaderMaterials`) — never destroy through a + // context that is gone. self.assets.invalidateGpuResources(); + const invalidated = self.invalidateAllShaderMaterials(); + if (invalidated != 0) self.log.warn( + "surface_lost: {d} shader material(s) invalidated across worlds; their ids now resolve to nothing and bound sprites draw plain — recreate from game-owned definitions after surface_restored", + .{invalidated}, + ); self.atlas_manager.invalidateUploadedTextures(); // Direct uploads (#820): drop the dead handles, keep the ids. self.invalidateDirectTextures(); diff --git a/src/game/loop_mixin.zig b/src/game/loop_mixin.zig index 5b9fc32b..7d33bb95 100644 --- a/src/game/loop_mixin.zig +++ b/src/game/loop_mixin.zig @@ -115,17 +115,7 @@ pub fn Mixin(comptime Game: type) type { if (self.drive_particles and scaled_dt != 0) { @import("../particles_tick.zig").tick(self, scaled_dt); } - // Pixel water (COND-07, labelle-bgfx#100). Same always-run block - // and the same TIME-SCALED dt as the two ticks above, so a hard - // pause (`time_scale == 0`) freezes the reservoir's clock and - // slow-mo slows it — the shader's time comes from the simulation - // step, never a wall clock. Unlike the particle tick this is NOT - // gated on `scaled_dt != 0`: a paused frame must still be able to - // bind a mask that finished uploading, and the advance itself is a - // no-op on a zero delta. - if (self.drive_pixel_water) { - @import("../pixel_water_tick.zig").tick(self, scaled_dt); - } + self.reapShaderMaterials(); // Tiled per-tile animations (labelle-gfx#351) — water, // shorelines, torches, waterfalls. gfx's tilemap renderer owns // NO clock (backends differ; headless tests must be diff --git a/src/game/pixel_water_mixin.zig b/src/game/pixel_water_mixin.zig deleted file mode 100644 index 899c833d..00000000 --- a/src/game/pixel_water_mixin.zig +++ /dev/null @@ -1,675 +0,0 @@ -//! Pixel-water mixin — the game-facing half of the built-in `pixel_water` -//! material (COND-07, labelle-bgfx#100, RFC-PIXEL-WATER phase 5). -//! -//! WHAT LIVES WHERE. Everything animated — simulation time, the fill level, -//! the eight live impacts, ripple expiry and the deterministic -//! oldest-replacement at capacity — is owned by labelle-gfx's water-instance -//! store and reached through the renderer's `createWaterInstance` / -//! `setWaterSettings` / `setWaterLevel` / `advanceWaterTime` / -//! `addWaterRipple` decls. This mixin does NOT reimplement any of it. Its job -//! is the three things gfx cannot do: -//! -//! 1. ASSET + LIFETIME. Resolve the authored `mask` / `reflection` asset -//! keys to renderer texture handles, create the instance once they are -//! resident, bind it to the sprite (`Sprite.water` + `material.effect = -//! .pixel_water`), and release it on destroy / scene reset / reload. -//! 2. AUTHORING VALIDATION with entity- and field-named diagnostics. -//! 3. STAGE BEFORE COMMIT (see `setWaterSettings`). -//! -//! GRACEFUL DEGRADE, two layers, mirroring `visuals.setMaterial`: -//! * comptime — a renderer without `createWaterInstance` (StubRender, mocks, -//! any gfx predating labelle-gfx#359) folds the whole side-table and every -//! helper to a no-op / `error.PixelWaterRejected`. The engine takes no gfx -//! dependency, so the capability is detected by `@hasDecl` and every -//! renderer-side type is read off the renderer, never imported. -//! * runtime — a backend that lacks the water shader reports `pixel_water` -//! unsupported and draws the authored static reservoir sprite. -//! -//! Side-table rather than "read the id back off `Sprite.water`": an entity -//! whose sprite was removed, or which was destroyed outright, still owes gfx a -//! `releaseWaterInstance`, and the sprite component is exactly what is gone by -//! then. `reapGhostPixelWater` (the mirror of `reapGhostEmitters`) is what -//! makes that collectable. `Sprite.water` remains the DRAW binding; the -//! side-table is the LIFETIME record. - -const std = @import("std"); -const pw = @import("../pixel_water.zig"); -const atlas_mixin = @import("atlas_mixin.zig"); - -const PixelWater = pw.PixelWater; -const PixelWaterSettings = pw.PixelWaterSettings; -const PixelWaterError = pw.PixelWaterError; -const PixelWaterIssue = pw.PixelWaterIssue; - -/// True when the renderer plugin ships labelle-gfx#359's water-instance API. -pub fn rendererSupportsWater(comptime Renderer: type) bool { - return @hasDecl(Renderer, "createWaterInstance") and - @hasDecl(Renderer, "WaterInstanceId") and - @hasDecl(Renderer, "WaterConfig"); -} - -/// The side-table's value type: the renderer's own generational instance id, -/// or an empty struct on a renderer without the seam (so the table's type is -/// always well-formed and no field is hidden behind a comptime gate). -pub fn WaterInstanceIdOf(comptime Renderer: type) type { - return if (rendererSupportsWater(Renderer)) Renderer.WaterInstanceId else struct {}; -} - -/// The catalog references ONE reservoir holds, and the one bit of resolution -/// state that cannot be read back off the renderer. -/// -/// Why the names are OWNED copies rather than the component's own slices: the -/// release is owed exactly when the component is gone (entity destroyed, -/// component removed, ECS wiped), and scene-authored names live in the -/// world's nested-entity arena — which the very same teardown frees. A -/// borrowed slice would be dangling at the only moment it is needed. -pub const WaterAssets = struct { - /// Duped with `game.allocator`; empty when no reference is held. - mask: []const u8 = "", - reflection: []const u8 = "", - /// Set when the instance was created while an authored reflection was - /// still streaming, so the tick knows to reconfigure once it lands. - /// Without it, whichever of the two textures finished first would decide - /// whether the authored reflection ever appears. - reflection_pending: bool = false, -}; - -pub fn Mixin(comptime Game: type) type { - const Entity = Game.EntityType; - const Renderer = Game.RendererType; - const Sprite = Game.SpriteComp; - - const supported = rendererSupportsWater(Renderer); - const WaterInstanceId = WaterInstanceIdOf(Renderer); - - // `Sprite.material.effect` carries labelle-core's `MaterialEffect`, and - // `.pixel_water` is APPENDED by labelle-core#78. Reached through - // `@hasField` / `@field` rather than by naming the tag, so the engine - // still compiles against a core pin that predates the tag — the same - // additive, capability-gated discipline the rest of the seam uses. - const MaterialEffectType = if (@hasField(Sprite, "material")) - @FieldType(@FieldType(Sprite, "material"), "effect") - else - void; - - const has_pixel_water_effect = MaterialEffectType != void and - @hasField(MaterialEffectType, "pixel_water"); - const pixel_water_effect = if (has_pixel_water_effect) - @field(MaterialEffectType, "pixel_water") - else {}; - - return struct { - // ── Diagnostics ───────────────────────────────────────────────── - - fn report(self: *Game, entity: Entity, issue: PixelWaterIssue) void { - self.log.err("PixelWater on entity {any}: field '{s}' {s}", .{ - entity, - @tagName(issue.field), - issue.reason.text(), - }); - } - - // ── Authoring ─────────────────────────────────────────────────── - - /// Attach (or re-attach) an authored `PixelWater` to `entity`. - /// - /// The single entry point for EVERY authoring path — the JSONC - /// built-in branch (`component_apply.applyPixelWater`), the comptime - /// `.zon` writer, the script contract, hot reload and live prefab - /// refresh all land here, which is what makes the stage-before-commit - /// rule hold on all of them rather than on whichever one was written - /// first. - /// - /// Returns `false` (having logged a field-named diagnostic) when the - /// candidate is invalid. On that path NOTHING is committed: no - /// component is added, an existing component keeps its previous - /// values, and the gfx instance is untouched. - pub fn addPixelWater(self: *Game, entity: Entity, candidate: PixelWater) bool { - // STAGE: validate the candidate before touching either side. - if (pw.validateComponent(candidate)) |issue| { - report(self, entity, issue); - return false; - } - - if (self.ecs_backend.getComponent(entity, PixelWater)) |existing| { - // Re-author over a live component (hot reload / prefab - // refresh). Structural change → explicit reconfiguration; - // otherwise the same validated synchronization path a runtime - // caller uses. Both commit gfx first, component second. - const structural_changed = - !std.mem.eql(u8, existing.mask, candidate.mask) or - !std.mem.eql(u8, existing.reflection, candidate.reflection) or - existing.logical_size[0] != candidate.logical_size[0] or - existing.logical_size[1] != candidate.logical_size[1] or - existing.grid_pixels != candidate.grid_pixels; - - if (structural_changed) { - // Structural fields are the instance's identity; drop and - // let the tick recreate it from the new component. - releasePixelWaterInstance(self, entity); - existing.* = candidate; - } else { - setWaterSettings(self, entity, candidate.settings()) catch return false; - setWaterLevel(self, entity, candidate.water_level) catch return false; - } - self.drive_pixel_water = true; - syncWaterAssets(self, entity, candidate); - return true; - } - - // COMMIT: fresh component. - self.addComponent(entity, candidate); - self.drive_pixel_water = true; - syncWaterAssets(self, entity, candidate); - // Opportunistic: if the textures are already resident the - // instance exists on this very call rather than one tick later. - _ = resolvePixelWaterInstance(self, entity); - return true; - } - - // ── Asset references ──────────────────────────────────────────── - - /// The ACTIVE world's asset records. Lives on `Game.World`, not on - /// `Game`: the keys are that world's entity ids and the table - /// survives a world swap, so a game-global one would confuse two - /// worlds' reservoirs whenever their ids collide and would have the - /// reaper release a shelved world's references (see the field's own - /// comment in `game.zig`). Every caller below is by definition - /// operating on the active world. - fn waterAssetTable(self: *Game) *std.AutoHashMap(Entity, WaterAssets) { - return &self.active_world.water_assets; - } - - // - // A reservoir pins its mask / reflection in the catalog for as long - // as the component lives, so a streaming mask actually streams and - // an unrelated `release` cannot evict a texture a live instance is - // sampling. Every one of those `acquire`s is balanced: the names are - // recorded per entity (`Game.water_assets`) and released when the - // component goes away — destroy, `removeComponent` (via the reaper), - // ECS reset, and `deinit`. An unbalanced `acquire` would pin the - // texture for the rest of the process and inflate further on every - // hot reload. - - /// Take the references `comp` needs and drop any this entity held - /// for other names. A re-author with UNCHANGED names is a no-op — - /// it must not bump the refcount, or repeated prefab refreshes would - /// ratchet it up one per pass. - fn syncWaterAssets(self: *Game, entity: Entity, comp: PixelWater) void { - if (waterAssetTable(self).getPtr(entity)) |held| { - if (std.mem.eql(u8, held.mask, comp.mask) and - std.mem.eql(u8, held.reflection, comp.reflection)) return; - releaseWaterAssets(self, entity); - } - - var rec: WaterAssets = .{}; - rec.mask = acquireOne(self, comp.mask); - rec.reflection = acquireOne(self, comp.reflection); - waterAssetTable(self).put(entity, rec) catch { - freeAssetRecord(self, rec); - }; - } - - /// Acquire `name` and return an OWNED copy of it, or `""` when there - /// is nothing to hold (empty name, unregistered asset, OOM). The - /// returned slice is the receipt: a non-empty one means a reference - /// IS held and must be released. - fn acquireOne(self: *Game, name: []const u8) []const u8 { - if (name.len == 0) return ""; - _ = self.assets.acquire(name) catch return ""; - return self.allocator.dupe(u8, name) catch { - self.assets.release(name); - return ""; - }; - } - - fn freeAssetRecord(self: *Game, rec: WaterAssets) void { - if (rec.mask.len != 0) { - self.assets.release(rec.mask); - self.allocator.free(rec.mask); - } - if (rec.reflection.len != 0) { - self.assets.release(rec.reflection); - self.allocator.free(rec.reflection); - } - } - - /// Drop every catalog reference `entity` holds. Safe to call on an - /// entity that never had any. - pub fn releaseWaterAssets(self: *Game, entity: Entity) void { - const kv = waterAssetTable(self).fetchRemove(entity) orelse return; - freeAssetRecord(self, kv.value); - } - - /// Drop every reservoir's catalog references (retaining capacity). - /// The ECS-reset / `deinit` counterpart of `releaseWaterAssets` — - /// NOT called on a world swap, where the shelved world's components - /// live on and still own their references. - pub fn releaseAllWaterAssets(self: *Game) void { - var it = waterAssetTable(self).valueIterator(); - while (it.next()) |rec| freeAssetRecord(self, rec.*); - waterAssetTable(self).clearRetainingCapacity(); - } - - /// Release BOTH halves for `entity` — the gfx instance and the - /// catalog references. The whole-component teardown the destroy - /// paths and the reaper use. - pub fn releasePixelWater(self: *Game, entity: Entity) void { - releasePixelWaterInstance(self, entity); - releaseWaterAssets(self, entity); - } - - /// The live component, or `null`. - pub fn pixelWater(self: *Game, entity: Entity) ?*PixelWater { - return self.ecs_backend.getComponent(entity, PixelWater); - } - - /// The gfx instance id bound to `entity`, or `null` while its - /// textures are still streaming in. - pub fn waterInstance(self: *Game, entity: Entity) ?WaterInstanceId { - if (comptime !supported) return null; - return self.water_instances.get(entity); - } - - // ── Asset resolution + instance creation ──────────────────────── - - /// The backend texture handle a catalog `.image` asset uploaded to, or - /// `null` while it is not yet resident. Deliberately the very lookup - /// `bridgeImageAssetsToAtlasManager` uses, so a mask resolves through - /// the standard streaming path and the catalog keeps texture - /// ownership — releasing a water instance can never destroy it. - fn catalogTexture(self: *Game, name: []const u8) ?u32 { - if (name.len == 0) return null; - const entry = self.assets.entries.getPtr(name) orelse return null; - if (entry.loader_kind != .image) return null; - const resource = entry.resource orelse return null; - return switch (resource) { - .image => |t| t, - else => null, - }; - } - - /// Build the renderer's `WaterConfig` from an authored component. - /// `null` when the REQUIRED mask is not resident yet (the reflection - /// is optional and degrades to "no reflection"). - /// - /// This is the ONE place authored sRGB hex becomes linear - /// `PixelWaterRgba` — see the double-gamma note in - /// `src/pixel_water.zig`. - fn buildWaterConfig(self: *Game, comp: PixelWater) ?Renderer.WaterConfig { - if (comptime !supported) return null; - const Config = Renderer.WaterConfig; - const TexHandle = @FieldType(Config, "mask"); - const Rgba = @FieldType(Config, "deep"); - - const mask = catalogTexture(self, comp.mask) orelse return null; - - var cfg: Config = .{}; - cfg.mask = atlas_mixin.normalizeHandle(TexHandle, mask); - if (catalogTexture(self, comp.reflection)) |r| { - cfg.reflection = atlas_mixin.normalizeHandle(TexHandle, r); - } - cfg.logical_width = comp.logical_size[0]; - cfg.logical_height = comp.logical_size[1]; - cfg.grid_pixels = comp.grid_pixels; - - // `validateComponent` has already proven these parse; a defensive - // `catch` keeps the seam total rather than trusting that. - cfg.deep = pw.toLinearRgba(Rgba, pw.parseHexColor(comp.deep_color) catch return null); - cfg.surface = pw.toLinearRgba(Rgba, pw.parseHexColor(comp.surface_color) catch return null); - cfg.highlight = pw.toLinearRgba(Rgba, pw.parseHexColor(comp.highlight_color) catch return null); - - cfg.wave_amplitude_pixels = comp.wave_amplitude_pixels; - cfg.wave_period_seconds = comp.wave_period_seconds; - cfg.waves_enabled = comp.waves_enabled; - cfg.distortion_pixels = comp.distortion_pixels; - cfg.reflection_opacity = comp.reflection_opacity; - cfg.ripple_duration_seconds = comp.ripple_duration_seconds; - cfg.ripple_radius_pixels = comp.ripple_radius_pixels; - cfg.ripple_strength_pixels = comp.ripple_strength_pixels; - return cfg; - } - - /// Create the gfx instance for `entity` if it has a `PixelWater`, has - /// no instance yet, and its mask is resident. Returns true when an - /// instance exists afterwards. Idempotent and cheap to call per frame - /// — that is how a streaming mask "pops in" without a retry timer. - pub fn resolvePixelWaterInstance(self: *Game, entity: Entity) bool { - if (comptime !supported) return false; - if (self.water_instances.get(entity)) |existing_id| { - // NOT a bare `return true`. Two things can still be owed on - // an entity that already has an instance: - // * the SPRITE may have arrived after the water did - // (authored component order, or a sprite removed and - // re-added), and the binding is what makes the effect - // draw at all; - // * an authored REFLECTION may have finished streaming - // after the mask did, and nothing else would ever apply - // it. - // Both helpers are idempotent and cost a hash lookup, which - // is what makes calling them per frame acceptable. - applyPendingReflection(self, entity, existing_id); - bindSpriteToInstance(self, entity, existing_id); - return true; - } - - const comp = self.ecs_backend.getComponent(entity, PixelWater) orelse return false; - const cfg = buildWaterConfig(self, comp.*) orelse return false; - - const id = self.renderer.createWaterInstance(cfg) catch |err| { - self.log.err("PixelWater on entity {any}: createWaterInstance failed: {s}", .{ - entity, @errorName(err), - }); - return false; - }; - self.water_instances.put(entity, id) catch { - _ = self.renderer.releaseWaterInstance(id); - return false; - }; - - // Seed the authored level. A failure here is NOT ignorable: it - // would leave a live instance at the default level while the - // component reads the authored one — the exact component / - // instance divergence the rest of this file exists to prevent — - // and report success. Hand the instance back instead; the tick - // retries from a clean slate next frame. - self.renderer.setWaterLevel(id, comp.water_level) catch |err| { - self.log.err("PixelWater on entity {any}: initial level sync failed: {s}", .{ - entity, @errorName(err), - }); - _ = self.water_instances.remove(entity); - _ = self.renderer.releaseWaterInstance(id); - return false; - }; - - // Remember a reflection that was still streaming when the - // (required) mask became resident, so `applyPendingReflection` - // can finish the job. Whichever texture lands first must not - // decide whether the authored reflection ever appears. - if (waterAssetTable(self).getPtr(entity)) |held| { - held.reflection_pending = comp.reflection.len != 0 and - catalogTexture(self, comp.reflection) == null; - } - - bindSpriteToInstance(self, entity, id); - return true; - } - - /// Push the full config again once a late-arriving reflection is - /// resident. No-op unless one was actually pending. - fn applyPendingReflection(self: *Game, entity: Entity, id: WaterInstanceId) void { - if (comptime !supported) return; - const held = waterAssetTable(self).getPtr(entity) orelse return; - if (!held.reflection_pending) return; - const comp = self.ecs_backend.getComponent(entity, PixelWater) orelse return; - if (catalogTexture(self, comp.reflection) == null) return; - const cfg = buildWaterConfig(self, comp.*) orelse return; - self.renderer.setWaterSettings(id, cfg) catch |err| { - self.log.err("PixelWater on entity {any}: reflection bind failed: {s}", .{ - entity, @errorName(err), - }); - return; - }; - held.reflection_pending = false; - } - - /// Point the sprite's draw at the instance: `Sprite.water` carries the - /// id and `Sprite.material.effect` selects the effect. Both writes are - /// `@hasField`-guarded, so a renderer with a material seam but no - /// water seam (or neither) still compiles. - fn bindSpriteToInstance(self: *Game, entity: Entity, id: WaterInstanceId) void { - const sprite = self.ecs_backend.getComponent(entity, Sprite) orelse return; - var changed = false; - if (comptime @hasField(Sprite, "water")) { - // Equality-guarded: this now runs on EVERY tick of an - // already-bound reservoir (see `resolvePixelWaterInstance`), - // and an unconditional write would mark the visual dirty - // every frame — a redundant sync that breaks the draw batch. - if (!std.meta.eql(sprite.water, id)) { - sprite.water = id; - changed = true; - } - } - if (comptime has_pixel_water_effect) { - if (sprite.material.effect != pixel_water_effect) { - sprite.material.effect = pixel_water_effect; - changed = true; - } - } - if (changed) self.renderer.markVisualDirty(entity); - } - - // ── Runtime helpers (delegating to gfx) ───────────────────────── - - /// Validate a complete backend-independent settings value, then - /// synchronize the component and the gfx-owned instance. - /// - /// STAGE BEFORE COMMIT. The candidate is validated, and pushed to gfx, - /// BEFORE the component is written. That order is the whole point: - /// committing the component first and synchronizing after leaves a - /// TORN state when validation rejects — the component holds the new - /// value while the gfx instance still holds the old one, and nothing - /// is left to detect the divergence because the component already - /// looks updated. Here every rejection path (engine validation, or - /// gfx's own) returns with BOTH sides still on the previous settings. - /// - /// Equal settings are a no-op: no gfx call, no revision bump. - pub fn setWaterSettings(self: *Game, entity: Entity, next: PixelWaterSettings) PixelWaterError!void { - const comp = self.ecs_backend.getComponent(entity, PixelWater) orelse - return error.NoPixelWater; - - // STAGE 1 — engine validation of the candidate. - if (pw.validateSettings(next)) |issue| { - report(self, entity, issue); - return error.InvalidPixelWater; - } - - const staged = comp.withSettings(next); - if (std.meta.eql(staged, comp.*)) return; // no-op - - // STAGE 2 — push to gfx. Its `setSettings` validates the whole - // candidate and leaves the previous settings intact on rejection, - // so a failure here still has the component untouched below. - if (comptime supported) { - if (self.water_instances.get(entity)) |id| { - const cfg = buildWaterConfig(self, staged) orelse { - // Mask no longer resident: refuse rather than commit a - // component the instance cannot mirror. - report(self, entity, .{ .field = .mask, .reason = .missing }); - return error.PixelWaterRejected; - }; - self.renderer.setWaterSettings(id, cfg) catch |err| { - self.log.err("PixelWater on entity {any}: renderer rejected settings: {s}", .{ - entity, @errorName(err), - }); - return error.PixelWaterRejected; - }; - } - } - - // COMMIT — only now, with gfx already carrying the new value. - comp.* = staged; - } - - /// Set the fill fraction. Clamped to [0, 1] by gfx; NaN/Infinity is - /// rejected here with a named diagnostic. A nonzero change preserves - /// every active impact's X, age and strength (they stay attached to - /// the rising surface); setting zero clears them — both rules live in - /// gfx, this only forwards. - pub fn setWaterLevel(self: *Game, entity: Entity, level: f32) PixelWaterError!void { - const comp = self.ecs_backend.getComponent(entity, PixelWater) orelse - return error.NoPixelWater; - if (pw.validateLevel(level)) |issue| { - report(self, entity, issue); - return error.InvalidPixelWater; - } - const clamped = std.math.clamp(level, 0, 1); - if (clamped == comp.water_level) return; - - if (comptime supported) { - if (self.water_instances.get(entity)) |id| { - self.renderer.setWaterLevel(id, clamped) catch |err| { - self.log.err("PixelWater on entity {any}: renderer rejected level: {s}", .{ - entity, @errorName(err), - }); - return error.PixelWaterRejected; - }; - } - } - comp.water_level = clamped; - } - - /// Toggle surface waves without destroying the authored amplitude. - pub fn setWaterWavesEnabled(self: *Game, entity: Entity, on: bool) PixelWaterError!void { - var s = (self.ecs_backend.getComponent(entity, PixelWater) orelse - return error.NoPixelWater).settings(); - s.waves_enabled = on; - return setWaterSettings(self, entity, s); - } - - /// Record one drop impact at reservoir-local `x`, with a - /// DIMENSIONLESS `strength` in [0, 1] that scales the authored - /// `ripple_strength_pixels`. - /// - /// `strength` is bounded HERE (the runtime boundary); - /// `ripple_strength_pixels` is bounded in `validateSettings` (the - /// authored boundary). Both, because a bound on only one of them - /// leaves the other as the way to exceed it. Peak displacement is - /// therefore at most `ripple_strength_pixels`, whatever a caller - /// passes. - /// - /// X bounds, the empty-reservoir refusal, expiry, the eight-slot cap - /// and the deterministic oldest-replacement are gfx's — delegated, not - /// duplicated. CPU acceptance is logical-bounds only: it does not - /// query mask coverage, so an in-bounds impact over a masked-out - /// region may consume a slot and be clipped on the GPU. - pub fn addWaterRipple(self: *Game, entity: Entity, x: f32, strength: f32) PixelWaterError!void { - if (self.ecs_backend.getComponent(entity, PixelWater) == null) return error.NoPixelWater; - - if (!std.math.isFinite(x)) { - report(self, entity, .{ .field = .ripple_x, .reason = .non_finite }); - return error.InvalidPixelWater; - } - if (pw.validateRippleStrength(strength)) |issue| { - report(self, entity, issue); - return error.InvalidPixelWater; - } - - if (comptime !supported) return error.PixelWaterRejected; - const id = self.water_instances.get(entity) orelse return error.PixelWaterRejected; - self.renderer.addWaterRipple(id, x, strength) catch |err| { - self.log.err("PixelWater on entity {any}: renderer rejected ripple: {s}", .{ - entity, @errorName(err), - }); - return error.PixelWaterRejected; - }; - } - - // ── Lifetime ──────────────────────────────────────────────────── - - /// Opt into the engine-driven water phase. Set automatically when a - /// scene loads a `PixelWater` (mirroring `drive_particles`), so - /// scene-authored reservoirs just work and a game with none leaves the - /// tick a byte-identical no-op. - pub fn setDrivePixelWater(self: *Game, on: bool) void { - self.drive_pixel_water = on; - } - - /// Release the gfx instance bound to `entity` (if any) and forget it. - /// Never touches the mask/reflection textures — the asset catalog owns - /// those, and a second reservoir may be sharing them. - pub fn releasePixelWaterInstance(self: *Game, entity: Entity) void { - if (comptime !supported) return; - if (self.water_instances.fetchRemove(entity)) |kv| { - _ = self.renderer.releaseWaterInstance(kv.value); - if (self.ecs_backend.getComponent(entity, Sprite)) |sprite| { - if (comptime @hasField(Sprite, "water")) sprite.water = .none; - if (comptime has_pixel_water_effect) { - if (sprite.material.effect == pixel_water_effect) { - sprite.material.effect = .none; - } - } - self.renderer.markVisualDirty(entity); - } - } - } - - /// Release every instance and empty the table (retaining capacity). - /// Called from `resetEcsBackend` (scene swap / load): the ECS is about - /// to be wiped, so every entity key becomes a dangling handle. - pub fn clearPixelWaterInstances(self: *Game) void { - if (comptime !supported) return; - // Through `releasePixelWaterInstance` rather than a raw release - // loop: a world SWAP also lands here, and there the entities - // survive — so their sprites must lose the now-dead id too, or - // a shelved reservoir keeps a stale `Sprite.water` until - // something happens to rebind it. `fetchRemove` invalidates the - // iterator, hence the take-the-first-key loop. - while (true) { - var it = self.water_instances.iterator(); - const entry = it.next() orelse break; - releasePixelWaterInstance(self, entry.key_ptr.*); - } - self.water_instances.clearRetainingCapacity(); - } - - /// Free any side-table entry whose entity no longer carries the - /// `PixelWater` component — the orphan a `removeComponent` or an - /// entity destroy leaves behind (`game.destroyEntity` cascades to - /// children, so a destroyed parent can orphan several at once). - /// Restart-on-remove iteration because `fetchRemove` invalidates the - /// map iterator. - pub fn reapGhostPixelWater(self: *Game) void { - if (comptime !supported) return; - outer: while (true) { - var it = self.water_instances.iterator(); - while (it.next()) |entry| { - const entity = entry.key_ptr.*; - if (!self.ecs_backend.hasComponent(entity, PixelWater)) { - releasePixelWater(self, entity); - continue :outer; // iterator invalidated — restart - } - } - break; - } - // The asset table is the SUPERSET: a reservoir whose mask never - // became resident holds catalog references without ever having - // had an instance, so sweeping only the instance table above - // would strand exactly those. - outer_assets: while (true) { - var it = waterAssetTable(self).iterator(); - while (it.next()) |entry| { - const entity = entry.key_ptr.*; - if (!self.ecs_backend.hasComponent(entity, PixelWater)) { - releaseWaterAssets(self, entity); - continue :outer_assets; - } - } - break; - } - } - - /// Release every instance and the instance table itself. Called from - /// `Game.deinit`. - /// - /// The ASSET records are not touched here: they live on each `World` - /// now, and every world's teardown (`World.deinit`) frees its own - /// name copies. That is also why no `assets.release` happens on the - /// `Game.deinit` path at all — the catalog is already gone by then. - pub fn deinitPixelWaterInstances(self: *Game) void { - if (comptime supported) clearPixelWaterInstances(self); - self.water_instances.deinit(); - } - - /// Drop the catalog references held by a world that is about to be - /// DESTROYED (`destroyWorld`, or the unnamed active world discarded - /// by `setActiveWorld`). Runs while the catalog is still alive, - /// which is exactly what `World.deinit` cannot assume; it leaves the - /// table empty so that teardown has nothing left to free. - pub fn releaseWorldWaterAssets(self: *Game, world: *Game.World) void { - var it = world.water_assets.valueIterator(); - while (it.next()) |rec| freeAssetRecord(self, rec.*); - world.water_assets.clearRetainingCapacity(); - } - }; -} diff --git a/src/game/shader_material_mixin.zig b/src/game/shader_material_mixin.zig new file mode 100644 index 00000000..e18dbf51 --- /dev/null +++ b/src/game/shader_material_mixin.zig @@ -0,0 +1,332 @@ +const std = @import("std"); +const core = @import("labelle-core"); +const sm = @import("../shader_material.zig"); + +pub fn Mixin(comptime Game: type) type { + const Entity = Game.EntityType; + const Renderer = Game.RendererType; + const supported = @hasDecl(Renderer, "createShaderMaterial") and + @hasDecl(Renderer, "ShaderMaterialDescriptor") and @hasDecl(Renderer, "shaderMaterialSupported") and + @hasDecl(Renderer, "setShaderParameter") and @hasDecl(Renderer, "setShaderTexture") and + @hasDecl(Renderer, "destroyShaderMaterial") and @hasField(Game.SpriteComp, "material"); + return struct { + pub fn shaderMaterial(self: *const Game, entity: Entity) ?sm.contract.Id { + const record = self.active_world.shader_materials.get(entity) orelse return null; + return record.id; + } + + fn clearPending(self: *Game, world: *Game.World, entity: Entity) void { + const old = world.shader_pending.fetchRemove(entity) orelse return; + var keys = old.value; + for (keys.items) |item| freePending(self, item); + keys.deinit(self.allocator); + } + + fn freePending(self: *Game, item: sm.Pending) void { + self.assets.release(item.key); + self.allocator.free(item.key); + self.allocator.free(item.name); + } + + /// Drops the entity's request for `name` (unless it is exactly + /// `keep_key`) WITHOUT touching the map itself, so a caller holding + /// a `getOrPut` pointer into it stays valid. + fn removePendingBinding(self: *Game, entity: Entity, name: []const u8, keep_key: ?[]const u8) void { + const pending = self.active_world.shader_pending.getPtr(entity) orelse return; + for (pending.items, 0..) |held, i| { + if (!std.mem.eql(u8, held.name, name)) continue; + if (keep_key) |key| if (std.mem.eql(u8, held.key, key)) return; + freePending(self, pending.swapRemove(i)); + return; + } + } + + fn cancelPendingBinding(self: *Game, entity: Entity, name: []const u8, keep_key: ?[]const u8) void { + removePendingBinding(self, entity, name, keep_key); + pruneEmptyPending(self, entity); + } + + /// An entity with no live request owns no map entry: `count()` is + /// what `World.deinit` asserts on and what "nothing pending" means. + fn pruneEmptyPending(self: *Game, entity: Entity) void { + const pending = self.active_world.shader_pending.getPtr(entity) orelse return; + if (pending.items.len != 0) return; + var list = self.active_world.shader_pending.fetchRemove(entity).?.value; + list.deinit(self.allocator); + } + + /// Stage-before-commit for PENDING pins: the replacement request is + /// fully secured (copies made, reference acquired, decode confirmed + /// queued) before the same-name request it supersedes is released, + /// so a failure here drops nothing the entity already held. + fn requestPending(self: *Game, entity: Entity, name: []const u8, key: []const u8) !void { + if (self.active_world.shader_pending.getPtr(entity)) |pending| { + for (pending.items) |held| if (std.mem.eql(u8, held.name, name) and std.mem.eql(u8, held.key, key)) return; + } + const copy = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(copy); + const binding = try self.allocator.dupe(u8, name); + errdefer self.allocator.free(binding); + const gop = try self.active_world.shader_pending.getOrPut(self.allocator, entity); + if (!gop.found_existing) gop.value_ptr.* = .empty; + errdefer pruneEmptyPending(self, entity); + try gop.value_ptr.ensureUnusedCapacity(self.allocator, 1); + _ = try self.assets.acquire(key); + errdefer self.assets.release(key); + if (self.assets.entries.getPtr(key).?.state == .registered) return error.AssetDecodeNotQueued; + // Secured: NOW retire the superseded same-name request (a + // different key by construction — the exact pair returned above). + // Non-pruning on purpose: `gop.value_ptr` must stay valid. + removePendingBinding(self, entity, name, null); + gop.value_ptr.appendAssumeCapacity(.{ .name = binding, .key = copy }); + } + + /// How a runtime material is retired — the split labelle-gfx encodes + /// as `TextureInfo.gpu_resident` and `invalidateShaderMaterials`. + pub const Retire = enum { + /// The context is alive: free the backend resource. + destroy, + /// The context is GONE (surface loss): forget the id, never call + /// the backend destructor — destroying on a stale context is UB, + /// and after re-init the dead handle would free whatever the + /// backend recycled into that slot. + invalidate, + }; + + fn releaseRecord(self: *Game, world: *Game.World, record: sm.Record, retire: Retire) void { + if (comptime supported) { + if (retire == .destroy and record.id != .none) world.renderer.destroyShaderMaterial(record.id); + } + for (record.textures[0..record.len]) |binding| { + if (binding.catalog) |key| { + self.assets.release(key); + self.allocator.free(key); + } + self.allocator.free(binding.name); + } + } + + /// Requires a live sprite. Failure preserves the previous material and + /// pins: NOTHING the entity already holds — the committed record or + /// any pending request, mentioned by the new descriptor or not — is + /// released until the replacement has been validated, resolved and + /// created. On commit every pending request is swept (the committed + /// pins own the textures from then on); `TextureNotReady` keeps the + /// request it just made so the retry finds the asset still streaming. + pub fn createShaderMaterial(self: *Game, entity: Entity, desc: sm.Descriptor) anyerror!void { + if (comptime !supported) return error.Unsupported; + if (!self.assets.gpu_alive) return error.GpuSurfaceUnavailable; + if (!self.renderer.shaderMaterialSupported()) return error.Unsupported; + if (!self.ecs_backend.entityExists(entity)) return error.InvalidEntity; + const sprite = self.ecs_backend.getComponent(entity, Game.SpriteComp) orelse return error.MissingSprite; + if (desc.textures.len > sm.contract.MAX_TEXTURES) return error.CapacityExceeded; + var validation_bindings: [sm.contract.MAX_TEXTURES]sm.contract.TextureBinding = undefined; + for (desc.textures, 0..) |binding, i| validation_bindings[i] = .{ .name = binding.name, .sampler = binding.sampler }; + try sm.contract.validateDescriptor(.{ .version = desc.version, .label = desc.label, .shaders = desc.shaders, .parameters = desc.parameters, .textures = validation_bindings[0..desc.textures.len], .blend = desc.blend }); + var record: sm.Record = .{ .id = .none }; + errdefer releaseRecord(self, self.active_world, record, .destroy); + const RD = Renderer.ShaderMaterialDescriptor; + const RB = std.meta.Child(@FieldType(RD, "textures")); + var bindings: [sm.contract.MAX_TEXTURES]RB = undefined; + for (desc.textures, 0..) |binding, i| { + const name = try self.allocator.dupe(u8, binding.name); + record.textures[i] = .{ .name = name }; + record.len += 1; + const texture = try resolveTexture(self, entity, binding.name, binding.texture, &record.textures[i].catalog); + bindings[i] = .{ .name = binding.name, .texture = texture, .sampler = binding.sampler }; + } + try self.active_world.shader_materials.ensureUnusedCapacity(self.allocator, 1); + record.id = try self.renderer.createShaderMaterial(.{ + .version = desc.version, + .label = desc.label, + .shaders = desc.shaders, + .parameters = desc.parameters, + .textures = bindings[0..desc.textures.len], + .blend = desc.blend, + }); + if (self.active_world.shader_materials.fetchRemove(entity)) |old| releaseRecord(self, self.active_world, old.value, .destroy); + self.active_world.shader_materials.putAssumeCapacity(entity, record); + // Only the shader slot is ours. `Material.shader` takes precedence + // over a curated `effect` while it is live, but the curated effect + // must SURVIVE the shader's lifetime: overwriting the whole + // `Material` here silently drops an authored flash/outline/dissolve + // and `clearShaderMaterial` (which only resets `.shader`) can never + // give it back. + sprite.material.shader = record.id; + self.renderer.markVisualDirty(entity); + clearPending(self, self.active_world, entity); + } + + fn resolveTexture(self: *Game, entity: Entity, name: []const u8, texture: sm.Texture, pin: *?[]const u8) !core.TextureId { + switch (texture) { + .id => |id| return id, + .catalog => |key| { + const entry = self.assets.entries.getPtr(key) orelse return error.AssetNotRegistered; + if (entry.loader_kind != .image) return error.InvalidTexture; + // Hold one pending pin across retries, which starts streaming even + // when the scene manifest did not infer this game-owned binding. + const resource = entry.resource orelse { + if (entry.state == .failed) return error.AssetLoadFailed; + try requestPending(self, entity, name, key); + return error.TextureNotReady; + }; + const id = switch (resource) { + .image => |id| id, + else => return error.InvalidTexture, + }; + const copy = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(copy); + _ = try self.assets.acquire(key); + pin.* = copy; + return @enumFromInt(id); + }, + } + } + + pub fn setShaderParameter(self: *Game, entity: Entity, name: []const u8, values: []const f32) anyerror!void { + if (comptime !supported) return error.Unsupported; + // Contract: calls made while the GPU is unavailable report the + // surface loss, not a handle error — `surfaceLost` has already + // cleared every runtime id, so `InvalidHandle` here would read as + // "this entity never had a material". + if (!self.assets.gpu_alive) return error.GpuSurfaceUnavailable; + const id = shaderMaterial(self, entity) orelse return error.InvalidHandle; + self.renderer.setShaderParameter(id, name, values) catch |err| { + if (err == error.InvalidHandle) clearShaderMaterial(self, entity); + return @as(anyerror!void, err); + }; + } + + /// Accepts a catalog key, TextureId, or explicit ShaderTexture union. + pub fn setShaderTexture(self: *Game, entity: Entity, name: []const u8, source: anytype) anyerror!void { + if (comptime !supported) return error.Unsupported; + if (!self.assets.gpu_alive) return error.GpuSurfaceUnavailable; + const record = self.active_world.shader_materials.getPtr(entity) orelse return error.InvalidHandle; + const texture: sm.Texture = if (@TypeOf(source) == sm.Texture) source else if (@TypeOf(source) == core.TextureId) .{ .id = source } else .{ .catalog = source }; + var binding: ?*sm.HeldTexture = null; + for (record.textures[0..record.len]) |*held| { + if (std.mem.eql(u8, held.name, name)) { + binding = held; + break; + } + } + const held = binding orelse return error.UnknownTexture; + // The request this binding may already hold is left alone until the + // replacement commits: `requestPending` supersedes it only once its + // own request is secured, and the cancel below runs after the + // backend accepted the texture. A hard failure therefore drops + // nothing. + var pin: ?[]const u8 = null; + const id = try resolveTexture(self, entity, name, texture, &pin); + errdefer if (pin) |key| { + self.assets.release(key); + self.allocator.free(key); + }; + self.renderer.setShaderTexture(record.id, name, id) catch |err| { + if (err == error.InvalidHandle) clearShaderMaterial(self, entity); + return @as(anyerror!void, err); + }; + if (held.catalog) |key| { + self.assets.release(key); + self.allocator.free(key); + } + held.catalog = pin; + cancelPendingBinding(self, entity, name, null); + } + + pub fn clearShaderMaterial(self: *Game, entity: Entity) void { + clearPending(self, self.active_world, entity); + const old = self.active_world.shader_materials.fetchRemove(entity) orelse return; + releaseRecord(self, self.active_world, old.value, .destroy); + if (comptime supported) if (self.ecs_backend.entityExists(entity)) if (self.ecs_backend.getComponent(entity, Game.SpriteComp)) |sprite| { + if (sprite.material.shader == old.value.id) { + sprite.material.shader = .none; + self.renderer.markVisualDirty(entity); + } + }; + } + + /// World-local ownership prevents entity-id collisions across scene worlds. + pub fn clearWorldShaderMaterials(self: *Game, world: *Game.World) void { + _ = retireWorldShaderMaterials(self, world, .destroy); + } + + /// Returns how many committed materials were retired. + fn retireWorldShaderMaterials(self: *Game, world: *Game.World, retire: Retire) usize { + while (world.shader_pending.count() != 0) { + var pending = world.shader_pending.keyIterator(); + clearPending(self, world, pending.next().?.*); + } + const retired = world.shader_materials.count(); + var it = world.shader_materials.iterator(); + while (it.next()) |entry| { + releaseRecord(self, world, entry.value_ptr.*, retire); + if (comptime supported) if (world.ecs_backend.entityExists(entry.key_ptr.*)) if (world.ecs_backend.getComponent(entry.key_ptr.*, Game.SpriteComp)) |sprite| { + sprite.material.shader = .none; + world.renderer.markVisualDirty(entry.key_ptr.*); + }; + } + world.shader_materials.clearRetainingCapacity(); + switch (retire) { + .destroy => if (comptime @hasDecl(Renderer, "clearShaderMaterials")) world.renderer.clearShaderMaterials(), + // The whole-context counterpart of `clearShaderMaterials` + // (labelle-gfx#361): a material with NO texture bindings is + // invisible to gfx's per-texture invalidation, so this is the + // only way it is retired on surface loss. A renderer that + // predates the seam only offers the destroying clear, which + // gfx documents as "call before context teardown" — the best + // that renderer can do, and no worse than before. + .invalidate => if (comptime @hasDecl(Renderer, "invalidateShaderMaterials")) + world.renderer.invalidateShaderMaterials() + else if (comptime @hasDecl(Renderer, "clearShaderMaterials")) + world.renderer.clearShaderMaterials(), + } + return retired; + } + + pub fn clearAllShaderMaterials(self: *Game) void { + clearWorldShaderMaterials(self, self.active_world); + var it = self.worlds.valueIterator(); + while (it.next()) |world| clearWorldShaderMaterials(self, world.*); + } + + /// Surface loss: forget every world's runtime materials WITHOUT a + /// backend destroy, release their catalog pins, and put every bound + /// sprite back on the plain draw path. The engine retains no + /// descriptor (they are borrowed for the create call), so it cannot + /// recreate; the game does, from its own definitions, after + /// `surfaceRestored`. Returns the count for the caller's diagnostic. + pub fn invalidateAllShaderMaterials(self: *Game) usize { + var n = retireWorldShaderMaterials(self, self.active_world, .invalidate); + var it = self.worlds.valueIterator(); + while (it.next()) |world| n += retireWorldShaderMaterials(self, world.*, .invalidate); + return n; + } + + pub fn reapShaderMaterials(self: *Game) void { + if (comptime !supported) return; + while (true) { + var pending = self.active_world.shader_pending.keyIterator(); + var dead: ?Entity = null; + while (pending.next()) |entity| if (!self.ecs_backend.entityExists(entity.*) or !self.ecs_backend.hasComponent(entity.*, Game.SpriteComp)) { + dead = entity.*; + break; + }; + clearPending(self, self.active_world, dead orelse break); + } + while (true) { + var it = self.active_world.shader_materials.iterator(); + var dead: ?Entity = null; + while (it.next()) |entry| { + const entity = entry.key_ptr.*; + const sprite = if (self.ecs_backend.entityExists(entity)) self.ecs_backend.getComponent(entity, Game.SpriteComp) else null; + if (!self.ecs_backend.entityExists(entity) or sprite == null or sprite.?.material.shader != entry.value_ptr.id) { + dead = entity; + break; + } + } + clearShaderMaterial(self, dead orelse break); + } + } + }; +} diff --git a/src/game/visuals.zig b/src/game/visuals.zig index bc7f43bd..0765514c 100644 --- a/src/game/visuals.zig +++ b/src/game/visuals.zig @@ -96,6 +96,7 @@ pub fn Mixin(comptime Game: type) type { } pub fn removeSprite(self: *Game, entity: Entity) void { + self.clearShaderMaterial(entity); self.renderer.untrackEntity(entity); self.ecs_backend.removeComponent(entity, Sprite); self.bumpRoster(); // membership changed (#653) @@ -329,6 +330,7 @@ pub fn Mixin(comptime Game: type) type { self.assertEntityAlive(entity, "setMaterial"); const sprite = self.ecs_backend.getComponent(entity, Sprite) orelse return; if (std.meta.eql(sprite.material, material)) return; + if (sprite.material.shader != material.shader) self.clearShaderMaterial(entity); sprite.material = material; self.renderer.markVisualDirty(entity); } diff --git a/src/game/world_mixin.zig b/src/game/world_mixin.zig index 3b671933..6cc7eb4a 100644 --- a/src/game/world_mixin.zig +++ b/src/game/world_mixin.zig @@ -38,11 +38,7 @@ pub fn Mixin(comptime Game: type) type { /// Destroy a named inactive world. Frees all its entities and visuals. pub fn destroyWorld(self: *Game, name: []const u8) void { if (self.worlds.fetchRemove(name)) |kv| { - // Its reservoirs die with it, so their catalog references are - // owed back — and this is the last moment the catalog is - // guaranteed alive (`World.deinit` cannot assume that; see - // the `water_assets` field comment). - self.releaseWorldWaterAssets(kv.value); + self.clearWorldShaderMaterials(kv.value); kv.value.deinit(); self.allocator.destroy(kv.value); self.allocator.free(kv.key); @@ -56,31 +52,12 @@ pub fn Mixin(comptime Game: type) type { // Remove target first — guarantees a free slot for shelving the current world const kv = self.worlds.fetchRemove(name) orelse return error.WorldNotFound; - // Water instance ids belong to the world's OWN renderer, and the - // table's keys to its own ECS (#100). Carrying them across the - // swap would hand the incoming renderer ids it never issued: an - // entity-id collision makes an old-world entry look like the new - // entity's instance, and a non-collision has the reaper release - // a stale id against the wrong renderer. Release them here, - // while `self.renderer` still points at the world that issued - // them; the water tick recreates them from the components when - // that world becomes active again. The ASSET references are - // deliberately kept — the shelved world's components still own - // them, and nothing would re-acquire on the way back. They are - // safe to keep because they live on the `World` itself, so they - // travel with the ECS whose ids key them and stay out of the - // incoming world's reaper. - self.clearPixelWaterInstances(); - // Shelve or destroy current active world if (self.active_world_name) |current_name| { // Named world — shelve into map (can't fail: we just freed a slot) self.worlds.put(current_name, self.active_world) catch @panic("OOM shelving world"); } else { - // Unnamed default world — destroy it. Unlike the shelved - // case its components do NOT live on, so the references they - // hold are released here, while the catalog is still alive. - self.releaseWorldWaterAssets(self.active_world); + self.clearWorldShaderMaterials(self.active_world); self.active_world.deinit(); self.allocator.destroy(self.active_world); } @@ -133,6 +110,7 @@ pub fn Mixin(comptime Game: type) type { } pub fn resetEcsBackend(self: *Game) void { + self.clearWorldShaderMaterials(self.active_world); // Tear down active world's fields (reverse of init order) self.gizmo_state.deinit(self.allocator); self.active_world.sprite_cache.deinit(); @@ -146,13 +124,6 @@ pub fn Mixin(comptime Game: type) type { // Free per-entity particle sims for the same reason (#750): the // ECS wipe invalidates every emitter entity id in the side-table. self.clearParticleSystems(); - // Water instances are keyed by entity; the ECS reset makes every - // key dangling, so release them before the wipe (#100). The - // catalog references go with them: unlike a world swap, the - // components themselves are about to be destroyed, so nothing - // is left to own them (and the incoming scene re-acquires). - self.clearPixelWaterInstances(); - self.releaseAllWaterAssets(); // Free every `ChildrenComponent`'s backing allocation before the // ECS is torn down: the backend drops components by value with no // destructor, so their heap-backed child lists would otherwise diff --git a/src/jsonc/component_apply.zig b/src/jsonc/component_apply.zig index b7087395..5d137ef8 100644 --- a/src/jsonc/component_apply.zig +++ b/src/jsonc/component_apply.zig @@ -34,7 +34,6 @@ const deserializer = @import("deserializer.zig"); const ref_resolver_mod = @import("ref_resolver.zig"); const uf = @import("unified_format.zig"); const ImageComp = @import("../image_component.zig").Image; -const PixelWaterComp = @import("../pixel_water.zig").PixelWater; pub fn ComponentApply(comptime GameType: type, comptime Components: type) type { const Entity = GameType.EntityType; @@ -180,23 +179,6 @@ pub fn ComponentApply(comptime GameType: type, comptime Components: type) type { } } - // PixelWater (COND-07, labelle-bgfx#100) — built-in. Unlike the - // other built-ins the apply does NOT go straight to - // `addComponent`: it routes through `game.addPixelWater`, which - // VALIDATES AND STAGES the candidate before committing either the - // component or the gfx-owned water instance. Committing the - // component here and synchronizing afterwards would leave a torn - // state on rejection — the component holding the new value while - // the instance still holds the old one, with nothing left to - // detect the divergence. Guarded `!Components.has("PixelWater")` - // exactly like the other built-ins. - if (comptime !Components.has("PixelWater")) { - if (std.mem.eql(u8, name, "PixelWater")) { - _ = applyPixelWater(game, entity, value); - return; - } - } - // All other components — comptime dispatch via // Components registry. const filtered = stripEntityArrayFields(value, game.allocator); @@ -339,25 +321,6 @@ pub fn ComponentApply(comptime GameType: type, comptime Components: type) type { return true; } - /// `PixelWater` → `game.addPixelWater` (COND-07). The generic struct - /// deserializer maps every authored field — including the - /// `[2]u32` `logical_size` (see the array branch in - /// `deserializer.zig`) and the sRGB hex colour strings, which stay - /// STRINGS on the component so the sRGB→linear conversion happens - /// exactly once, at the gfx seam. - /// - /// Returns `false` when the payload is malformed OR when validation - /// rejected it; in both cases the entity is untouched and a - /// field-named diagnostic has been logged. - pub fn applyPixelWater(game: *GameType, entity: Entity, value: Value) bool { - const comp_alloc = game.active_world.nested_entity_arena.allocator(); - const water = deserializer.deserialize(PixelWaterComp, value, comp_alloc) orelse { - game.log.err("PixelWater on entity {any}: malformed component payload", .{entity}); - return false; - }; - return game.addPixelWater(entity, water); - } - /// Strip fields that contain entity-like arrays from a /// component `Value`. The entity-array fields are spawned /// separately (see `spawnAndLinkNestedEntities` in the diff --git a/src/jsonc/deserializer.zig b/src/jsonc/deserializer.zig index 8f9b3562..10022bb2 100644 --- a/src/jsonc/deserializer.zig +++ b/src/jsonc/deserializer.zig @@ -84,6 +84,8 @@ fn internString(s: []const u8) ?[]const u8 { /// (typically `deserializeStruct`) treats `null` as "use default if /// available, else propagate failure to the parent struct." pub fn deserialize(comptime T: type, value: Value, allocator: std.mem.Allocator) ?T { + // A prefab cannot resurrect a backend generation-bearing runtime handle. + if (T == @import("labelle-core").shader_material.Id) return .none; const info = @typeInfo(T); // Optionals — JSONC `null` → component field stays `null`, @@ -141,11 +143,7 @@ pub fn deserialize(comptime T: type, value: Value, allocator: std.mem.Allocator) return internString(s); } - // Fixed-size arrays (`[2]u32` — `PixelWater.logical_size`, and any - // future `[N]T` authored as a JSON array). Length must match exactly: - // a short or long literal is an authoring mistake, and silently padding - // with zeros would turn `"logical_size": [96]` into a zero-height - // reservoir that fails validation somewhere far away from the typo. + // Authored fixed-size arrays require an exact length. if (info == .array) { const arr = value.asArray() orelse return null; if (arr.items.len != info.array.len) return null; diff --git a/src/jsonc/scene_loader/prefab_refresh.zig b/src/jsonc/scene_loader/prefab_refresh.zig index 1eeff903..6578471c 100644 --- a/src/jsonc/scene_loader/prefab_refresh.zig +++ b/src/jsonc/scene_loader/prefab_refresh.zig @@ -372,27 +372,6 @@ pub fn PrefabRefresh(comptime GameType: type, comptime Components: type) type { /// `onReady`/`postLoad` fire exactly like a Phase 1 respawn /// would. fn applyTransient(game: *GameType, entity: Entity, name: []const u8, value: Value) void { - if (comptime water_is_builtin) { - if (std.mem.eql(u8, name, "PixelWater")) { - // Straight through the SAME `applyPixelWater` the scene - // load and the script contract use, so a pushed prefab - // re-authors the live component and the gfx instance - // through `addPixelWater`'s stage-before-commit path - // (structural edit → instance dropped and recreated from - // the new component; otherwise the validated - // settings/level writes). A rejected candidate leaves - // BOTH sides on the previous configuration, exactly as on - // every other authoring boundary. - // - // No `preserveEntityRefs` / `fireOnReadyByName` here: - // `PixelWater` declares no entity-ref fields, and both - // helpers are registry-name-driven — a built-in is - // invisible to them by construction. - _ = ApplyHelpers.applyPixelWater(game, entity, value); - return; - } - } - const comp_names = comptime Components.names(); inline for (comp_names) |comp_name| { if (std.mem.eql(u8, name, comp_name)) { @@ -409,22 +388,6 @@ pub fn PrefabRefresh(comptime GameType: type, comptime Components: type) type { } fn removeTransient(game: *GameType, entity: Entity, name: []const u8) void { - if (comptime water_is_builtin) { - if (std.mem.eql(u8, name, "PixelWater")) { - if (game.ecs_backend.getComponent(entity, GameType.PixelWaterComp) != null) { - game.removeComponent(entity, GameType.PixelWaterComp); - // Drop the gfx instance and the catalog references - // NOW instead of leaving them to the water tick's - // reaper: the reaper only runs while some reservoir - // still drives the tick, so dropping the LAST - // reservoir in a prefab would otherwise keep its - // instance drawing and its mask pinned indefinitely. - game.releasePixelWater(entity); - } - return; - } - } - const comp_names = comptime Components.names(); inline for (comp_names) |comp_name| { if (std.mem.eql(u8, name, comp_name)) { @@ -439,22 +402,6 @@ pub fn PrefabRefresh(comptime GameType: type, comptime Components: type) type { } } - /// `PixelWater` is a JSONC BUILT-IN: `component_apply.zig` routes it - /// through `applyPixelWater` before its registry loop, and it is - /// deliberately absent from `Components.names()`. The two dispatch - /// helpers below iterate exactly that registry, so without this gate - /// a pushed prefab's reservoir edit would silently do nothing until a - /// full respawn. The precedence test is the SAME one the apply path - /// uses — a project-registered `PixelWater` compiles this branch out - /// and takes the generic registry route instead. - /// - /// The `isTransient` half keeps the refresh's scope contract honest - /// by construction: if the component's save policy ever stops being - /// `.transient`, the refresh stops touching it, like every registry - /// component. - const water_is_builtin = !Components.has("PixelWater") and - isTransient(GameType.PixelWaterComp); - fn isTransient(comptime T: type) bool { if (@typeInfo(T) != .@"struct") return false; return (core.getSavePolicy(T) orelse .saveable) == .transient; diff --git a/src/pixel_water.zig b/src/pixel_water.zig deleted file mode 100644 index ca4cfdfd..00000000 --- a/src/pixel_water.zig +++ /dev/null @@ -1,381 +0,0 @@ -//! `PixelWater` — the engine-side authoring component for the built-in -//! `MaterialEffect.pixel_water` reservoir effect (COND-07, labelle-bgfx#100, -//! RFC-PIXEL-WATER phase 5). -//! -//! This module is the AUTHORING half. It owns: -//! -//! * `PixelWater` — the flat ECS component a scene / prefab authors, shaped -//! exactly like the RFC's "Proposed authoring model" block. -//! * `PixelWaterSettings` — the public, backend-independent value -//! `game.setWaterSettings` takes: the NON-structural subset (waves, -//! distortion, reflection opacity, colour ramps, ripple appearance). -//! * Validation with a named field + reason, so a bad scene value reports -//! `PixelWater on entity 7: field 'ripple_radius_pixels' must be > 0` -//! instead of a GPU-side mystery. -//! * The ONE sRGB→linear conversion (`srgbToLinear`). -//! -//! It deliberately owns NO runtime state. Ripple expiry, the eight-impact cap -//! and the deterministic oldest-replacement live in labelle-gfx's gfx-owned -//! water-instance store (`pixel_water.zig` there); the engine delegates. -//! -//! ── Why colours are authored as HEX STRINGS, here and at runtime ────────── -//! -//! `PixelWaterRgba` at the backend seam is LINEAR 0..1. The authored value is -//! sRGB hex. Converting in two places invites the double-gamma bug the RFC -//! warns about, so the rule here is: the component and `PixelWaterSettings` -//! both carry the AUTHORED sRGB hex string, and the single conversion happens -//! once, at the `toWaterConfig` seam where the gfx `WaterConfig` is built. -//! There is no other place in the engine that touches these channels, so no -//! path can convert twice — and a game's runtime `setWaterSettings` uses the -//! very same representation the scene authored, which is what makes the -//! "JSON / ZON / runtime produce identical settings" test meaningful rather -//! than a coincidence of three separate parsers agreeing. -//! -//! String lifetime: the hex and asset-name slices are borrowed, exactly like -//! `Sprite.sprite_name` / `Image.name`. Scene-authored values live in the -//! world's nested-entity arena (or the deserializer's intern pool); a runtime -//! caller passes a string literal or something that outlives the component. - -const std = @import("std"); -const save_policy = @import("labelle-core").save_policy; - -// ── Colour ────────────────────────────────────────────────────────────── - -/// An sRGB, byte-per-channel authored colour — the parsed form of a -/// `"#RRGGBB"` / `"#RRGGBBAA"` authoring string. Public so a game can -/// pre-validate a colour, or build one without going through hex. -pub const PixelWaterColor = struct { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, - a: u8 = 255, -}; - -pub const HexColorError = error{InvalidHexColor}; - -/// Parse `"#RRGGBB"` or `"#RRGGBBAA"` (the leading `#` optional) into sRGB -/// bytes. Strict: any other length, or a non-hex digit, is an error the -/// caller turns into a field-named diagnostic. No 3-digit shorthand — the -/// RFC's authored palette is 6-digit and a silent shorthand expansion is one -/// more way for two authoring paths to disagree. -pub fn parseHexColor(text: []const u8) HexColorError!PixelWaterColor { - var s = text; - if (s.len > 0 and s[0] == '#') s = s[1..]; - if (s.len != 6 and s.len != 8) return error.InvalidHexColor; - - var out: PixelWaterColor = .{}; - const channels = [_]*u8{ &out.r, &out.g, &out.b, &out.a }; - var i: usize = 0; - while (i * 2 < s.len) : (i += 1) { - const hi = hexDigit(s[i * 2]) orelse return error.InvalidHexColor; - const lo = hexDigit(s[i * 2 + 1]) orelse return error.InvalidHexColor; - channels[i].* = (hi << 4) | lo; - } - return out; -} - -fn hexDigit(c: u8) ?u8 { - return switch (c) { - '0'...'9' => c - '0', - 'a'...'f' => c - 'a' + 10, - 'A'...'F' => c - 'A' + 10, - else => null, - }; -} - -/// The IEC 61966-2-1 sRGB electro-optical transfer function, applied to one -/// 0..255 authored channel to produce the linear 0..1 value the shader wants. -/// -/// THE single gamma conversion in the engine's water path. Alpha is NOT put -/// through it (alpha is linear coverage, never gamma-encoded) — see -/// `toLinearRgba`. -pub fn srgbToLinear(channel: u8) f32 { - const c = @as(f32, @floatFromInt(channel)) / 255.0; - if (c <= 0.04045) return c / 12.92; - return std.math.pow(f32, (c + 0.055) / 1.055, 2.4); -} - -/// Convert an authored sRGB colour into `Rgba`'s linear 0..1 channels. -/// `Rgba` is passed in as a type parameter rather than imported from -/// labelle-core so this module — like the rest of the engine — keeps no -/// compile-time dependency on the renderer's contract surface: the concrete -/// type is read off the renderer's own `WaterConfig` at the call site. -pub fn toLinearRgba(comptime Rgba: type, c: PixelWaterColor) Rgba { - return .{ - .r = srgbToLinear(c.r), - .g = srgbToLinear(c.g), - .b = srgbToLinear(c.b), - // Alpha is coverage, not light: linear already. Running it through - // the EOTF is the classic double-gamma-on-alpha bug. - .a = @as(f32, @floatFromInt(c.a)) / 255.0, - }; -} - -// ── Settings (the non-structural, runtime-settable subset) ─────────────── - -/// The value `game.setWaterSettings` takes: everything the RFC lists as -/// runtime-settable — waves, distortion, reflection opacity, colour ramps and -/// ripple appearance. -/// -/// Explicitly NOT here: `mask` / `reflection` / `logical_size` / `grid_pixels` -/// (structural — they change through explicit reconfiguration) and -/// `water_level` (its own setter, with its own ripple-clearing semantics). -pub const PixelWaterSettings = struct { - deep_color: []const u8 = "#0E1C24", - surface_color: []const u8 = "#2D4E5B", - highlight_color: []const u8 = "#9FC0BC", - - wave_amplitude_pixels: f32 = 0, - wave_period_seconds: f32 = 3, - /// Toggles waves without destroying the authored amplitude (which - /// setting the amplitude to zero would). - waves_enabled: bool = true, - - reflection_opacity: f32 = 0, - distortion_pixels: f32 = 0, - - ripple_duration_seconds: f32 = 0.8, - ripple_radius_pixels: f32 = 6, - /// AUTHORED peak displacement, in native art pixels, of a - /// full-strength impact. Nonnegative. The runtime `addWaterRipple` - /// strength is a DIMENSIONLESS [0,1] scale on this — see - /// `validateRippleStrength`. - ripple_strength_pixels: f32 = 1, -}; - -/// The authored component. Flat, mirroring the RFC's JSON block 1:1 so a -/// scene key and a field name are the same word. -pub const PixelWater = struct { - /// Transient: the reservoir's authored configuration is re-derived from - /// the scene/prefab on load, and the live instance (simulation time, - /// active impacts) is presentation state that resets cleanly. Same policy - /// as `Emitter` / `SpriteAnimation`. - pub const save = save_policy.Saveable(.transient, @This(), .{}); - - /// Reservoir silhouette mask — an `AssetCatalog` standalone-image key. - /// REQUIRED: without it the effect is unrenderable. - mask: []const u8 = "", - /// Supplied, pre-authored reflection texture. Optional. - reflection: []const u8 = "", - /// Reservoir rectangle in native art pixels, `[width, height]`. - logical_size: [2]u32 = .{ 0, 0 }, - /// Native art pixels per effect cell. - grid_pixels: u32 = 1, - /// Initial fill fraction from the bottom, [0, 1]. - water_level: f32 = 0, - - deep_color: []const u8 = "#0E1C24", - surface_color: []const u8 = "#2D4E5B", - highlight_color: []const u8 = "#9FC0BC", - - wave_amplitude_pixels: f32 = 0, - wave_period_seconds: f32 = 3, - waves_enabled: bool = true, - - reflection_opacity: f32 = 0, - distortion_pixels: f32 = 0, - - ripple_duration_seconds: f32 = 0.8, - ripple_radius_pixels: f32 = 6, - ripple_strength_pixels: f32 = 1, - - /// The non-structural subset, as a `PixelWaterSettings`. - pub fn settings(self: PixelWater) PixelWaterSettings { - return .{ - .deep_color = self.deep_color, - .surface_color = self.surface_color, - .highlight_color = self.highlight_color, - .wave_amplitude_pixels = self.wave_amplitude_pixels, - .wave_period_seconds = self.wave_period_seconds, - .waves_enabled = self.waves_enabled, - .reflection_opacity = self.reflection_opacity, - .distortion_pixels = self.distortion_pixels, - .ripple_duration_seconds = self.ripple_duration_seconds, - .ripple_radius_pixels = self.ripple_radius_pixels, - .ripple_strength_pixels = self.ripple_strength_pixels, - }; - } - - /// Overlay `s` onto the component's non-structural fields. Pure — the - /// caller decides when (and whether) to commit the result, which is what - /// makes stage-before-commit expressible: `setWaterSettings` builds the - /// candidate with this, validates and pushes it to gfx, and only then - /// writes the candidate back over the live component. - pub fn withSettings(self: PixelWater, s: PixelWaterSettings) PixelWater { - var out = self; - out.deep_color = s.deep_color; - out.surface_color = s.surface_color; - out.highlight_color = s.highlight_color; - out.wave_amplitude_pixels = s.wave_amplitude_pixels; - out.wave_period_seconds = s.wave_period_seconds; - out.waves_enabled = s.waves_enabled; - out.reflection_opacity = s.reflection_opacity; - out.distortion_pixels = s.distortion_pixels; - out.ripple_duration_seconds = s.ripple_duration_seconds; - out.ripple_radius_pixels = s.ripple_radius_pixels; - out.ripple_strength_pixels = s.ripple_strength_pixels; - return out; - } -}; - -// ── Validation ────────────────────────────────────────────────────────── - -/// Which authored field a diagnostic is about. Names match the scene keys -/// exactly, so `@tagName` is the string an author can search their `.jsonc` -/// for. -pub const PixelWaterField = enum { - mask, - reflection, - logical_size, - grid_pixels, - water_level, - deep_color, - surface_color, - highlight_color, - wave_amplitude_pixels, - wave_period_seconds, - reflection_opacity, - distortion_pixels, - ripple_duration_seconds, - ripple_radius_pixels, - ripple_strength_pixels, - /// The runtime `addWaterRipple` strength argument. Not an authored - /// field — it shares the diagnostic channel because it is the OTHER - /// half of the bounded-strength rule. - strength, - /// The runtime `addWaterRipple` local-X argument. - ripple_x, -}; - -/// Why a value was rejected. Rendered into the diagnostic verbatim. -pub const PixelWaterReason = enum { - missing, - non_finite, - negative, - not_positive, - out_of_unit_range, - invalid_hex, - - pub fn text(self: PixelWaterReason) []const u8 { - return switch (self) { - .missing => "is required and must not be empty", - .non_finite => "must be finite (NaN / Infinity rejected)", - .negative => "must be >= 0", - .not_positive => "must be > 0", - .out_of_unit_range => "must be within [0, 1]", - .invalid_hex => "must be an sRGB hex colour (\"#RRGGBB\" or \"#RRGGBBAA\")", - }; - } -}; - -pub const PixelWaterIssue = struct { - field: PixelWaterField, - reason: PixelWaterReason, -}; - -/// Error set the game-facing helpers return. Deliberately coarse: the -/// ACTIONABLE detail is the logged `PixelWaterIssue` (field + reason), which -/// an error tag could never carry. -pub const PixelWaterError = error{ - /// An authored or runtime settings value failed `validateSettings` / - /// `validateComponent`. Nothing was committed on either side. - InvalidPixelWater, - /// The entity carries no `PixelWater` component. - NoPixelWater, - /// The renderer rejected the staged configuration (stale instance, - /// or its own validation disagreed). Nothing was committed on either - /// side. - PixelWaterRejected, -}; - -fn finite(v: f32) bool { - return std.math.isFinite(v); -} - -fn checkColor(field: PixelWaterField, hex: []const u8) ?PixelWaterIssue { - _ = parseHexColor(hex) catch return .{ .field = field, .reason = .invalid_hex }; - return null; -} - -/// Validate the runtime-settable subset. Returns the FIRST offending field, -/// or `null` when the value is acceptable. -/// -/// `ripple_strength_pixels` is checked here as a NONNEGATIVE PIXEL AMPLITUDE. -/// Its runtime counterpart — the dimensionless [0,1] scale passed to -/// `addWaterRipple` — is checked by `validateRippleStrength`. Both boundaries -/// are enforced: a bound on only one of them leaves the other as the way -/// around it (RFC §"Runtime state and API"). -pub fn validateSettings(s: PixelWaterSettings) ?PixelWaterIssue { - if (checkColor(.deep_color, s.deep_color)) |i| return i; - if (checkColor(.surface_color, s.surface_color)) |i| return i; - if (checkColor(.highlight_color, s.highlight_color)) |i| return i; - - if (!finite(s.wave_amplitude_pixels)) return .{ .field = .wave_amplitude_pixels, .reason = .non_finite }; - if (s.wave_amplitude_pixels < 0) return .{ .field = .wave_amplitude_pixels, .reason = .negative }; - - if (!finite(s.wave_period_seconds)) return .{ .field = .wave_period_seconds, .reason = .non_finite }; - if (s.wave_period_seconds <= 0) return .{ .field = .wave_period_seconds, .reason = .not_positive }; - - if (!finite(s.reflection_opacity)) return .{ .field = .reflection_opacity, .reason = .non_finite }; - if (s.reflection_opacity < 0 or s.reflection_opacity > 1) { - return .{ .field = .reflection_opacity, .reason = .out_of_unit_range }; - } - - if (!finite(s.distortion_pixels)) return .{ .field = .distortion_pixels, .reason = .non_finite }; - if (s.distortion_pixels < 0) return .{ .field = .distortion_pixels, .reason = .negative }; - - if (!finite(s.ripple_duration_seconds)) return .{ .field = .ripple_duration_seconds, .reason = .non_finite }; - if (s.ripple_duration_seconds <= 0) return .{ .field = .ripple_duration_seconds, .reason = .not_positive }; - - if (!finite(s.ripple_radius_pixels)) return .{ .field = .ripple_radius_pixels, .reason = .non_finite }; - if (s.ripple_radius_pixels <= 0) return .{ .field = .ripple_radius_pixels, .reason = .not_positive }; - - // BOUNDARY 1 of the bounded-strength rule: the authored pixel amplitude. - if (!finite(s.ripple_strength_pixels)) return .{ .field = .ripple_strength_pixels, .reason = .non_finite }; - if (s.ripple_strength_pixels < 0) return .{ .field = .ripple_strength_pixels, .reason = .negative }; - - return null; -} - -/// Validate the whole authored component: the structural fields plus -/// `validateSettings`. -pub fn validateComponent(c: PixelWater) ?PixelWaterIssue { - if (c.mask.len == 0) return .{ .field = .mask, .reason = .missing }; - if (c.logical_size[0] == 0 or c.logical_size[1] == 0) { - return .{ .field = .logical_size, .reason = .not_positive }; - } - if (c.grid_pixels == 0) return .{ .field = .grid_pixels, .reason = .not_positive }; - - if (!finite(c.water_level)) return .{ .field = .water_level, .reason = .non_finite }; - if (c.water_level < 0 or c.water_level > 1) { - return .{ .field = .water_level, .reason = .out_of_unit_range }; - } - - return validateSettings(c.settings()); -} - -/// BOUNDARY 2 of the bounded-strength rule. -/// -/// The runtime `strength` handed to `addWaterRipple` is a DIMENSIONLESS -/// magnitude in [0, 1] that SCALES the authored `ripple_strength_pixels`. It -/// is not itself a pixel amplitude and it is not signed. Rejecting -/// non-finite, negative and > 1 here is what makes peak displacement at most -/// `ripple_strength_pixels` by construction, whatever a caller passes — the -/// authored setting stays the one place the effect's visual scale is retuned. -pub fn validateRippleStrength(strength: f32) ?PixelWaterIssue { - if (!finite(strength)) return .{ .field = .strength, .reason = .non_finite }; - if (strength < 0) return .{ .field = .strength, .reason = .negative }; - if (strength > 1) return .{ .field = .strength, .reason = .out_of_unit_range }; - return null; -} - -/// Runtime level updates clamp to [0, 1] but reject NaN/Infinity -/// (RFC §"Proposed authoring model"). -pub fn validateLevel(level: f32) ?PixelWaterIssue { - if (!finite(level)) return .{ .field = .water_level, .reason = .non_finite }; - return null; -} - -// Coverage lives in `test/pixel_water_test.zig` — per the engine convention, -// `src/*.zig` test blocks aren't reached by build.zig's cross-module test -// import. diff --git a/src/pixel_water_tick.zig b/src/pixel_water_tick.zig deleted file mode 100644 index d5131499..00000000 --- a/src/pixel_water_tick.zig +++ /dev/null @@ -1,52 +0,0 @@ -//! Pixel-water ECS tick — the per-frame half of the `PixelWater` pairing -//! (`src/pixel_water.zig` is the type), following the engine's "one type + -//! one tick fn, paired" convention. -//! -//! Two jobs per frame, in this order: -//! -//! 1. REAP + RESOLVE. Drop instances whose component is gone (entity -//! destroyed — `game.destroyEntity` cascades to children, so one destroy -//! can orphan several — or the component removed), then create the gfx -//! instance for any reservoir whose mask has since become resident. That -//! second half is why a streaming mask needs no retry timer: the tick IS -//! the retry, and it is idempotent. -//! -//! 2. ADVANCE SIMULATION TIME. `advanceWaterTime(id, dt)` with the dt the -//! caller passed — which is `loop_mixin`'s TIME-SCALED delta, so a hard -//! pause (`time_scale == 0`) freezes the water and slow-mo slows it. The -//! shader's clock therefore comes from the engine's simulation step and -//! NEVER from a wall clock, which is what makes a water test -//! deterministic (RFC §"Runtime state and API"). -//! -//! Folds to nothing on a renderer without labelle-gfx#359's water seam. - -const std = @import("std"); -const pixel_water = @import("pixel_water.zig"); -const pixel_water_mixin = @import("game/pixel_water_mixin.zig"); - -const PixelWater = pixel_water.PixelWater; - -/// Advance every reservoir by `dt` simulation seconds, after reaping orphans -/// and resolving newly-resident masks. Called from `loop_mixin.tick` when -/// `drive_pixel_water` is set. -/// -/// `dt == 0` still runs the reap/resolve half (a paused frame must still be -/// able to bind a mask that just finished uploading) but advances no clock — -/// gfx's `advanceTime` is itself a no-op on a zero delta. -pub fn tick(game: anytype, dt: f32) void { - const Game = @TypeOf(game.*); - if (comptime !pixel_water_mixin.rendererSupportsWater(Game.RendererType)) return; - - // Instances whose component vanished (destroy / removeComponent). - game.reapGhostPixelWater(); - - var view = game.ecs_backend.view(.{PixelWater}, .{}); - defer view.deinit(); - - while (view.next()) |entity| { - // Idempotent: returns immediately once an instance exists. - if (!game.resolvePixelWaterInstance(entity)) continue; - const id = game.waterInstance(entity) orelse continue; - game.renderer.advanceWaterTime(id, dt) catch {}; - } -} diff --git a/src/root.zig b/src/root.zig index 9044d238..a93c1db3 100644 --- a/src/root.zig +++ b/src/root.zig @@ -910,39 +910,12 @@ pub const Emitter = emitter_mod.Emitter; pub const EmitterPreset = emitter_mod.EmitterPreset; pub const particles_tick = @import("particles_tick.zig"); -// ── Pixel water (COND-07, labelle-bgfx#100, RFC-PIXEL-WATER) ── -// -// The built-in reservoir effect's AUTHORING surface. `PixelWater` is an -// engine built-in component (like `Emitter` / `Image` / `Camera`) — the scene -// loader recognizes it and auto-enables the water phase; it is NOT a -// `ComponentRegistry` entry. -// -// A game reaches the runtime through `game.setWaterSettings` / -// `game.setWaterLevel` / `game.addWaterRipple`, all of which delegate the -// animated state (time, level, the eight live impacts, expiry and the -// deterministic oldest-replacement at capacity) to labelle-gfx's gfx-owned -// water-instance store. The engine's own contributions are asset resolution + -// instance lifetime, validation with entity/field-named diagnostics, and the -// stage-before-commit synchronization rule. -// -// `PixelWaterSettings` is the public value `setWaterSettings` takes; colours -// stay AUTHORED sRGB hex on both the component and the settings so the -// sRGB→linear conversion happens exactly once, at the gfx seam (no -// double-gamma). -pub const pixel_water_mod = @import("pixel_water.zig"); -pub const PixelWater = pixel_water_mod.PixelWater; -pub const PixelWaterSettings = pixel_water_mod.PixelWaterSettings; -pub const PixelWaterColor = pixel_water_mod.PixelWaterColor; -pub const PixelWaterField = pixel_water_mod.PixelWaterField; -pub const PixelWaterReason = pixel_water_mod.PixelWaterReason; -pub const PixelWaterIssue = pixel_water_mod.PixelWaterIssue; -pub const PixelWaterError = pixel_water_mod.PixelWaterError; -pub const parseHexColor = pixel_water_mod.parseHexColor; -pub const srgbToLinear = pixel_water_mod.srgbToLinear; -pub const validatePixelWaterComponent = pixel_water_mod.validateComponent; -pub const validatePixelWaterSettings = pixel_water_mod.validateSettings; -pub const validatePixelWaterRippleStrength = pixel_water_mod.validateRippleStrength; -pub const pixel_water_tick = @import("pixel_water_tick.zig"); +// ── Game-owned shader materials ── +pub const shader_material = @import("shader_material.zig"); +pub const ShaderMaterialDescriptor = shader_material.Descriptor; +pub const ShaderTextureBinding = shader_material.TextureBinding; +pub const ShaderTexture = shader_material.Texture; +pub const ShaderMaterialId = shader_material.contract.Id; // ── Atlas ── pub const SpriteData = atlas_mod.SpriteData; diff --git a/src/shader_material.zig b/src/shader_material.zig new file mode 100644 index 00000000..e13890b7 --- /dev/null +++ b/src/shader_material.zig @@ -0,0 +1,25 @@ +//! Game-owned effects bind generic materials; catalog pins belong to the engine. +const core = @import("labelle-core"); +pub const contract = core.shader_material; +pub const Texture = union(enum) { catalog: []const u8, id: core.TextureId }; +pub const TextureBinding = struct { + name: [:0]const u8, + texture: Texture, + sampler: contract.Sampler = .point, +}; +pub const Descriptor = struct { + version: u32 = contract.VERSION, + label: []const u8 = "", + shaders: contract.ShaderVariants, + parameters: []const contract.Parameter = &.{}, + textures: []const TextureBinding = &.{}, + blend: contract.Blend = .alpha, +}; +pub const HeldTexture = struct { name: []const u8, catalog: ?[]const u8 = null }; +pub const Record = struct { + id: contract.Id, + textures: [contract.MAX_TEXTURES]HeldTexture = undefined, + len: usize = 0, +}; + +pub const Pending = struct { name: []const u8, key: []const u8 }; diff --git a/test/jsonc/deserializer_test.zig b/test/jsonc/deserializer_test.zig index 61b53562..3df3f8dd 100644 --- a/test/jsonc/deserializer_test.zig +++ b/test/jsonc/deserializer_test.zig @@ -44,7 +44,7 @@ test "deserialize: integer to u8 (out of range returns null)" { // (`1e100`, `NaN`, `-Infinity`) was illegal behaviour: a trap in safety // builds, which TERMINATES scene loading instead of the component's own // apply reporting a malformed payload. Reported by codex on #880 against -// `PixelWater.logical_size: [1e100, 18]`, but the branch is shared by +// `logical_size: [1e100, 18]`, but the branch is shared by // every integer field of every component, so it is fixed generally here. // // Two halves to each of these: the wild value must come back `null` (the @@ -90,7 +90,7 @@ test "deserialize: float to integer keeps truncation, negatives and the i64 boun test "deserialize: an out-of-range float inside logical_size fails the component, not the process" { // The reported shape, end to end: `[2]u32` recurses per element, so the // wild element must fail the ARRAY, which (no default on a rejected - // field) leaves `applyPixelWater` reporting a malformed payload. + // field) leaves component application reporting a malformed payload. var wild = [_]SceneValue{ .{ .float = 1e100 }, .{ .integer = 18 } }; try testing.expect(deserializer.deserialize([2]u32, SceneValue{ .array = .{ .items = &wild } }, testing.allocator) == null); diff --git a/test/pixel_water_test.zig b/test/pixel_water_test.zig deleted file mode 100644 index 148fdaad..00000000 --- a/test/pixel_water_test.zig +++ /dev/null @@ -1,1587 +0,0 @@ -//! `PixelWater` — the engine's authoring + runtime half of the built-in -//! pixel-water material (COND-07, labelle-bgfx#100, RFC-PIXEL-WATER phase 5). -//! -//! WHAT THESE TESTS ARE FOR, AND WHAT THEY DELIBERATELY ARE NOT. -//! -//! Ripple expiry, the eight-slot cap and the deterministic oldest-replacement -//! at capacity live in labelle-gfx's water-instance store and are tested -//! there. Re-testing them against the mock below would only prove the mock. -//! What is tested here is everything the ENGINE owns and gfx cannot: -//! -//! * authoring parity — JSONC and comptime `.zon` land identical settings, -//! * validation, with the entity and the offending field named, -//! * the bounded-strength rule at BOTH boundaries (authored pixel amplitude -//! and runtime dimensionless scale), -//! * stage-before-commit — a rejected update leaves the component AND the -//! gfx instance on the previous settings, -//! * simulation time arriving from the engine's time-scaled step, so pause -//! and slow-mo apply and nothing reads a wall clock, -//! * instance lifetime across destroy / scene reset / re-author, -//! * the public API being reachable through `engine.*`. -//! -//! ASSERTING THE MECHANISM. Several of these could be made to pass by a -//! fallback that happens to produce the same value — a "component unchanged" -//! assertion, for instance, is satisfied both by a correct rejection and by an -//! update that never ran at all. So the mock renderer COUNTS every call it -//! receives, and the tests assert which path ran (`set_settings_calls`, -//! `ripple_calls`, `advance_calls`, `release_calls`) alongside the value. -//! -//! The renderer is a local mock rather than the real labelle-gfx one on -//! purpose: the engine takes no gfx dependency, and the whole water seam is -//! reached by `@hasDecl`. Compiling `NoWaterGame` at the bottom of this file -//! IS the assertion that a renderer without the seam still builds. - -const std = @import("std"); -const testing = std.testing; -const core = @import("labelle-core"); -const engine = @import("engine"); - -const Material = core.backend_contract.Material; -const MockEcs = core.MockEcsBackend(u32); -const PixelWater = engine.PixelWater; -const PixelWaterSettings = engine.PixelWaterSettings; - -/// Whether the pinned labelle-core carries `MaterialEffect.pixel_water` -/// (labelle-core#78). The engine's binding is `@hasField`-gated so it compiles -/// against either pin; this mirror lets the test assert the RIGHT thing on -/// each — the effect tag when it exists, the untouched `.none` fallback when -/// it does not — instead of being silently skipped. -const has_pixel_water_effect = @hasField(core.backend_contract.MaterialEffect, "pixel_water"); - -// ── Mock renderer with the labelle-gfx#359 water seam ─────────────────── - -/// Mirrors the shape of gfx's `WaterInstanceId`: generational, with -/// `generation == 0` reserved for "none", so the all-zero default IS `.none`. -const MockWaterId = struct { - index: u32 = 0, - generation: u32 = 0, - pub const none: MockWaterId = .{}; - pub fn isNone(self: MockWaterId) bool { - return self.generation == 0; - } -}; - -const MockRgba = struct { r: f32 = 0, g: f32 = 0, b: f32 = 0, a: f32 = 0 }; - -/// Field-for-field the subset of gfx's `WaterConfig` the engine writes. -const MockWaterConfig = struct { - mask: u32 = 0, - reflection: u32 = 0, - logical_width: u32 = 0, - logical_height: u32 = 0, - grid_pixels: u32 = 1, - deep: MockRgba = .{}, - surface: MockRgba = .{}, - highlight: MockRgba = .{}, - wave_amplitude_pixels: f32 = 0, - wave_period_seconds: f32 = 1, - waves_enabled: bool = true, - distortion_pixels: f32 = 0, - reflection_opacity: f32 = 0, - ripple_duration_seconds: f32 = 0.8, - ripple_radius_pixels: f32 = 6, - ripple_strength_pixels: f32 = 1, -}; - -const MockWaterState = struct { - config: MockWaterConfig = .{}, - level: f32 = 0, - time: f32 = 0, - ripple_count: u32 = 0, - live: bool = false, - generation: u32 = 1, -}; - -fn WaterRenderer(comptime Entity: type) type { - return struct { - const Self = @This(); - - pub const Sprite = struct { - sprite_name: []const u8 = "", - visible: bool = true, - z_index: i16 = 0, - material: Material = .{}, - water: MockWaterId = .none, - layer: enum { default } = .default, - }; - - pub const Shape = struct { - visible: bool = true, - z_index: i16 = 0, - layer: enum { default } = .default, - }; - - // ── The water seam the engine detects via @hasDecl ── - pub const WaterInstanceId = MockWaterId; - pub const WaterConfig = MockWaterConfig; - pub const WaterState = MockWaterState; - - visual_dirty_count: usize = 0, - - slots: [8]MockWaterState = [_]MockWaterState{.{}} ** 8, - live_count: usize = 0, - - // Call counters — the "which path ran" half of every assertion. - create_calls: usize = 0, - release_calls: usize = 0, - set_settings_calls: usize = 0, - set_level_calls: usize = 0, - ripple_calls: usize = 0, - advance_calls: usize = 0, - /// Sum of every dt handed to `advanceWaterTime`. Proves the engine's - /// time-scaled delta is what reaches the simulation. - advanced_dt_total: f32 = 0, - - /// Simulates gfx's own validation rejecting a candidate — the second - /// of the two stage-before-commit rejection paths. - reject_settings: bool = false, - /// Simulates the instance store being out of room. - fail_create: bool = false, - /// Simulates gfx refusing a level write — the failure mode that - /// used to leave a live instance at the default level while the - /// component read the authored one. - fail_level: bool = false, - - pub fn init(_: std.mem.Allocator) Self { - return .{}; - } - pub fn deinit(_: *Self) void {} - pub fn trackEntity(_: *Self, _: Entity, _: core.VisualType) void {} - pub fn untrackEntity(_: *Self, _: Entity) void {} - pub fn markPositionDirty(_: *Self, _: Entity) void {} - pub fn markPositionDirtyWithChildren(_: *Self, comptime _: type, _: anytype, _: Entity) void {} - pub fn updateHierarchyFlag(_: *Self, _: Entity, _: bool) void {} - pub fn markVisualDirty(self: *Self, _: Entity) void { - self.visual_dirty_count += 1; - } - pub fn sync(_: *Self, comptime _: type, _: anytype) void {} - pub fn render(_: *Self) void {} - pub fn setScreenHeight(_: *Self, _: f32) void {} - pub fn clear(_: *Self) void {} - pub fn renderGizmoDraws(_: *Self, _: []const core.GizmoDraw) void {} - pub fn hasEntity(_: *const Self, _: Entity) bool { - return false; - } - - fn slot(self: *Self, id: MockWaterId) ?*MockWaterState { - if (id.generation == 0 or id.index >= self.slots.len) return null; - const s = &self.slots[id.index]; - if (!s.live or s.generation != id.generation) return null; - return s; - } - - pub fn createWaterInstance(self: *Self, config: MockWaterConfig) !MockWaterId { - self.create_calls += 1; - if (self.fail_create) return error.OutOfMemory; - for (&self.slots, 0..) |*s, i| { - if (s.live) continue; - s.* = .{ .config = config, .live = true, .generation = s.generation }; - self.live_count += 1; - return .{ .index = @intCast(i), .generation = s.generation }; - } - return error.OutOfMemory; - } - - pub fn releaseWaterInstance(self: *Self, id: MockWaterId) bool { - const s = self.slot(id) orelse return false; - const gen = s.generation +% 1; - s.* = .{ .generation = if (gen == 0) 1 else gen }; - self.live_count -= 1; - self.release_calls += 1; - return true; - } - - pub fn waterInstanceCount(self: *const Self) usize { - return self.live_count; - } - - pub fn waterState(self: *const Self, id: MockWaterId) ?*const MockWaterState { - if (id.generation == 0 or id.index >= self.slots.len) return null; - const s = &self.slots[id.index]; - if (!s.live or s.generation != id.generation) return null; - return s; - } - - pub fn setWaterSettings(self: *Self, id: MockWaterId, config: MockWaterConfig) !void { - self.set_settings_calls += 1; - const s = self.slot(id) orelse return error.StaleInstance; - // Like gfx: a rejection leaves the PREVIOUS settings intact. - if (self.reject_settings) return error.NonFiniteValue; - s.config = config; - } - - pub fn reconfigureWater(self: *Self, id: MockWaterId, config: MockWaterConfig) !void { - const s = self.slot(id) orelse return error.StaleInstance; - s.config = config; - } - - pub fn setWaterLevel(self: *Self, id: MockWaterId, level: f32) !void { - self.set_level_calls += 1; - const s = self.slot(id) orelse return error.StaleInstance; - if (self.fail_level) return error.OutOfMemory; - if (!std.math.isFinite(level)) return error.NonFiniteValue; - s.level = std.math.clamp(level, 0, 1); - if (s.level == 0) s.ripple_count = 0; - } - - pub fn setWaterTime(self: *Self, id: MockWaterId, t: f32) !void { - const s = self.slot(id) orelse return error.StaleInstance; - s.time = t; - } - - pub fn advanceWaterTime(self: *Self, id: MockWaterId, dt: f32) !void { - self.advance_calls += 1; - self.advanced_dt_total += dt; - const s = self.slot(id) orelse return error.StaleInstance; - s.time += dt; - } - - pub fn setWaterWavesEnabled(self: *Self, id: MockWaterId, on: bool) !void { - const s = self.slot(id) orelse return error.StaleInstance; - s.config.waves_enabled = on; - } - - pub fn addWaterRipple(self: *Self, id: MockWaterId, x: f32, strength: f32) !void { - self.ripple_calls += 1; - const s = self.slot(id) orelse return error.StaleInstance; - if (!std.math.isFinite(x) or !std.math.isFinite(strength)) return error.NonFiniteValue; - if (s.level <= 0) return error.EmptyReservoir; - const w: f32 = @floatFromInt(s.config.logical_width); - if (x < 0 or x >= w) return error.RippleOutOfBounds; - if (s.ripple_count < 8) s.ripple_count += 1; - } - }; -} - -const EmptyComponents = struct { - pub fn has(comptime _: []const u8) bool { - return false; - } - pub fn names() []const []const u8 { - return &.{}; - } -}; - -const TestGame = engine.GameConfig( - WaterRenderer(MockEcs.Entity), - MockEcs, - engine.StubInput, - engine.StubAudio, - engine.StubVideo, - engine.StubGui, - void, - core.StubLogSink, - EmptyComponents, - &.{}, - void, -); - -// ── Mock image backend, so a mask/reflection can become resident ──────── - -const Mock = struct { - var next_tex: engine.AssetTexture = 700; - - fn reset() void { - next_tex = 700; - } - fn decodeFn(_: [:0]const u8, _: []const u8, allocator: std.mem.Allocator) anyerror!engine.DecodedImage { - const pixels = try allocator.alloc(u8, 4); - @memset(pixels, 0x11); - return .{ .pixels = pixels, .width = 1, .height = 1 }; - } - fn uploadFn(_: engine.DecodedImage) anyerror!engine.AssetTexture { - const t = next_tex; - next_tex += 1; - return t; - } - fn unloadFn(_: engine.AssetTexture) void {} - - const backend: engine.ImageBackend = .{ - .decode = decodeFn, - .upload = uploadFn, - .unload = unloadFn, - }; -}; - -const png_type: [:0]const u8 = ".png"; -const fake_png: []const u8 = "fake-png-bytes"; - -fn installImageBackend() void { - Mock.reset(); - engine.ImageLoader.setBackend(Mock.backend); -} - -fn loadMask(game: *TestGame, name: []const u8) !void { - try game.loadImageFromMemory(name, png_type, fake_png); -} - -/// The catalog refcount held on `name`. The reservoir's asset references are -/// invisible in behaviour until something evicts a texture, so every -/// acquire/release assertion below reads this directly. -fn refcount(game: *TestGame, name: []const u8) u32 { - return game.assets.entries.getPtr(name).?.refcount; -} - -/// The canonical authored reservoir — the RFC's example block, with the -/// illustrative asset names swapped for the two this file loads. -fn authored() PixelWater { - return .{ - .mask = "reservoir_mask", - .reflection = "reflection", - .logical_size = .{ 96, 18 }, - .grid_pixels = 1, - .water_level = 0.35, - .deep_color = "#172D36", - .surface_color = "#507B8B", - .highlight_color = "#ADCAC6", - .wave_amplitude_pixels = 1, - .wave_period_seconds = 3, - .reflection_opacity = 0.25, - .distortion_pixels = 1, - .ripple_duration_seconds = 0.8, - .ripple_radius_pixels = 6, - .ripple_strength_pixels = 1, - }; -} - -/// Spawn a fully-resolved reservoir: assets resident, sprite attached, -/// component authored, gfx instance live. -fn spawnReservoir(game: *TestGame) !MockEcs.Entity { - try loadMask(game, "reservoir_mask"); - try loadMask(game, "reflection"); - const e = game.createEntity(); - game.addSprite(e, .{ .sprite_name = "reservoir" }); - try testing.expect(game.addPixelWater(e, authored())); - try testing.expect(game.waterInstance(e) != null); - return e; -} - -// ── Authoring: JSONC / .zon parity ────────────────────────────────────── - -const authored_jsonc = - \\{ - \\ "mask": "reservoir_mask", - \\ "reflection": "reflection", - \\ "logical_size": [96, 18], - \\ "grid_pixels": 1, - \\ "water_level": 0.35, - \\ "deep_color": "#172D36", - \\ "surface_color": "#507B8B", - \\ "highlight_color": "#ADCAC6", - \\ "wave_amplitude_pixels": 1, - \\ "wave_period_seconds": 3, - \\ "reflection_opacity": 0.25, - \\ "distortion_pixels": 1, - \\ "ripple_duration_seconds": 0.8, - \\ "ripple_radius_pixels": 6, - \\ "ripple_strength_pixels": 1 - \\} -; - -/// The identical reservoir as a comptime `.zon` component value — the shape -/// `scene/src/entity_writer.zig` coerces. -const authored_zon = .{ - .mask = "reservoir_mask", - .reflection = "reflection", - .logical_size = .{ 96, 18 }, - .grid_pixels = 1, - .water_level = 0.35, - .deep_color = "#172D36", - .surface_color = "#507B8B", - .highlight_color = "#ADCAC6", - .wave_amplitude_pixels = 1, - .wave_period_seconds = 3, - .reflection_opacity = 0.25, - .distortion_pixels = 1, - .ripple_duration_seconds = 0.8, - .ripple_radius_pixels = 6, - .ripple_strength_pixels = 1, -}; - -fn expectSameComponent(a: PixelWater, b: PixelWater) !void { - try testing.expectEqualStrings(a.mask, b.mask); - try testing.expectEqualStrings(a.reflection, b.reflection); - try testing.expectEqual(a.logical_size, b.logical_size); - try testing.expectEqual(a.grid_pixels, b.grid_pixels); - try testing.expectEqual(a.water_level, b.water_level); - try testing.expectEqualStrings(a.deep_color, b.deep_color); - try testing.expectEqualStrings(a.surface_color, b.surface_color); - try testing.expectEqualStrings(a.highlight_color, b.highlight_color); - try testing.expectEqual(a.wave_amplitude_pixels, b.wave_amplitude_pixels); - try testing.expectEqual(a.wave_period_seconds, b.wave_period_seconds); - try testing.expectEqual(a.waves_enabled, b.waves_enabled); - try testing.expectEqual(a.reflection_opacity, b.reflection_opacity); - try testing.expectEqual(a.distortion_pixels, b.distortion_pixels); - try testing.expectEqual(a.ripple_duration_seconds, b.ripple_duration_seconds); - try testing.expectEqual(a.ripple_radius_pixels, b.ripple_radius_pixels); - try testing.expectEqual(a.ripple_strength_pixels, b.ripple_strength_pixels); -} - -test "authoring: JSONC and .zon produce the same PixelWater settings" { - // Both formats must reach the SAME validated component through the SAME - // helper — that is the point of routing the built-in through - // `addPixelWater` rather than giving each format its own apply. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - var parser = engine.JsoncParser.init(arena.allocator(), authored_jsonc); - const value = try parser.parse(); - const from_json = engine.jsonc_deserializer.deserialize( - PixelWater, - value, - arena.allocator(), - ) orelse return error.JsoncDeserializeFailed; - - const from_zon = engine.scene_mod.EntityWriter(TestGame, EmptyComponents) - .coerce(PixelWater, authored_zon); - - try expectSameComponent(from_json, from_zon); - // …and both match the hand-written literal the rest of this file uses, - // so a drift in any one of the three fails here rather than silently. - try expectSameComponent(from_json, authored()); -} - -test "authoring: the JSONC array maps onto logical_size, not a zeroed default" { - // `[2]u32` needs the deserializer's array branch. Without it the field - // would fall back to its `.{0,0}` default — which validation rejects, so - // the mechanism assertion is that the value ARRIVED, not merely that - // something was produced. - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - var parser = engine.JsoncParser.init(arena.allocator(), authored_jsonc); - const value = try parser.parse(); - const comp = engine.jsonc_deserializer.deserialize(PixelWater, value, arena.allocator()).?; - try testing.expectEqual(@as(u32, 96), comp.logical_size[0]); - try testing.expectEqual(@as(u32, 18), comp.logical_size[1]); -} - -// ── Asset resolution + instance lifetime ──────────────────────────────── - -test "instance: created once the mask is resident, and bound to the sprite" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); - const id = game.waterInstance(e).?; - - // The draw binding: the sprite carries the id AND selects the effect. - const sprite = game.getComponent(e, TestGame.SpriteComp).?; - try testing.expect(!sprite.water.isNone()); - try testing.expectEqual(id.index, sprite.water.index); - if (comptime has_pixel_water_effect) { - try testing.expectEqual( - @field(core.backend_contract.MaterialEffect, "pixel_water"), - sprite.material.effect, - ); - } else { - // The pinned labelle-core predates core#78, so the effect tag does - // not exist yet. The engine must then leave the sprite on `.none` — - // the authored static-reservoir fallback — rather than inventing an - // effect. This branch disappears the moment the pin carries the tag. - try testing.expectEqual(core.backend_contract.MaterialEffect.none, sprite.material.effect); - } - - // Structural + colour data actually reached gfx. - const st = game.renderer.waterState(id).?; - try testing.expectEqual(@as(u32, 96), st.config.logical_width); - try testing.expectEqual(@as(u32, 18), st.config.logical_height); - try testing.expectEqual(@as(u32, 700), st.config.mask); - try testing.expectEqual(@as(u32, 701), st.config.reflection); - try testing.expectApproxEqAbs(@as(f32, 0.35), st.level, 1e-6); -} - -test "instance: a missing mask defers creation, then resolves when it lands" { - // The streaming case. The important half is the MECHANISM: before the - // asset is resident `createWaterInstance` must not have been called at - // all — an assertion on "no instance" alone would also pass if creation - // had been attempted and failed. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = game.createEntity(); - game.addSprite(e, .{ .sprite_name = "reservoir" }); - try testing.expect(game.addPixelWater(e, authored())); - - try testing.expectEqual(@as(usize, 0), game.renderer.create_calls); - try testing.expect(game.waterInstance(e) == null); - - try loadMask(&game, "reservoir_mask"); - engine.pixel_water_tick.tick(&game, 0); - - try testing.expectEqual(@as(usize, 1), game.renderer.create_calls); - try testing.expect(game.waterInstance(e) != null); - // The reflection never arrived; it is optional and degrades to "none". - const st = game.renderer.waterState(game.waterInstance(e).?).?; - try testing.expectEqual(@as(u32, 0), st.config.reflection); -} - -test "lifetime: destroying the entity releases the instance" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); - - game.destroyEntity(e); - engine.pixel_water_tick.tick(&game, 0); - - try testing.expectEqual(@as(usize, 1), game.renderer.release_calls); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); - try testing.expect(game.waterInstance(e) == null); -} - -test "lifetime: an ECS reset releases every instance" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - _ = try spawnReservoir(&game); - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); - - game.clearPixelWaterInstances(); - - try testing.expectEqual(@as(usize, 1), game.renderer.release_calls); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); -} - -test "reload: re-authoring the same reservoir reuses the instance" { - // "Changing settings must not create a new GPU program or silently - // recreate the water instance." A non-structural re-author therefore must - // NOT bump `create_calls`. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - const before = game.renderer.create_calls; - const id_before = game.waterInstance(e).?; - - var next = authored(); - next.reflection_opacity = 0.5; - try testing.expect(game.addPixelWater(e, next)); - - try testing.expectEqual(before, game.renderer.create_calls); - try testing.expectEqual(@as(usize, 0), game.renderer.release_calls); - const id_after = game.waterInstance(e).?; - try testing.expectEqual(id_before.index, id_after.index); - try testing.expectEqual(id_before.generation, id_after.generation); - try testing.expectApproxEqAbs( - @as(f32, 0.5), - game.renderer.waterState(id_after).?.config.reflection_opacity, - 1e-6, - ); -} - -test "reload: a STRUCTURAL re-author recreates the instance" { - // The contrasting half of the test above — without it, "reuses the - // instance" could be satisfied by a path that never recreates anything. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - const before = game.renderer.create_calls; - - var next = authored(); - next.logical_size = .{ 64, 12 }; - try testing.expect(game.addPixelWater(e, next)); - engine.pixel_water_tick.tick(&game, 0); - - try testing.expectEqual(@as(usize, 1), game.renderer.release_calls); - try testing.expectEqual(before + 1, game.renderer.create_calls); - try testing.expectEqual( - @as(u32, 64), - game.renderer.waterState(game.waterInstance(e).?).?.config.logical_width, - ); -} - -test "lifetime: the destroy releases the instance SYNCHRONOUSLY, before any tick" { - // The reaper is the fallback for a bare `removeComponent`, not the - // primary path: an ECS backend that recycles entity ids can hand a - // reservoir created later in the SAME frame the dead entity's key, and - // `resolvePixelWaterInstance` would then accept the stale instance - // instead of building the new one. So the destroy itself must release. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - game.destroyEntity(e); - - // No tick in between — that is the whole assertion. - try testing.expectEqual(@as(usize, 1), game.renderer.release_calls); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); - try testing.expect(game.waterInstance(e) == null); -} - -test "instance: a failed initial level sync releases the instance instead of reporting success" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try loadMask(&game, "reservoir_mask"); - try loadMask(&game, "reflection"); - game.renderer.fail_level = true; - - const e = game.createEntity(); - game.addSprite(e, .{ .sprite_name = "reservoir" }); - try testing.expect(game.addPixelWater(e, authored())); - - // MECHANISM: the instance WAS created and then handed back — not merely - // never created. A swallowed level error would leave it live at the - // default level while the component reads the authored 0.35. - try testing.expectEqual(@as(usize, 1), game.renderer.create_calls); - try testing.expectEqual(@as(usize, 1), game.renderer.release_calls); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); - try testing.expect(game.waterInstance(e) == null); - // And nothing half-bound is left on the sprite. - try testing.expect(game.getComponent(e, TestGame.SpriteComp).?.water.isNone()); -} - -test "binding: a sprite added AFTER the water still gets bound" { - // Authored component order is not guaranteed: `PixelWater` before - // `Sprite`, with the mask already resident, creates the instance while - // there is no sprite to bind. The per-frame resolve must finish the job - // when the sprite shows up, or the effect never draws. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try loadMask(&game, "reservoir_mask"); - try loadMask(&game, "reflection"); - - const e = game.createEntity(); - try testing.expect(game.addPixelWater(e, authored())); - const id = game.waterInstance(e).?; - - game.addSprite(e, .{ .sprite_name = "reservoir" }); - try testing.expect(game.getComponent(e, TestGame.SpriteComp).?.water.isNone()); - - game.tick(0); - const sprite = game.getComponent(e, TestGame.SpriteComp).?; - try testing.expect(!sprite.water.isNone()); - try testing.expectEqual(id.index, sprite.water.index); - - // MECHANISM: the rebind is equality-guarded, so a steady-state frame - // does NOT re-dirty the visual. Without the guard this would climb by - // one every tick and break the backend's draw batching. - const dirty_after_bind = game.renderer.visual_dirty_count; - game.tick(0); - game.tick(0); - try testing.expectEqual(dirty_after_bind, game.renderer.visual_dirty_count); -} - -test "reflection: one that finishes streaming after the mask is still applied" { - // Mask and reflection are acquired together but land independently. If - // only the creation moment could apply the reflection, the completion - // ORDER of two uploads would decide whether the authored reflection ever - // appears — a nondeterministic visual. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try loadMask(&game, "reservoir_mask"); - // Registered but NOT resident: the streaming reflection. - try game.registerImageFromMemory("reflection", png_type, fake_png); - - const e = game.createEntity(); - game.addSprite(e, .{ .sprite_name = "reservoir" }); - try testing.expect(game.addPixelWater(e, authored())); - - const id = game.waterInstance(e).?; - try testing.expectEqual(@as(u32, 0), game.renderer.waterState(id).?.config.reflection); - - const settings_before = game.renderer.set_settings_calls; - _ = try game.loadImageIfNeeded("reflection"); - game.tick(0); - - try testing.expect(game.renderer.waterState(id).?.config.reflection != 0); - // MECHANISM: exactly ONE reconfigure ran… - try testing.expectEqual(settings_before + 1, game.renderer.set_settings_calls); - // …and the pending flag was cleared, so later frames do not re-push it. - game.tick(0); - game.tick(0); - try testing.expectEqual(settings_before + 1, game.renderer.set_settings_calls); -} - -// ── Asset references ──────────────────────────────────────────────────── - -test "assets: a reservoir pins its mask and reflection, and the destroy releases both" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - // The eager load holds one reference; the reservoir adds the second. - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reflection")); - - game.destroyEntity(e); - try testing.expectEqual(@as(u32, 1), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 1), refcount(&game, "reflection")); -} - -test "assets: re-authoring the same names does not ratchet the refcount" { - // Hot reload and live prefab refresh re-run `addPixelWater` on a live - // component. An unconditional acquire would add one reference per pass - // and pin the texture for the rest of the process. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try spawnReservoir(&game); - const before = refcount(&game, "reservoir_mask"); - - var again = authored(); - again.distortion_pixels = 0.5; // a non-structural re-author - try testing.expect(game.addPixelWater(e, again)); - try testing.expect(game.addPixelWater(e, again)); - try testing.expectEqual(before, refcount(&game, "reservoir_mask")); - - // A STRUCTURAL re-author onto a different mask moves the reference: - // the old name drops back, the new one is pinned. - try loadMask(&game, "other_mask"); - var swapped = authored(); - swapped.mask = "other_mask"; - try testing.expect(game.addPixelWater(e, swapped)); - try testing.expectEqual(before - 1, refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 2), refcount(&game, "other_mask")); -} - -test "assets: a reservoir whose mask never arrives still releases on removal" { - // The asset table is the superset of the instance table — this entity - // never had an instance for the reaper to find, but it does hold - // catalog references. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try game.registerImageFromMemory("reservoir_mask", png_type, fake_png); - const e = game.createEntity(); - try testing.expect(game.addPixelWater(e, .{ - .mask = "reservoir_mask", - .logical_size = .{ 96, 18 }, - })); - try testing.expect(game.waterInstance(e) == null); - try testing.expectEqual(@as(u32, 1), refcount(&game, "reservoir_mask")); - - game.removeComponent(e, PixelWater); - engine.pixel_water_tick.tick(&game, 0); - try testing.expectEqual(@as(u32, 0), refcount(&game, "reservoir_mask")); -} - -test "assets: an ECS reset releases every reservoir's references" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - _ = try spawnReservoir(&game); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - - game.resetEcsBackend(); - try testing.expectEqual(@as(u32, 1), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); -} - -// ── Worlds ────────────────────────────────────────────────────────────── - -test "world: a swap releases the instances and the return rebuilds them" { - // A water-instance id belongs to the renderer that ISSUED it, and every - // world owns its own renderer. Carrying the table across a swap hands - // the incoming renderer ids it never issued. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try game.createWorld("a"); - try game.setActiveWorld("a"); - const e = try spawnReservoir(&game); - try testing.expect(game.waterInstance(e) != null); - - try game.createWorld("b"); - try game.setActiveWorld("b"); - // Released against the renderer that issued it, not carried over. - try testing.expect(game.waterInstance(e) == null); - - try game.setActiveWorld("a"); - // The asset references were NOT dropped on the way out (the shelved - // world's components still own them), so nothing has to re-acquire. - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - game.tick(0); - try testing.expect(game.waterInstance(e) != null); - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); -} - -test "world: a shelved world's asset references survive a tick in another world" { - // The asset table deliberately SURVIVES a world swap (the shelved - // world's components still own their references). That is only sound - // if the reaper can tell the two worlds apart: keyed on `Entity` - // alone, every shelved record looks like an orphan against the - // newly-active ECS and gets released out from under a live component. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try game.createWorld("a"); - try game.setActiveWorld("a"); - const e = try spawnReservoir(&game); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - - try game.createWorld("b"); - try game.setActiveWorld("b"); - // A single frame in the OTHER world must not touch world a's books. - game.tick(0); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reflection")); - - // And the reservoir still works on the way back — the references it - // kept are the ones its instance is rebuilt from. - try game.setActiveWorld("a"); - game.tick(0); - try testing.expect(game.waterInstance(e) != null); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); -} - -test "world: colliding entity ids get their own asset records" { - // Two independent world ECS instances hand out the same ids from the - // same base, so world b's first entity IS world a's first entity as - // far as an `Entity`-keyed table is concerned. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - try game.createWorld("a"); - try game.setActiveWorld("a"); - const e_a = try spawnReservoir(&game); - - try game.createWorld("b"); - try game.setActiveWorld("b"); - const e_b = game.createEntity(); - // The premise: without it this test proves nothing. - try testing.expectEqual(e_a, e_b); - game.addSprite(e_b, .{ .sprite_name = "reservoir" }); - try testing.expect(game.addPixelWater(e_b, authored())); - - // Two reservoirs in two worlds → two references on top of the eager - // load. A shared record would have mistaken b's reservoir for a's and - // never acquired at all. - try testing.expectEqual(@as(u32, 3), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 3), refcount(&game, "reflection")); - - // Destroying b's reservoir drops only b's reference. - game.destroyEntity(e_b); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reflection")); -} - -// ── Validation ────────────────────────────────────────────────────────── - -test "validation: a missing mask is rejected and names the field" { - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = game.createEntity(); - var bad = authored(); - bad.mask = ""; - - try testing.expect(!game.addPixelWater(e, bad)); - // Rejected BEFORE anything was committed: no component, no gfx call. - try testing.expect(game.pixelWater(e) == null); - try testing.expectEqual(@as(usize, 0), game.renderer.create_calls); - - const issue = engine.validatePixelWaterComponent(bad).?; - try testing.expectEqual(engine.PixelWaterField.mask, issue.field); - try testing.expectEqual(engine.PixelWaterReason.missing, issue.reason); -} - -test "validation: every range / finiteness rule names its own field" { - const nan = std.math.nan(f32); - const inf = std.math.inf(f32); - - const Case = struct { - mutate: *const fn (*PixelWater) void, - field: engine.PixelWaterField, - reason: engine.PixelWaterReason, - }; - - const cases = [_]Case{ - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.logical_size = .{ 0, 18 }; - } - }.f, .field = .logical_size, .reason = .not_positive }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.grid_pixels = 0; - } - }.f, .field = .grid_pixels, .reason = .not_positive }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.water_level = nan; - } - }.f, .field = .water_level, .reason = .non_finite }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.water_level = 1.5; - } - }.f, .field = .water_level, .reason = .out_of_unit_range }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.deep_color = "not-a-colour"; - } - }.f, .field = .deep_color, .reason = .invalid_hex }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.surface_color = "#12345"; - } - }.f, .field = .surface_color, .reason = .invalid_hex }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.wave_amplitude_pixels = -1; - } - }.f, .field = .wave_amplitude_pixels, .reason = .negative }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.wave_period_seconds = 0; - } - }.f, .field = .wave_period_seconds, .reason = .not_positive }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.reflection_opacity = 1.5; - } - }.f, .field = .reflection_opacity, .reason = .out_of_unit_range }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.distortion_pixels = inf; - } - }.f, .field = .distortion_pixels, .reason = .non_finite }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.ripple_duration_seconds = 0; - } - }.f, .field = .ripple_duration_seconds, .reason = .not_positive }, - .{ .mutate = struct { - fn f(c: *PixelWater) void { - c.ripple_radius_pixels = -2; - } - }.f, .field = .ripple_radius_pixels, .reason = .not_positive }, - }; - - for (cases) |c| { - var comp = authored(); - c.mutate(&comp); - const issue = engine.validatePixelWaterComponent(comp) orelse { - std.debug.print("expected {s} to be rejected\n", .{@tagName(c.field)}); - return error.ExpectedRejection; - }; - try testing.expectEqual(c.field, issue.field); - try testing.expectEqual(c.reason, issue.reason); - } - - // The unmutated reservoir is accepted — otherwise every case above would - // pass for the wrong reason. - try testing.expect(engine.validatePixelWaterComponent(authored()) == null); -} - -// ── Bounded strength — BOTH boundaries ────────────────────────────────── - -test "strength boundary 1 (authored): ripple_strength_pixels rejects negative and non-finite" { - var negative = authored(); - negative.ripple_strength_pixels = -1; - const a = engine.validatePixelWaterComponent(negative).?; - try testing.expectEqual(engine.PixelWaterField.ripple_strength_pixels, a.field); - try testing.expectEqual(engine.PixelWaterReason.negative, a.reason); - - var non_finite = authored(); - non_finite.ripple_strength_pixels = std.math.nan(f32); - const b = engine.validatePixelWaterComponent(non_finite).?; - try testing.expectEqual(engine.PixelWaterField.ripple_strength_pixels, b.field); - try testing.expectEqual(engine.PixelWaterReason.non_finite, b.reason); - - // Above one is LEGAL here: the authored value is a pixel amplitude, not a - // unit scale. Pinning that asymmetry is the point of having two - // boundaries rather than one shared rule. - var big = authored(); - big.ripple_strength_pixels = 4; - try testing.expect(engine.validatePixelWaterComponent(big) == null); -} - -test "strength boundary 2 (runtime): addWaterRipple rejects negative, >1 and non-finite" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - - const before = game.renderer.ripple_calls; - - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, 10, -0.1)); - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, 10, 1.5)); - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, 10, std.math.nan(f32))); - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, 10, std.math.inf(f32))); - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, std.math.nan(f32), 0.5)); - - // MECHANISM: none of those reached the renderer. Asserting only "the - // ripple count did not grow" would also pass if the call had been - // forwarded and gfx had silently ignored it. - try testing.expectEqual(before, game.renderer.ripple_calls); - - // Both endpoints of the closed interval are accepted. - try game.addWaterRipple(e, 10, 0); - try game.addWaterRipple(e, 10, 1); - try testing.expectEqual(before + 2, game.renderer.ripple_calls); -} - -test "ripple: one drop crossing forwards exactly one impact" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - - const before_calls = game.renderer.ripple_calls; - const before_count = game.renderer.waterState(game.waterInstance(e).?).?.ripple_count; - - try game.addWaterRipple(e, 42, 0.8); - - try testing.expectEqual(before_calls + 1, game.renderer.ripple_calls); - try testing.expectEqual( - before_count + 1, - game.renderer.waterState(game.waterInstance(e).?).?.ripple_count, - ); -} - -test "ripple: out-of-bounds X and an empty reservoir are refused by gfx, not silently accepted" { - // The engine delegates X bounds and the empty-reservoir rule; what it - // owes the caller is that the refusal SURFACES as an error rather than - // being swallowed. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - - try testing.expectError(error.PixelWaterRejected, game.addWaterRipple(e, 96, 0.5)); - try testing.expectError(error.PixelWaterRejected, game.addWaterRipple(e, -1, 0.5)); - - try game.setWaterLevel(e, 0); - try testing.expectError(error.PixelWaterRejected, game.addWaterRipple(e, 10, 0.5)); -} - -// ── Stage before commit ───────────────────────────────────────────────── - -test "stage-before-commit: engine-rejected settings leave BOTH sides on the previous values" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - const before_settings_calls = game.renderer.set_settings_calls; - - var bad = game.pixelWater(e).?.settings(); - bad.reflection_opacity = 9; - try testing.expectError(error.InvalidPixelWater, game.setWaterSettings(e, bad)); - - // Component side: previous value. - try testing.expectApproxEqAbs(@as(f32, 0.25), game.pixelWater(e).?.reflection_opacity, 1e-6); - // gfx side: previous value. - try testing.expectApproxEqAbs( - @as(f32, 0.25), - game.renderer.waterState(id).?.config.reflection_opacity, - 1e-6, - ); - // MECHANISM: the renderer was never even asked. Both value assertions - // above would also hold if the candidate had been pushed and rejected - // downstream — this is what distinguishes "staged" from "attempted". - try testing.expectEqual(before_settings_calls, game.renderer.set_settings_calls); -} - -test "stage-before-commit: a gfx-rejected candidate also leaves BOTH sides intact" { - // The other rejection path: the engine's validation passes, the renderer's - // does not. Committing the component first and synchronizing after would - // leave the component ahead of the instance here — the exact torn state - // the ordering rule exists to prevent. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - game.renderer.reject_settings = true; - - var next = game.pixelWater(e).?.settings(); - next.reflection_opacity = 0.9; - next.wave_amplitude_pixels = 2; - try testing.expectError(error.PixelWaterRejected, game.setWaterSettings(e, next)); - - // MECHANISM: the renderer WAS asked (unlike the test above) and refused. - try testing.expectEqual(@as(usize, 1), game.renderer.set_settings_calls); - - try testing.expectApproxEqAbs(@as(f32, 0.25), game.pixelWater(e).?.reflection_opacity, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 1), game.pixelWater(e).?.wave_amplitude_pixels, 1e-6); - const st = game.renderer.waterState(id).?; - try testing.expectApproxEqAbs(@as(f32, 0.25), st.config.reflection_opacity, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 1), st.config.wave_amplitude_pixels, 1e-6); -} - -test "stage-before-commit: the LOADER path rolls back too" { - // Hot reload / live prefab refresh route through the same synchronization - // operation, so a rejected re-author must not leave the component ahead - // of the instance either. - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - var bad = authored(); - bad.ripple_radius_pixels = -3; - try testing.expect(!game.addPixelWater(e, bad)); - - try testing.expectApproxEqAbs(@as(f32, 6), game.pixelWater(e).?.ripple_radius_pixels, 1e-6); - try testing.expectApproxEqAbs( - @as(f32, 6), - game.renderer.waterState(id).?.config.ripple_radius_pixels, - 1e-6, - ); -} - -test "settings: an accepted change commits to both sides; an equal one is a no-op" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - var next = game.pixelWater(e).?.settings(); - next.distortion_pixels = 0.5; - try game.setWaterSettings(e, next); - - try testing.expectEqual(@as(usize, 1), game.renderer.set_settings_calls); - try testing.expectApproxEqAbs(@as(f32, 0.5), game.pixelWater(e).?.distortion_pixels, 1e-6); - try testing.expectApproxEqAbs( - @as(f32, 0.5), - game.renderer.waterState(id).?.config.distortion_pixels, - 1e-6, - ); - - // Re-applying the same value must not re-submit — a redundant material - // write breaks the backend's draw batch. - try game.setWaterSettings(e, next); - try testing.expectEqual(@as(usize, 1), game.renderer.set_settings_calls); -} - -test "settings: waves toggle without destroying the authored amplitude" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - try game.setWaterWavesEnabled(e, false); - const st = game.renderer.waterState(id).?; - try testing.expect(!st.config.waves_enabled); - // The authored amplitude survives — the reason there is a flag at all. - try testing.expectApproxEqAbs(@as(f32, 1), st.config.wave_amplitude_pixels, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 1), game.pixelWater(e).?.wave_amplitude_pixels, 1e-6); -} - -test "level: NaN is rejected, out-of-range clamps, and both sides agree" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - const before = game.renderer.set_level_calls; - try testing.expectError(error.InvalidPixelWater, game.setWaterLevel(e, std.math.nan(f32))); - try testing.expectEqual(before, game.renderer.set_level_calls); // never forwarded - try testing.expectApproxEqAbs(@as(f32, 0.35), game.pixelWater(e).?.water_level, 1e-6); - - try game.setWaterLevel(e, 1.8); - try testing.expectApproxEqAbs(@as(f32, 1), game.pixelWater(e).?.water_level, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 1), game.renderer.waterState(id).?.level, 1e-6); -} - -// ── Simulation time: pause and time scale ─────────────────────────────── - -test "time: the water clock advances on the engine's TIME-SCALED delta" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const id = game.waterInstance(e).?; - - game.tick(0.5); - try testing.expectApproxEqAbs(@as(f32, 0.5), game.renderer.waterState(id).?.time, 1e-6); - - // Hard pause (time_scale == 0): the clock HOLDS. A wall-clock - // implementation would keep moving here, which is exactly what makes - // this assertion load-bearing rather than decorative. - const advance_calls_before_pause = game.renderer.advance_calls; - game.pause(); - game.tick(1.0); - try testing.expectApproxEqAbs(@as(f32, 0.5), game.renderer.waterState(id).?.time, 1e-6); - - // MECHANISM: the tick still RAN while paused (so a mask finishing upload - // behind a pause menu can still bind) — it simply advanced by zero. If - // the phase had been skipped wholesale the time assertion above would - // pass for the wrong reason. - try testing.expect(game.renderer.advance_calls > advance_calls_before_pause); - - // Slow-mo: half rate. - game.resume_(); - game.setTimeScale(0.5); - game.tick(1.0); - try testing.expectApproxEqAbs(@as(f32, 1.0), game.renderer.waterState(id).?.time, 1e-6); -} - -test "time: two reservoirs keep independent clocks" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const a = try spawnReservoir(&game); - const b = game.createEntity(); - game.addSprite(b, .{ .sprite_name = "reservoir_b" }); - try testing.expect(game.addPixelWater(b, authored())); - - const id_a = game.waterInstance(a).?; - const id_b = game.waterInstance(b).?; - - // Seed A's clock apart from B's BEFORE any tick. Equal starting times - // would leave this test unable to tell two independent clocks from one - // shared clock — and an implementation that advanced only B would pass - // an assertion on B alone. - try game.renderer.setWaterTime(id_a, 1.0); - game.tick(0.25); - try testing.expectApproxEqAbs(@as(f32, 1.25), game.renderer.waterState(id_a).?.time, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 0.25), game.renderer.waterState(id_b).?.time, 1e-6); - - // Then freeze A's entity out of the world and keep ticking. - game.destroyEntity(a); - game.tick(0.25); - - try testing.expectApproxEqAbs(@as(f32, 0.5), game.renderer.waterState(id_b).?.time, 1e-6); - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); -} - -// ── Colour ────────────────────────────────────────────────────────────── - -test "colour: authored sRGB hex is converted to linear exactly once" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - const e = try spawnReservoir(&game); - const st = game.renderer.waterState(game.waterInstance(e).?).?; - - // `#172D36` → sRGB bytes 0x17/0x2D/0x36 → the sRGB EOTF. - const expected_r = engine.srgbToLinear(0x17); - try testing.expectApproxEqAbs(expected_r, st.config.deep.r, 1e-6); - try testing.expectApproxEqAbs(engine.srgbToLinear(0x2D), st.config.deep.g, 1e-6); - try testing.expectApproxEqAbs(engine.srgbToLinear(0x36), st.config.deep.b, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 1), st.config.deep.a, 1e-6); - - // Double-converting would land here instead — the assertion that makes - // "converted once" a real claim rather than a comment. - const double = engine.srgbToLinear(@intFromFloat(@round(expected_r * 255))); - try testing.expect(@abs(double - st.config.deep.r) > 1e-4); - - // Alpha is coverage, not light: NOT gamma-decoded. - try testing.expectApproxEqAbs(@as(f32, 1), st.config.highlight.a, 1e-6); -} - -test "colour: hex parsing accepts #RRGGBB / #RRGGBBAA and rejects the rest" { - const c = try engine.parseHexColor("#172D36"); - try testing.expectEqual(@as(u8, 0x17), c.r); - try testing.expectEqual(@as(u8, 0x2D), c.g); - try testing.expectEqual(@as(u8, 0x36), c.b); - try testing.expectEqual(@as(u8, 255), c.a); - - const d = try engine.parseHexColor("172D3680"); - try testing.expectEqual(@as(u8, 0x80), d.a); - - try testing.expectError(error.InvalidHexColor, engine.parseHexColor("#12345")); - try testing.expectError(error.InvalidHexColor, engine.parseHexColor("#12345G")); - try testing.expectError(error.InvalidHexColor, engine.parseHexColor("")); -} - -// ── Public API reachability ───────────────────────────────────────────── - -test "root API: PixelWater and its settings type are reachable through the engine root" { - // A type that is not reachable from `src/root.zig` is not usable by a - // game, however correct it is internally. This file imports ONLY - // `engine`, so the test is the reachability proof. - try testing.expect(engine.PixelWater == engine.pixel_water_mod.PixelWater); - try testing.expect(engine.PixelWaterSettings == engine.pixel_water_mod.PixelWaterSettings); - try testing.expect(engine.PixelWaterColor == engine.pixel_water_mod.PixelWaterColor); - try testing.expect(engine.PixelWaterError == engine.pixel_water_mod.PixelWaterError); - - // And the component the engine hands the ECS is that very type. - try testing.expect(TestGame.PixelWaterComp == engine.PixelWater); - - // A game can build a settings value from the public surface alone. - const s: PixelWaterSettings = .{ .deep_color = "#101820", .reflection_opacity = 0.2 }; - try testing.expect(engine.validatePixelWaterSettings(s) == null); -} - -// ── Live prefab refresh (#691) reaches the built-in ───────────────────── -// -// `PixelWater` is a JSONC BUILT-IN: `component_apply.zig` routes it before -// its registry loop, so it is deliberately absent from `Components.names()`. -// The prefab-refresh dispatcher iterates exactly that registry, which used to -// mean a pushed prefab's reservoir edit silently did NOTHING until a full -// respawn — component and gfx instance both left on the retired values. Codex -// P2 on #880. -// -// These assert the MECHANISM on BOTH sides: the live component AND the -// renderer state the instance actually holds. A test that only checked the -// component would pass on a fix that never reached gfx; one that only checked -// "no crash" would pass on the bug itself. - -const Bridge = engine.JsoncSceneBridge(TestGame, EmptyComponents); - -const reservoir_prefab_v1 = - \\{ "components": { - \\ "Sprite": { "sprite_name": "reservoir" }, - \\ "PixelWater": { - \\ "mask": "reservoir_mask", - \\ "reflection": "reflection", - \\ "logical_size": [96, 18], - \\ "grid_pixels": 1, - \\ "water_level": 0.35, - \\ "distortion_pixels": 1 - \\ } - \\} } -; - -/// Boot a bridge-backed game with the reservoir prefab installed, the two -/// textures already resident, and one live instance spawned. -fn bootRefresh(game: *TestGame) !MockEcs.Entity { - try loadMask(game, "reservoir_mask"); - try loadMask(game, "reflection"); - try Bridge.addEmbeddedPrefab(game, "reservoir", reservoir_prefab_v1, "prefabs"); - try Bridge.loadSceneFromSource(game, - \\{ "children": [] } - , "prefabs"); - const e = game.spawnPrefab("reservoir", .{ .x = 0, .y = 0 }).?; - // The push is only meaningful against a reservoir that is already fully - // resolved on both sides. - try testing.expect(game.waterInstance(e) != null); - return e; -} - -test "prefab refresh: a non-structural reservoir edit reaches the live component AND the gfx instance" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try bootRefresh(&game); - const id = game.waterInstance(e).?; - try testing.expectApproxEqAbs(@as(f32, 0.35), game.renderer.waterState(id).?.level, 1e-6); - - const settings_before = game.renderer.set_settings_calls; - const level_before = game.renderer.set_level_calls; - const releases_before = game.renderer.release_calls; - - try game.reloadPrefabSource("reservoir", - \\{ "components": { - \\ "Sprite": { "sprite_name": "reservoir" }, - \\ "PixelWater": { - \\ "mask": "reservoir_mask", - \\ "reflection": "reflection", - \\ "logical_size": [96, 18], - \\ "grid_pixels": 1, - \\ "water_level": 0.8, - \\ "distortion_pixels": 2 - \\ } - \\} } - ); - - // The component took the new values… - const comp = game.pixelWater(e).?; - try testing.expectApproxEqAbs(@as(f32, 0.8), comp.water_level, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 2), comp.distortion_pixels, 1e-6); - - // …and so did the gfx instance. This is the half the bug dropped. - try testing.expectEqual(id.index, game.waterInstance(e).?.index); - const st = game.renderer.waterState(id).?; - try testing.expectApproxEqAbs(@as(f32, 0.8), st.level, 1e-6); - try testing.expectApproxEqAbs(@as(f32, 2), st.config.distortion_pixels, 1e-6); - - // WHICH PATH RAN: the validated non-structural writes, not a - // drop-and-recreate (which would reach the same values by accident). - try testing.expect(game.renderer.set_settings_calls > settings_before); - try testing.expect(game.renderer.set_level_calls > level_before); - try testing.expectEqual(releases_before, game.renderer.release_calls); -} - -test "prefab refresh: a structural reservoir edit recreates the instance from the new component" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try bootRefresh(&game); - const old_id = game.waterInstance(e).?; - const releases_before = game.renderer.release_calls; - - try game.reloadPrefabSource("reservoir", - \\{ "components": { - \\ "Sprite": { "sprite_name": "reservoir" }, - \\ "PixelWater": { - \\ "mask": "reservoir_mask", - \\ "reflection": "reflection", - \\ "logical_size": [64, 18], - \\ "grid_pixels": 1, - \\ "water_level": 0.35, - \\ "distortion_pixels": 1 - \\ } - \\} } - ); - - // Structural fields are the instance's identity: the old one is gone… - try testing.expectEqual(releases_before + 1, game.renderer.release_calls); - // …and the tick rebuilds it from the new component. - engine.pixel_water_tick.tick(&game, 0); - const new_id = game.waterInstance(e).?; - try testing.expect(game.renderer.waterState(old_id) == null); - try testing.expectEqual(@as(u32, 64), game.renderer.waterState(new_id).?.config.logical_width); - try testing.expectEqual(@as(usize, 1), game.renderer.waterInstanceCount()); -} - -test "prefab refresh: dropping the reservoir from the prefab removes the component, the instance and the asset refs" { - installImageBackend(); - defer engine.ImageLoader.clearBackend(); - - var game = TestGame.init(testing.allocator); - defer game.deinit(); - - const e = try bootRefresh(&game); - const id = game.waterInstance(e).?; - // The reservoir is pinning both textures on top of the loader's own ref. - try testing.expectEqual(@as(u32, 2), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 2), refcount(&game, "reflection")); - - try game.reloadPrefabSource("reservoir", - \\{ "components": { "Sprite": { "sprite_name": "reservoir" } } } - ); - - try testing.expect(game.pixelWater(e) == null); - try testing.expect(game.waterInstance(e) == null); - try testing.expect(game.renderer.waterState(id) == null); - try testing.expectEqual(@as(usize, 0), game.renderer.waterInstanceCount()); - // Released SYNCHRONOUSLY — not left for a tick that a reservoir-less - // world has no reason to run. - try testing.expectEqual(@as(u32, 1), refcount(&game, "reservoir_mask")); - try testing.expectEqual(@as(u32, 1), refcount(&game, "reflection")); -} - -// ── Graceful degrade on a renderer without the water seam ─────────────── - -const NoWaterGame = engine.GameConfig( - core.StubRender(MockEcs.Entity), - MockEcs, - engine.StubInput, - engine.StubAudio, - engine.StubVideo, - engine.StubGui, - void, - core.StubLogSink, - EmptyComponents, - &.{}, - void, -); - -test "degrade: a renderer without the water seam still compiles and runs" { - // `core.StubRender` has no `createWaterInstance`. The whole side-table and - // every helper must fold to a no-op rather than a type error — compiling - // this test at all is half the assertion. - var game = NoWaterGame.init(testing.allocator); - defer game.deinit(); - - const e = game.createEntity(); - try testing.expect(game.addPixelWater(e, authored())); - try testing.expect(game.pixelWater(e) != null); - try testing.expect(game.waterInstance(e) == null); - - // Validation still applies; the runtime path reports "no instance". - try testing.expectError(error.PixelWaterRejected, game.addWaterRipple(e, 10, 0.5)); - try testing.expectError(error.InvalidPixelWater, game.addWaterRipple(e, 10, 2)); - - // Settings still commit to the component (the authored fallback sprite - // is what draws), and the tick is inert. - var next = game.pixelWater(e).?.settings(); - next.distortion_pixels = 0.25; - try game.setWaterSettings(e, next); - try testing.expectApproxEqAbs(@as(f32, 0.25), game.pixelWater(e).?.distortion_pixels, 1e-6); - - game.tick(0.5); -} diff --git a/test/shader_material_test.zig b/test/shader_material_test.zig new file mode 100644 index 00000000..2e8a2c8a --- /dev/null +++ b/test/shader_material_test.zig @@ -0,0 +1,499 @@ +const std = @import("std"); +const testing = std.testing; +const core = @import("labelle-core"); +const engine = @import("engine"); +const Ecs = core.MockEcsBackend(u32); +const sm = core.shader_material; +const MockRenderer = struct { + const Self = @This(); + const Entity = u32; + pub const Sprite = struct { sprite_name: []const u8 = "", visible: bool = true, z_index: i16 = 0, layer: enum { default } = .default, material: core.Material = .{} }; + pub const Shape = core.StubRender(u32).Shape; + pub const ShaderMaterialDescriptor = struct { + version: u32 = sm.VERSION, + label: []const u8 = "", + shaders: sm.ShaderVariants, + parameters: []const sm.Parameter = &.{}, + textures: []const Binding = &.{}, + blend: sm.Blend = .alpha, + }; + pub const Binding = struct { name: [:0]const u8, texture: core.TextureId, sampler: sm.Sampler = .point }; + visual_dirty_count: usize = 0, + creates: u64 = 0, + destroys: usize = 0, + invalidates: usize = 0, + writes: usize = 0, + texture_writes: usize = 0, + fail_create: bool = false, + fail_update: bool = false, + stale: bool = false, + last_texture: core.TextureId = .invalid, + pub fn init(_: std.mem.Allocator) Self { + return .{}; + } + pub fn deinit(_: *Self) void {} + pub fn trackEntity(_: *Self, _: Entity, _: core.VisualType) void {} + pub fn untrackEntity(_: *Self, _: Entity) void {} + pub fn markPositionDirty(_: *Self, _: Entity) void {} + pub fn markPositionDirtyWithChildren(_: *Self, comptime _: type, _: anytype, _: Entity) void {} + pub fn updateHierarchyFlag(_: *Self, _: Entity, _: bool) void {} + pub fn markVisualDirty(self: *Self, _: Entity) void { + self.visual_dirty_count += 1; + } + pub fn sync(_: *Self, comptime _: type, _: anytype) void {} + pub fn render(_: *Self) void {} + pub fn setScreenHeight(_: *Self, _: f32) void {} + pub fn clear(_: *Self) void {} + pub fn renderGizmoDraws(_: *Self, _: []const core.GizmoDraw) void {} + pub fn hasEntity(_: *const Self, _: Entity) bool { + return false; + } + + pub fn shaderMaterialSupported(_: *const Self) bool { + return true; + } + pub fn createShaderMaterial(self: *Self, desc: ShaderMaterialDescriptor) !sm.Id { + if (self.fail_create) return error.InvalidShader; + self.creates += 1; + if (desc.textures.len > 0) self.last_texture = desc.textures[0].texture; + return @enumFromInt(self.creates); + } + pub fn destroyShaderMaterial(self: *Self, _: sm.Id) void { + self.destroys += 1; + } + pub fn invalidateShaderMaterials(self: *Self) void { + self.invalidates += 1; + } + pub fn setShaderParameter(self: *Self, _: sm.Id, _: []const u8, _: []const f32) !void { + if (self.stale) return error.InvalidHandle; + self.writes += 1; + } + pub fn setShaderTexture(self: *Self, _: sm.Id, _: []const u8, texture: core.TextureId) !void { + if (self.fail_update) return error.InvalidTexture; + if (self.stale) return error.InvalidHandle; + self.texture_writes += 1; + self.last_texture = texture; + } +}; +const Empty = struct { + pub fn has(comptime _: []const u8) bool { + return false; + } + pub fn names() []const []const u8 { + return &.{}; + } +}; +fn GameWith(comptime R: type) type { + return engine.GameConfig(R, Ecs, engine.StubInput, engine.StubAudio, engine.StubVideo, engine.StubGui, void, core.StubLogSink, Empty, &.{}, void); +} +const Game = GameWith(MockRenderer); +const descriptor: engine.ShaderMaterialDescriptor = .{ .shaders = .{ .spv = "fragment" }, .parameters = &.{.{ .name = "u_time", .kind = .scalar }} }; +fn spawn(game: *Game) !u32 { + const e = game.createEntity(); + game.addSprite(e, .{}); + try game.createShaderMaterial(e, descriptor); + return e; +} +test "shader facade executes create update replace clear and entity destruction" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = try spawn(&game); + try testing.expectEqual(@as(u64, 1), game.renderer.creates); + try testing.expectEqual(game.shaderMaterial(e).?, game.ecs_backend.getComponent(e, MockRenderer.Sprite).?.material.shader); + try game.setShaderParameter(e, "u_time", &.{3}); + try testing.expectEqual(@as(usize, 1), game.renderer.writes); + const old = game.shaderMaterial(e); + game.renderer.fail_create = true; + try testing.expectError(error.InvalidShader, game.createShaderMaterial(e, descriptor)); + try testing.expectEqual(old, game.shaderMaterial(e)); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); + game.renderer.fail_create = false; + try game.createShaderMaterial(e, descriptor); + try testing.expectEqual(@as(usize, 1), game.renderer.destroys); + game.destroyEntity(e); + try testing.expectEqual(@as(usize, 2), game.renderer.destroys); + try testing.expect(game.shaderMaterial(e) == null); + game.clearShaderMaterial(e); + try testing.expectEqual(@as(usize, 2), game.renderer.destroys); +} +test "shader scene reset destroys instances and reused entity ids start empty" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = try spawn(&game); + _ = try spawn(&game); + game.resetEcsBackend(); + try testing.expectEqual(@as(usize, 2), game.renderer.destroys); + const e = game.createEntity(); + try testing.expect(game.shaderMaterial(e) == null); + game.addSprite(e, .{}); + try game.createShaderMaterial(e, descriptor); + try testing.expectEqual(@as(u64, 3), game.renderer.creates); +} +test "shader ownership stays with named worlds and context loss clears every world" { + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.createWorld("a"); + try game.setActiveWorld("a"); + const a = try spawn(&game); + const world_a = game.active_world; + try game.createWorld("b"); + try game.setActiveWorld("b"); + const b_entity = try spawn(&game); + try testing.expectEqual(a, b_entity); + try testing.expectEqual(@as(usize, 0), world_a.renderer.destroys); + game.surfaceLost(); + try testing.expectEqual(@as(usize, 0), world_a.renderer.destroys); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); + try testing.expectEqual(@as(usize, 1), world_a.renderer.invalidates); + try testing.expectEqual(@as(usize, 1), game.renderer.invalidates); + try testing.expect(game.shaderMaterial(b_entity) == null); + try testing.expectError(error.GpuSurfaceUnavailable, game.createShaderMaterial(b_entity, descriptor)); + try game.setActiveWorld("a"); + try testing.expect(game.shaderMaterial(a) == null); + game.destroyWorld("b"); +} +test "backend invalid handle clears binding and game can recreate" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = try spawn(&game); + game.renderer.stale = true; + try testing.expectError(error.InvalidHandle, game.setShaderParameter(e, "u_time", &.{0})); + try testing.expect(game.shaderMaterial(e) == null); + try testing.expectEqual(sm.Id.none, game.ecs_backend.getComponent(e, MockRenderer.Sprite).?.material.shader); + game.renderer.stale = false; + try game.createShaderMaterial(e, descriptor); + try testing.expectEqual(@as(u64, 2), game.renderer.creates); +} +test "unsupported renderer returns actionable errors with no resource state" { + var game = GameWith(core.StubRender(u32)).init(testing.allocator); + defer game.deinit(); + const e = game.createEntity(); + try testing.expectError(error.Unsupported, game.createShaderMaterial(e, descriptor)); + try testing.expectError(error.Unsupported, game.setShaderParameter(e, "u_time", &.{0})); + try testing.expectError(error.Unsupported, game.setShaderTexture(e, "s_mask", "mask")); + game.clearShaderMaterial(e); +} +const ImageBackend = struct { + var next: u32 = 700; + var uploads: usize = 0; + var unloads: usize = 0; + fn decode(_: [:0]const u8, _: []const u8, allocator: std.mem.Allocator) !engine.DecodedImage { + const pixels = try allocator.alloc(u8, 4); + @memset(pixels, 0xff); + return .{ .pixels = pixels, .width = 1, .height = 1 }; + } + fn upload(_: engine.DecodedImage) !engine.AssetTexture { + uploads += 1; + next += 1; + return next; + } + fn unload(_: engine.AssetTexture) void { + unloads += 1; + } + fn install() void { + uploads = 0; + unloads = 0; + engine.ImageLoader.setBackend(.{ .decode = decode, .upload = upload, .unload = unload }); + } +}; +fn refs(game: *Game, key: []const u8) u32 { + return game.assets.entries.getPtr(key).?.refcount; +} +fn pumpReady(game: *Game, key: []const u8) !void { + for (0..1000) |_| { + game.assets.pump(); + if (game.assets.isReady(key)) return; + try std.Io.sleep(testing.io, .fromMilliseconds(1), .awake); + } + return error.TextureDidNotLoad; +} +fn textured(key: []const u8, binding: *[1]engine.ShaderTextureBinding) engine.ShaderMaterialDescriptor { + binding.* = .{.{ .name = "s_mask", .texture = .{ .catalog = key } }}; + var d = descriptor; + d.textures = binding; + return d; +} +test "cold catalog request holds one pending pin, retries, and restores last-owner texture" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.registerImageFromMemory("mask", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var binding: [1]engine.ShaderTextureBinding = undefined; + const d = textured("mask", &binding); + try testing.expectError(error.TextureNotReady, game.createShaderMaterial(e, d)); + try testing.expectEqual(@as(u32, 1), refs(&game, "mask")); + try testing.expectError(error.TextureNotReady, game.createShaderMaterial(e, d)); + try testing.expectEqual(@as(u32, 1), refs(&game, "mask")); + try pumpReady(&game, "mask"); + try game.createShaderMaterial(e, d); + try testing.expectEqual(@as(u32, 1), refs(&game, "mask")); + try testing.expectEqual(@as(usize, 0), game.active_world.shader_pending.count()); + const before = game.renderer.last_texture; + game.surfaceLost(); + try testing.expectEqual(@as(u32, 0), refs(&game, "mask")); + try testing.expect(game.shaderMaterial(e) == null); + game.surfaceRestored(); + try testing.expectError(error.TextureNotReady, game.createShaderMaterial(e, d)); + try pumpReady(&game, "mask"); + try game.createShaderMaterial(e, d); + try testing.expect(game.renderer.last_texture != before); + try testing.expectEqual(@as(u32, 1), refs(&game, "mask")); + game.destroyEntityOnly(e); + try testing.expectEqual(@as(u32, 0), refs(&game, "mask")); + try testing.expectEqual(@as(usize, 2), ImageBackend.uploads); +} +test "catalog texture replacement balances success failure and superseded pending bindings" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.loadImageFromMemory("ready", ".png", "fake"); + try game.registerImageFromMemory("cold", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var binding: [1]engine.ShaderTextureBinding = undefined; + try game.createShaderMaterial(e, textured("ready", &binding)); + try testing.expectEqual(@as(u32, 2), refs(&game, "ready")); + try testing.expectError(error.TextureNotReady, game.setShaderTexture(e, "s_mask", "cold")); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try game.setShaderTexture(e, "s_mask", "ready"); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold")); + try testing.expectEqual(@as(u32, 2), refs(&game, "ready")); + try game.registerImageFromMemory("cold2", ".png", "fake"); + try testing.expectError(error.TextureNotReady, game.setShaderTexture(e, "s_mask", "cold2")); + try game.setShaderTexture(e, "s_mask", @as(core.TextureId, @enumFromInt(99))); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold2")); + try testing.expectEqual(@as(u32, 1), refs(&game, "ready")); + game.renderer.fail_update = true; + try testing.expectError(error.InvalidTexture, game.setShaderTexture(e, "s_mask", "ready")); + try testing.expectEqual(@as(u32, 1), refs(&game, "ready")); + game.renderer.fail_update = false; + try game.setShaderTexture(e, "s_mask", @as(engine.ShaderTexture, .{ .catalog = "ready" })); + game.clearShaderMaterial(e); + try testing.expectEqual(@as(u32, 1), refs(&game, "ready")); +} +test "pending acquisitions survive failed material creation and are released on scene reset" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.registerImageFromMemory("cold", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var binding: [1]engine.ShaderTextureBinding = undefined; + const d = textured("cold", &binding); + try testing.expectError(error.TextureNotReady, game.createShaderMaterial(e, d)); + try pumpReady(&game, "cold"); + game.renderer.fail_create = true; + try testing.expectError(error.InvalidShader, game.createShaderMaterial(e, d)); + // The attempt's own committed pin was released; the retained pending + // request (the asset is already streamed) is NOT — a retry must not + // re-stream it. + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try testing.expectEqual(@as(usize, 1), game.active_world.shader_pending.count()); + game.renderer.fail_create = false; + try game.createShaderMaterial(e, d); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try testing.expectEqual(@as(usize, 0), game.active_world.shader_pending.count()); + game.clearShaderMaterial(e); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold")); + try game.registerImageFromMemory("reset", ".png", "fake"); + try testing.expectError(error.TextureNotReady, game.createShaderMaterial(e, textured("reset", &binding))); + game.resetEcsBackend(); + try testing.expectEqual(@as(u32, 0), refs(&game, "reset")); + try testing.expectEqual(@as(usize, 0), game.active_world.shader_pending.count()); +} +test "direct ECS destruction and sprite removal reap committed and pending ownership" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = try spawn(&game); + game.ecs_backend.destroyEntity(e); + game.reapShaderMaterials(); + try testing.expect(game.shaderMaterial(e) == null); + const next = try spawn(&game); + game.ecs_backend.removeComponent(next, MockRenderer.Sprite); + game.reapShaderMaterials(); + try testing.expectEqual(@as(usize, 2), game.renderer.destroys); +} +test "actual atomic scene swap clears shader ownership before new scene loader" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = try spawn(&game); + const Loader = struct { + fn load(g: *Game) anyerror!void { + try testing.expectEqual(@as(usize, 0), g.active_world.shader_materials.count()); + _ = try spawn(g); + } + }; + game.registerSceneSimple("next", Loader.load); + try game.setSceneAtomic("next"); + try testing.expectEqual(@as(usize, 1), game.renderer.destroys); + try testing.expectEqual(@as(u64, 2), game.renderer.creates); + try testing.expect(game.shaderMaterial(e).? != @as(sm.Id, @enumFromInt(1))); +} +test "prefab JSONC cannot restore a live shader runtime handle" { + const Value = engine.SceneValue; + const parsed = engine.jsonc_deserializer.deserialize(sm.Id, Value{ .integer = 123 }, testing.allocator); + try testing.expectEqual(sm.Id.none, parsed.?); +} +test "superseding one pending sampler preserves another sampler request" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.registerImageFromMemory("cold_a", ".png", "fake"); + try game.registerImageFromMemory("cold_b", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var d = descriptor; + d.textures = &.{ .{ .name = "s_a", .texture = .{ .id = @enumFromInt(99) } }, .{ .name = "s_b", .texture = .{ .id = @enumFromInt(98) } } }; + try game.createShaderMaterial(e, d); + try testing.expectError(error.TextureNotReady, game.setShaderTexture(e, "s_a", "cold_a")); + try testing.expectError(error.TextureNotReady, game.setShaderTexture(e, "s_b", "cold_b")); + try game.setShaderTexture(e, "s_a", @as(core.TextureId, @enumFromInt(97))); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold_a")); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold_b")); + try pumpReady(&game, "cold_b"); + try game.setShaderTexture(e, "s_b", "cold_b"); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold_b")); + game.clearMaterial(e); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold_b")); + try testing.expect(game.shaderMaterial(e) == null); +} + +test "zon writer rejects typed authored shader handles" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const Writer = @import("scene").entity_writer.EntityWriter(Game, Empty); + const e = game.createEntity(); + _ = Writer.addComponents(e, &game, .{ .Sprite = @as(MockRenderer.Sprite, .{ .material = .{ .shader = @enumFromInt(123) } }) }, null); + try testing.expectEqual(sm.Id.none, game.ecs_backend.getComponent(e, MockRenderer.Sprite).?.material.shader); +} + +// A game-owned shader takes PRECEDENCE over a curated effect; it does not +// replace it. Creating and then clearing a shader material must leave the +// authored `effect`/`uniforms` exactly as the game set them — asserted on the +// component itself, so a fix that merely re-derived the same `.none` would not +// pass. +test "shader creation preserves the curated material effect it takes precedence over" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = game.createEntity(); + game.addSprite(e, .{}); + game.setMaterial(e, .{ .effect = .flash, .uniforms = .{ .scalar0 = 0.5, .r = 1 } }); + try game.createShaderMaterial(e, descriptor); + const sprite = game.ecs_backend.getComponent(e, MockRenderer.Sprite).?; + try testing.expect(sprite.material.shader != .none); + try testing.expectEqual(core.MaterialEffect.flash, sprite.material.effect); + try testing.expectEqual(@as(f32, 0.5), sprite.material.uniforms.scalar0); + try testing.expectEqual(@as(f32, 1), sprite.material.uniforms.r); + game.clearShaderMaterial(e); + try testing.expectEqual(sm.Id.none, sprite.material.shader); + try testing.expectEqual(core.MaterialEffect.flash, sprite.material.effect); + try testing.expectEqual(@as(f32, 0.5), sprite.material.uniforms.scalar0); +} + +// Contract: "Calls while the GPU is unavailable return GpuSurfaceUnavailable." +// Asserted on a LIVE entity whose material existed before the loss, so the +// error cannot be the incidental `InvalidHandle` an unknown entity would give. +test "shader setters report surface loss rather than a handle error" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = try spawn(&game); + game.surfaceLost(); + try testing.expectError(error.GpuSurfaceUnavailable, game.setShaderParameter(e, "u_time", &.{0})); + try testing.expectError(error.GpuSurfaceUnavailable, game.setShaderTexture(e, "s_mask", "mask")); + game.surfaceRestored(); + try testing.expectError(error.InvalidHandle, game.setShaderParameter(e, "u_time", &.{0})); + try game.createShaderMaterial(e, descriptor); + try game.setShaderParameter(e, "u_time", &.{1}); +} + +// Stage-before-commit for the PENDING side (codex on #882): re-authoring on +// an entity that holds a cold pending request must not release that request +// — or the committed material — unless the replacement fully commits. +// Refcounts are asserted directly, and the ids compared, so a fix that +// merely re-requested the asset (refcount dips to 0 then back) would fail. +test "failed re-authoring keeps the previous material and every pending pin" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.loadImageFromMemory("ready", ".png", "fake"); + try game.registerImageFromMemory("cold", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var binding: [1]engine.ShaderTextureBinding = undefined; + try game.createShaderMaterial(e, textured("ready", &binding)); + const live = game.shaderMaterial(e).?; + try testing.expectError(error.TextureNotReady, game.setShaderTexture(e, "s_mask", "cold")); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + // Resolution failure on a descriptor that does not mention the pending binding. + var other: [1]engine.ShaderTextureBinding = .{.{ .name = "s_other", .texture = .{ .catalog = "missing" } }}; + var d = descriptor; + d.textures = &other; + try testing.expectError(error.AssetNotRegistered, game.createShaderMaterial(e, d)); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try testing.expectEqual(@as(u32, 2), refs(&game, "ready")); + try testing.expectEqual(live, game.shaderMaterial(e).?); + try testing.expectEqual(live, game.ecs_backend.getComponent(e, MockRenderer.Sprite).?.material.shader); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); + // Backend creation failure after every texture resolved. + game.renderer.fail_create = true; + try testing.expectError(error.InvalidShader, game.createShaderMaterial(e, textured("ready", &binding))); + game.renderer.fail_create = false; + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try testing.expectEqual(@as(u32, 2), refs(&game, "ready")); + try testing.expectEqual(live, game.shaderMaterial(e).?); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); + // A failed texture update on the SAME binding keeps the pending request too. + try testing.expectError(error.AssetNotRegistered, game.setShaderTexture(e, "s_mask", "missing")); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + // The retained request is still the live one: once ready it commits + // without re-streaming, and the sweep leaves exactly the committed pin. + try pumpReady(&game, "cold"); + try game.setShaderTexture(e, "s_mask", "cold"); + try testing.expectEqual(@as(u32, 1), refs(&game, "cold")); + try testing.expectEqual(@as(u32, 1), refs(&game, "ready")); + try testing.expectEqual(@as(usize, 0), game.active_world.shader_pending.count()); + // "ready" (loadImageFromMemory) + "cold" exactly once: the retained + // request kept it streamed across every failure, so nothing re-uploaded. + try testing.expectEqual(@as(usize, 2), ImageBackend.uploads); + game.clearShaderMaterial(e); + try testing.expectEqual(@as(u32, 0), refs(&game, "cold")); +} + +// labelle-gfx#361 hand-off: on surface loss the engine must FORGET materials +// through `invalidateShaderMaterials` — never destroy through the lost +// context — and its pin releases must not reach the image backend either +// (the catalog is invalidated first). After restore the setters report the +// stale id and a recreate goes live. +test "surface loss forgets materials without a backend destroy and recreation goes live after restore" { + ImageBackend.install(); + var game = Game.init(testing.allocator); + defer game.deinit(); + try game.loadImageFromMemory("mask", ".png", "fake"); + const e = game.createEntity(); + game.addSprite(e, .{}); + var binding: [1]engine.ShaderTextureBinding = undefined; + try game.createShaderMaterial(e, textured("mask", &binding)); + const textureless = game.createEntity(); + game.addSprite(textureless, .{}); + try game.createShaderMaterial(textureless, descriptor); + try testing.expectEqual(@as(u32, 2), refs(&game, "mask")); + game.surfaceLost(); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); + try testing.expectEqual(@as(usize, 1), game.renderer.invalidates); + try testing.expectEqual(@as(usize, 0), ImageBackend.unloads); + try testing.expectEqual(@as(u32, 1), refs(&game, "mask")); + try testing.expect(game.shaderMaterial(e) == null); + try testing.expect(game.shaderMaterial(textureless) == null); + try testing.expectEqual(sm.Id.none, game.ecs_backend.getComponent(e, MockRenderer.Sprite).?.material.shader); + try testing.expectEqual(sm.Id.none, game.ecs_backend.getComponent(textureless, MockRenderer.Sprite).?.material.shader); + game.surfaceRestored(); + try testing.expectError(error.InvalidHandle, game.setShaderParameter(e, "u_time", &.{0})); + try pumpReady(&game, "mask"); + try game.createShaderMaterial(e, textured("mask", &binding)); + try testing.expectEqual(@as(u64, 3), game.renderer.creates); + try testing.expect(game.shaderMaterial(e) != null); + try game.setShaderParameter(e, "u_time", &.{1}); + try testing.expectEqual(@as(usize, 0), game.renderer.destroys); +}