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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions SHADER-MATERIALS.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions SHADER-VALIDATION.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 6 additions & 5 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(.{
Expand All @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
Loading
Loading