@forgeax/engine-runtime 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -32
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  101. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  102. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  103. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  104. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  105. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  106. package/src/__tests__/world-scene-nested.test.ts +90 -163
  107. package/src/__tests__/world-scene-override.test.ts +17 -16
  108. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  109. package/src/collect-scene-asset.ts +637 -19
  110. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  111. package/src/index.ts +1 -0
  112. package/src/scene-utils/mount-override-fold.ts +5 -14
  113. package/src/__tests__/node-shims.d.ts +0 -31
@@ -0,0 +1,666 @@
1
+ import { createWorldContext, type EntityHandle, FixedTime, World } from '@forgeax/engine-ecs';
2
+ import {
3
+ ANTIALIAS_TAA,
4
+ Atmosphere,
5
+ CAMERA_EXPOSURE_MODE_AUTO,
6
+ Camera,
7
+ type DynamicGeometryCandidate,
8
+ type FrameReceipt,
9
+ MeshFilter,
10
+ MeshRenderer,
11
+ type RenderError,
12
+ type Renderer,
13
+ type RenderWorldLease,
14
+ } from '@forgeax/engine-render';
15
+ import type { RhiDevice } from '@forgeax/engine-rhi';
16
+ import { propagateTransforms, scenePlugin } from '@forgeax/engine-scene';
17
+ import {
18
+ createStandaloneRuntimeAssetBinding,
19
+ type Handle,
20
+ type MeshAsset,
21
+ type TextureAsset,
22
+ } from '@forgeax/engine-types';
23
+ import { expect, it } from 'vitest';
24
+ import { page } from 'vitest/browser';
25
+ import {
26
+ createWave1RenderingRecipe,
27
+ WAVE1_ATMOSPHERE_PRESET,
28
+ } from '../../../../scripts/dev-verify/wave1-rendering/recipe';
29
+ import type { RhiBackendInstrumentation } from '../../../render/src/assembly/backend-contract';
30
+ import { constructRuntimeRendererHost } from '../renderer-host';
31
+
32
+ interface ScreenshotPixels {
33
+ readonly width: number;
34
+ readonly height: number;
35
+ readonly pixels: Uint8Array;
36
+ }
37
+
38
+ interface PixelStats {
39
+ readonly nonBlackPixels: number;
40
+ readonly meanLuma: number;
41
+ }
42
+
43
+ interface PixelDelta {
44
+ readonly changedPixels: number;
45
+ readonly meanAbsoluteChannelDelta: number;
46
+ }
47
+
48
+ interface HostLossSignal {
49
+ readonly device: RhiDevice;
50
+ readonly trigger: () => void;
51
+ readonly outputAllocations: { buffers: number; lutViews: number };
52
+ }
53
+
54
+ function expectedHostLossGeneration(error: RenderError): number | undefined {
55
+ if (error.code !== 'device-operation-failed') return undefined;
56
+ if (error.detail.operation !== 'renderer-event') return undefined;
57
+ if (error.detail.cause.code !== 'device-lost') return undefined;
58
+ const match =
59
+ /^device-lost reason: unknown; message: host-injected wave1 loss for generation (0|1)$/.exec(
60
+ error.detail.cause.hint,
61
+ );
62
+ if (match === null) return undefined;
63
+ const generation = Number(match[1]);
64
+ return Number.isInteger(generation) ? generation : undefined;
65
+ }
66
+
67
+ type LossInfo = Awaited<RhiDevice['lost']>;
68
+ type Inspection = ReturnType<Renderer['inspect']>;
69
+
70
+ interface FrameSample {
71
+ readonly receipt: FrameReceipt;
72
+ readonly pixels: ScreenshotPixels;
73
+ readonly inspection: Inspection;
74
+ }
75
+
76
+ interface RecoveryResourceSample {
77
+ readonly generation: number;
78
+ readonly graphResources: number;
79
+ readonly gpuCapacity: number;
80
+ readonly gpuFullRebuilds: number;
81
+ readonly dynamicMeshBytes: number;
82
+ readonly dynamicCandidates: number;
83
+ }
84
+
85
+ function createHostLossInstrumentation(): {
86
+ readonly signals: HostLossSignal[];
87
+ readonly instrumentation: RhiBackendInstrumentation;
88
+ } {
89
+ const signals: HostLossSignal[] = [];
90
+ const deviceLost: NonNullable<RhiBackendInstrumentation['deviceLost']> = (device) => {
91
+ let resolveLoss!: (info: LossInfo) => void;
92
+ const loss = new Promise<LossInfo>((resolve) => {
93
+ resolveLoss = resolve;
94
+ });
95
+ const generation = signals.length;
96
+ const outputAllocations = { buffers: 0, lutViews: 0 };
97
+ const createBuffer = device.createBuffer.bind(device);
98
+ device.createBuffer = (descriptor) => {
99
+ const result = createBuffer(descriptor);
100
+ if (result.ok && descriptor.label?.startsWith('standard-auto-exposure-'))
101
+ outputAllocations.buffers += 1;
102
+ return result;
103
+ };
104
+ const createTextureView = device.createTextureView.bind(device);
105
+ device.createTextureView = (texture, descriptor) => {
106
+ const result = createTextureView(texture, descriptor);
107
+ if (result.ok && descriptor?.dimension === '3d') outputAllocations.lutViews += 1;
108
+ return result;
109
+ };
110
+ signals.push({
111
+ outputAllocations,
112
+ device,
113
+ // This deliberately projects an unknown host signal. It does not call
114
+ // GPUDevice.destroy() and therefore does not claim a driver reset.
115
+ trigger: () =>
116
+ resolveLoss({
117
+ reason: 'unknown',
118
+ message: `host-injected wave1 loss for generation ${generation}`,
119
+ }),
120
+ });
121
+ return loss;
122
+ };
123
+ return { signals, instrumentation: { deviceLost } };
124
+ }
125
+
126
+ async function waitUntil<T>(
127
+ label: string,
128
+ read: () => T | Promise<T>,
129
+ predicate: (value: T) => boolean,
130
+ timeoutMs = 15_000,
131
+ ): Promise<T> {
132
+ const deadline = performance.now() + timeoutMs;
133
+ let last: T | undefined;
134
+ while (performance.now() < deadline) {
135
+ last = await read();
136
+ if (predicate(last)) return last;
137
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
138
+ }
139
+ throw new Error(`wave1-rendering recovery timed out at ${label}: ${JSON.stringify(last)}`);
140
+ }
141
+
142
+ async function screenshotPixels(
143
+ canvas: HTMLCanvasElement,
144
+ label: string,
145
+ ): Promise<ScreenshotPixels> {
146
+ const shot = await page.elementLocator(canvas).screenshot({
147
+ path: `../../../../artifacts/wave1-rendering/wave1-recovery-${label}.png`,
148
+ base64: true,
149
+ });
150
+ const base64 = typeof shot === 'string' ? shot : shot.base64;
151
+ const binary = atob(base64);
152
+ const bytes = new Uint8Array(binary.length);
153
+ for (let index = 0; index < binary.length; index += 1) {
154
+ bytes[index] = binary.charCodeAt(index);
155
+ }
156
+ const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
157
+ const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
158
+ const context = offscreen.getContext('2d', { willReadFrequently: true });
159
+ if (context === null) {
160
+ bitmap.close();
161
+ throw new Error('wave1-rendering recovery screenshot context unavailable');
162
+ }
163
+ context.drawImage(bitmap, 0, 0);
164
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
165
+ bitmap.close();
166
+ return { width: image.width, height: image.height, pixels: new Uint8Array(image.data) };
167
+ }
168
+
169
+ function pixelStats(readback: ScreenshotPixels): PixelStats {
170
+ let nonBlackPixels = 0;
171
+ let lumaTotal = 0;
172
+ for (let offset = 0; offset < readback.pixels.length; offset += 4) {
173
+ const red = readback.pixels[offset] ?? 0;
174
+ const green = readback.pixels[offset + 1] ?? 0;
175
+ const blue = readback.pixels[offset + 2] ?? 0;
176
+ if (red + green + blue > 3) nonBlackPixels += 1;
177
+ lumaTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
178
+ }
179
+ const pixels = Math.max(1, readback.width * readback.height);
180
+ return { nonBlackPixels, meanLuma: lumaTotal / pixels };
181
+ }
182
+
183
+ function pixelDelta(left: ScreenshotPixels, right: ScreenshotPixels): PixelDelta {
184
+ if (left.width !== right.width || left.height !== right.height) {
185
+ return { changedPixels: left.width * left.height, meanAbsoluteChannelDelta: 255 };
186
+ }
187
+ let changedPixels = 0;
188
+ let absoluteDelta = 0;
189
+ for (let offset = 0; offset < left.pixels.length; offset += 4) {
190
+ let changed = false;
191
+ for (let channel = 0; channel < 3; channel += 1) {
192
+ const delta = Math.abs(
193
+ (left.pixels[offset + channel] ?? 0) - (right.pixels[offset + channel] ?? 0),
194
+ );
195
+ if (delta > 0) changed = true;
196
+ absoluteDelta += delta;
197
+ }
198
+ if (changed) changedPixels += 1;
199
+ }
200
+ return {
201
+ changedPixels,
202
+ meanAbsoluteChannelDelta: absoluteDelta / Math.max(1, left.width * left.height * 3),
203
+ };
204
+ }
205
+
206
+ function cropPixels(
207
+ readback: ScreenshotPixels,
208
+ centerX: number,
209
+ centerY: number,
210
+ halfWidth: number,
211
+ halfHeight: number,
212
+ ): ScreenshotPixels {
213
+ const x0 = Math.max(0, Math.floor((centerX - halfWidth) * readback.width));
214
+ const y0 = Math.max(0, Math.floor((centerY - halfHeight) * readback.height));
215
+ const x1 = Math.min(readback.width, Math.ceil((centerX + halfWidth) * readback.width));
216
+ const y1 = Math.min(readback.height, Math.ceil((centerY + halfHeight) * readback.height));
217
+ const width = Math.max(1, x1 - x0);
218
+ const height = Math.max(1, y1 - y0);
219
+ const pixels = new Uint8Array(width * height * 4);
220
+ for (let row = 0; row < height; row += 1) {
221
+ const sourceOffset = ((y0 + row) * readback.width + x0) * 4;
222
+ pixels.set(readback.pixels.subarray(sourceOffset, sourceOffset + width * 4), row * width * 4);
223
+ }
224
+ return { width, height, pixels };
225
+ }
226
+
227
+ async function submitFrame(
228
+ world: World,
229
+ renderer: Renderer,
230
+ lease: RenderWorldLease,
231
+ canvas: HTMLCanvasElement,
232
+ label: string,
233
+ ): Promise<FrameSample> {
234
+ propagateTransforms(world).unwrap();
235
+ const drawn = renderer.draw({
236
+ leases: [lease],
237
+ camera: { lease },
238
+ environment: { lease },
239
+ fixedStep: world.getResource(FixedTime).tick,
240
+ });
241
+ if (!drawn.ok) throw drawn.error;
242
+ const receipt = drawn.value;
243
+ const completed = await receipt.completed;
244
+ if (!completed.ok) throw completed.error;
245
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
246
+ const pixels = await screenshotPixels(canvas, label);
247
+ const inspection = renderer.inspect();
248
+ // biome-ignore lint/suspicious/noConsole: browser recovery evidence is intentionally emitted.
249
+ console.info(
250
+ '[wave1-rendering:recovery]',
251
+ JSON.stringify({
252
+ label,
253
+ lossSource: 'host-injected-unknown',
254
+ receipt: {
255
+ frameId: receipt.frameId,
256
+ deviceGeneration: receipt.deviceGeneration,
257
+ },
258
+ pixels: pixelStats(pixels),
259
+ state: inspection.state,
260
+ temporal: inspection.temporal,
261
+ dynamicGeometry: inspection.dynamicGeometry,
262
+ recovery: inspection.recovery,
263
+ recoveryEvidence: inspection.recoveryEvidence,
264
+ gpu: inspection.renderScene.gpu,
265
+ }),
266
+ );
267
+ return { receipt, pixels, inspection };
268
+ }
269
+
270
+ function prepareDynamic(
271
+ renderer: Renderer,
272
+ world: World,
273
+ entity: EntityHandle,
274
+ mesh: MeshAsset,
275
+ meshHandle: Handle<'MeshAsset', 'shared'>,
276
+ revision: number,
277
+ ): DynamicGeometryCandidate {
278
+ const prepared = renderer.prepareDynamicGeometry({ world, entity, mesh, meshHandle, revision });
279
+ if (!prepared.ok) throw prepared.error;
280
+ const accepted = renderer.acceptDynamicGeometry(prepared.value, {
281
+ world,
282
+ fixedStep: world.getResource(FixedTime).tick,
283
+ });
284
+ if (!accepted.ok) throw accepted.error;
285
+ return accepted.value;
286
+ }
287
+
288
+ function resourceSample(inspection: Inspection): RecoveryResourceSample {
289
+ const gpu = inspection.renderScene.gpu;
290
+ if (gpu.status !== 'resident') {
291
+ throw new Error(`wave1-rendering recovery GPU scene is not resident: ${gpu.status}`);
292
+ }
293
+ const dynamic = inspection.dynamicGeometry;
294
+ return {
295
+ generation: inspection.frame.deviceGeneration,
296
+ graphResources: inspection.recoveryEvidence.graph.resourceCount,
297
+ gpuCapacity: gpu.capacity,
298
+ gpuFullRebuilds: gpu.fullRebuilds,
299
+ dynamicMeshBytes: dynamic?.meshBytes ?? 0,
300
+ dynamicCandidates:
301
+ (dynamic?.prepared ?? 0) + (dynamic?.accepted ?? 0) + (dynamic?.published ?? 0),
302
+ };
303
+ }
304
+
305
+ async function flushCleanup(): Promise<void> {
306
+ await Promise.resolve();
307
+ await Promise.resolve();
308
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
309
+ }
310
+
311
+ it('recovers the same Renderer, World, and lease through two host loss cycles', async () => {
312
+ const canvas = document.createElement('canvas');
313
+ canvas.width = 384;
314
+ canvas.height = 384;
315
+ canvas.style.width = '384px';
316
+ canvas.style.height = '384px';
317
+ document.body.appendChild(canvas);
318
+
319
+ const hostLoss = createHostLossInstrumentation();
320
+ const rendererResult = await constructRuntimeRendererHost(
321
+ canvas,
322
+ { rhiInstrumentation: hostLoss.instrumentation },
323
+ { shaderManifestUrl: '/shaders/manifest.json' },
324
+ );
325
+ if (!rendererResult.ok) throw rendererResult.error;
326
+ const { renderer, assets } = rendererResult.value;
327
+ const world = new World();
328
+ const scene = await createWorldContext(world, [scenePlugin()]);
329
+ const recipe = createWave1RenderingRecipe(world, { aspect: 1, includeAtmosphere: true });
330
+ // A tiny identity LUT exercises ordinary asset residency on every device.
331
+ const lutGuid = 'dab53d78-d233-4c79-8cb3-c03f33130001';
332
+ const lutSourceKey = 'test://wave1-output-recovery/identity-lut';
333
+ const binding = createStandaloneRuntimeAssetBinding('wave1-output-recovery');
334
+ const catalog = {
335
+ schemaVersion: 'runtime-catalog-snapshot-v1',
336
+ scopeId: binding.scopeId,
337
+ generation: binding.generation,
338
+ authority: 'authoritative',
339
+ entries: [
340
+ {
341
+ guid: lutGuid,
342
+ kind: 'texture',
343
+ packageUrl: 'data:application/json,{}',
344
+ sourceKey: lutSourceKey,
345
+ },
346
+ ],
347
+ };
348
+ assets.configureRuntimeBinding({
349
+ ...binding,
350
+ catalogUrl: `data:application/json,${encodeURIComponent(JSON.stringify(catalog))}`,
351
+ });
352
+ expect(await assets.refreshCatalog()).toBe(true);
353
+ const lutBytes = new Uint16Array(2 * 2 * 2 * 4);
354
+ for (let index = 0; index < 8; index += 1) {
355
+ lutBytes.set(
356
+ [index & 1 ? 0x3c00 : 0, index & 2 ? 0x3c00 : 0, index & 4 ? 0x3c00 : 0, 0x3c00],
357
+ index * 4,
358
+ );
359
+ }
360
+ const lut: TextureAsset = {
361
+ kind: 'texture',
362
+ shape: { viewDimension: '3d', extent: { width: 2, height: 2, depth: 2 } },
363
+ format: 'rgba16float',
364
+ colorSpace: 'linear',
365
+ mips: { kind: 'none' },
366
+ data: new Uint8Array(lutBytes.buffer),
367
+ };
368
+ assets.catalog(lutGuid, lut).unwrap();
369
+ const lutHandle = world.allocSharedRef('TextureAsset', lut);
370
+ const attached = renderer.attach(world);
371
+ if (!attached.ok) throw attached.error;
372
+ const lease = attached.value;
373
+ const worldIdentity = world.identity;
374
+ const initialLease = lease;
375
+ const errors: RenderError[] = [];
376
+ const unsubscribe = renderer.subscribe((event) => {
377
+ if (event.kind === 'error') {
378
+ errors.push(event.error);
379
+ // biome-ignore lint/suspicious/noConsole: recovery failure triage keeps the structured owner cause.
380
+ console.info('[wave1-rendering:recovery-error]', JSON.stringify(event.error));
381
+ }
382
+ });
383
+ const extraAtmospheres = new Set<EntityHandle>();
384
+ let activeAtmosphere = recipe.entities.atmosphere;
385
+ let rendererDisposed = false;
386
+
387
+ const removeAtmosphere = (): void => {
388
+ if (activeAtmosphere === undefined) return;
389
+ if (activeAtmosphere === recipe.entities.atmosphere) recipe.destroy(activeAtmosphere);
390
+ else {
391
+ world.despawn(activeAtmosphere).unwrap();
392
+ extraAtmospheres.delete(activeAtmosphere);
393
+ }
394
+ activeAtmosphere = undefined;
395
+ };
396
+ const addAtmosphere = (): void => {
397
+ const entity = world.spawn({ component: Atmosphere, data: WAVE1_ATMOSPHERE_PRESET }).unwrap();
398
+ extraAtmospheres.add(entity);
399
+ activeAtmosphere = entity;
400
+ };
401
+
402
+ try {
403
+ await waitUntil(
404
+ 'initial loss hook',
405
+ () => hostLoss.signals.length,
406
+ (count) => count >= 1,
407
+ );
408
+ world
409
+ .set(recipe.entities.camera, Camera, {
410
+ antialias: ANTIALIAS_TAA,
411
+ exposureMode: CAMERA_EXPOSURE_MODE_AUTO,
412
+ exposure: 1,
413
+ // Keep the exposure fixed for the existing atmosphere pixel comparisons.
414
+ rangeEv: [0, 0],
415
+ colorLut: lutHandle,
416
+ colorLutStrength: 0.5,
417
+ })
418
+ .unwrap();
419
+ world.update(1 / 60).unwrap();
420
+ const baseline = await submitFrame(world, renderer, lease, canvas, 'baseline');
421
+ expect(baseline.receipt.deviceGeneration).toBe(0);
422
+ expect(baseline.inspection.state).toBe('alive');
423
+ expect(baseline.inspection.temporal.mode).toBe('taa');
424
+ expect(baseline.inspection.output.autoExposure?.receipt.committed).toBe(true);
425
+ expect(baseline.inspection.output.standardLut?.sourceKey).toBe(lutSourceKey);
426
+ expect(pixelStats(baseline.pixels).nonBlackPixels).toBeGreaterThan(0);
427
+ const baselineResources = resourceSample(baseline.inspection);
428
+
429
+ const initialDynamic = prepareDynamic(
430
+ renderer,
431
+ world,
432
+ recipe.entities.wall,
433
+ recipe.assets.wallAperture,
434
+ recipe.assets.wallApertureHandle,
435
+ 1,
436
+ );
437
+ world.update(1 / 60).unwrap();
438
+ const initialAperture = await submitFrame(world, renderer, lease, canvas, 'initial-aperture');
439
+ expect(renderer.dynamicGeometryReceipt(initialDynamic)?.frameId).toBe(
440
+ initialAperture.receipt.frameId,
441
+ );
442
+ expect(pixelDelta(baseline.pixels, initialAperture.pixels).changedPixels).toBeGreaterThan(0);
443
+
444
+ const recoveryResources: RecoveryResourceSample[] = [];
445
+ for (let cycle = 1; cycle <= 2; cycle += 1) {
446
+ // Bind the recipe's ordinary successor mesh after the previous
447
+ // submission. It has never been visible on this Renderer, so this
448
+ // World-only edit must cause a real first-use GPU upload after recovery.
449
+ world
450
+ .set(recipe.entities.wall, MeshFilter, {
451
+ assetHandle: recipe.assets.successorHandle,
452
+ })
453
+ .unwrap();
454
+ world
455
+ .set(recipe.entities.wall, MeshRenderer, {
456
+ materials: [recipe.assets.wood, recipe.assets.metal],
457
+ })
458
+ .unwrap();
459
+ world.update(1 / 60).unwrap();
460
+ const signal = hostLoss.signals[cycle - 1];
461
+ if (signal === undefined) throw new Error(`missing host loss signal for cycle ${cycle}`);
462
+ signal.trigger();
463
+ await waitUntil(
464
+ 'device-lost',
465
+ () => renderer.state(),
466
+ (state) => state === 'device-lost',
467
+ );
468
+ expect(renderer.inspect().state).toBe('device-lost');
469
+
470
+ const recovered = await renderer.recover();
471
+ expect(recovered.ok, recovered.ok ? '' : JSON.stringify(recovered.error)).toBe(true);
472
+ await waitUntil(
473
+ 'alive after recovery',
474
+ () => renderer.state(),
475
+ (state) => state === 'alive',
476
+ );
477
+ await waitUntil(
478
+ 'replacement loss hook',
479
+ () => hostLoss.signals.length,
480
+ (count) => count >= cycle + 1,
481
+ );
482
+
483
+ // Recovery readiness must already own fresh output resources. A later
484
+ // draw rebuilding them cannot validate a graph published with stale handles.
485
+ expect(hostLoss.signals[cycle]?.outputAllocations.buffers).toBe(4);
486
+ expect(hostLoss.signals[cycle]?.outputAllocations.lutViews).toBeGreaterThan(0);
487
+ expect(world.identity).toBe(worldIdentity);
488
+ expect(lease).toBe(initialLease);
489
+ const recoveryReadyMeshEpoch =
490
+ renderer.inspect().recoveryEvidence.residency.meshResidencyEpoch;
491
+ world.update(1 / 60).unwrap();
492
+ const firstRecovered = await submitFrame(
493
+ world,
494
+ renderer,
495
+ lease,
496
+ canvas,
497
+ `cycle-${cycle}-recovered-first`,
498
+ );
499
+ world.update(1 / 60).unwrap();
500
+ const recoveredFrame = await submitFrame(
501
+ world,
502
+ renderer,
503
+ lease,
504
+ canvas,
505
+ `cycle-${cycle}-recovered-steady`,
506
+ );
507
+ expect(firstRecovered.receipt.deviceGeneration).toBe(cycle);
508
+ expect(recoveredFrame.receipt.deviceGeneration).toBe(cycle);
509
+ expect(recoveredFrame.inspection.state).toBe('alive');
510
+ expect(recoveredFrame.inspection.output.autoExposure?.receipt.committed).toBe(true);
511
+ expect(recoveredFrame.inspection.output.standardLut?.deviceEpoch).toBe(cycle);
512
+ expect(recoveredFrame.inspection.output.standardLut?.sourceKey).toBe(lutSourceKey);
513
+ expect(recoveredFrame.inspection.temporal.mode).toBe('taa');
514
+ expect(recoveredFrame.inspection.temporal.historyValid).toBe(true);
515
+ expect(pixelStats(recoveredFrame.pixels).nonBlackPixels).toBeGreaterThan(0);
516
+ expect(recoveredFrame.inspection.recovery.lastOutcome).toBe('succeeded');
517
+ expect(recoveredFrame.inspection.recovery.candidateGeneration).toBe(cycle);
518
+ expect(recoveredFrame.inspection.recoveryEvidence.graph.ready).toBe(true);
519
+ expect(recoveredFrame.inspection.recoveryEvidence.receipts.lastGeneration).toBe(cycle);
520
+ expect(
521
+ pixelDelta(initialAperture.pixels, firstRecovered.pixels).changedPixels,
522
+ ).toBeGreaterThan(0);
523
+ expect(
524
+ firstRecovered.inspection.recoveryEvidence.residency.meshResidencyEpoch,
525
+ ).toBeGreaterThan(recoveryReadyMeshEpoch);
526
+ const recoveredResources = resourceSample(recoveredFrame.inspection);
527
+ expect(recoveredResources.gpuCapacity).toBe(baselineResources.gpuCapacity);
528
+ expect(recoveredResources.graphResources).toBe(baselineResources.graphResources);
529
+ // Recovery invalidates device-bound dynamic credentials but retains the
530
+ // World MeshFilter; the next candidate must be prepared on this device.
531
+ expect(recoveredResources.dynamicCandidates).toBe(0);
532
+ expect(recoveredResources.dynamicMeshBytes).toBe(0);
533
+
534
+ const skyOn = cropPixels(recoveredFrame.pixels, 0.5, 0.2, 0.45, 0.18);
535
+ removeAtmosphere();
536
+ world.update(1 / 60).unwrap();
537
+ const atmosphereOff = await submitFrame(
538
+ world,
539
+ renderer,
540
+ lease,
541
+ canvas,
542
+ `cycle-${cycle}-atmosphere-off`,
543
+ );
544
+ expect(
545
+ pixelDelta(skyOn, cropPixels(atmosphereOff.pixels, 0.5, 0.2, 0.45, 0.18)).changedPixels,
546
+ ).toBeGreaterThan(0);
547
+ addAtmosphere();
548
+ world.update(1 / 60).unwrap();
549
+ const atmosphereOn = await submitFrame(
550
+ world,
551
+ renderer,
552
+ lease,
553
+ canvas,
554
+ `cycle-${cycle}-atmosphere-on`,
555
+ );
556
+ expect(pixelDelta(atmosphereOff.pixels, atmosphereOn.pixels).changedPixels).toBeGreaterThan(
557
+ 0,
558
+ );
559
+ expect(atmosphereOn.inspection.temporal.mode).toBe('taa');
560
+
561
+ const closedCandidate = prepareDynamic(
562
+ renderer,
563
+ world,
564
+ recipe.entities.wall,
565
+ recipe.assets.wallClosed,
566
+ recipe.assets.wallClosedHandle,
567
+ cycle + 1,
568
+ );
569
+ world.update(1 / 60).unwrap();
570
+ const closed = await submitFrame(
571
+ world,
572
+ renderer,
573
+ lease,
574
+ canvas,
575
+ `cycle-${cycle}-dynamic-closed`,
576
+ );
577
+ expect(renderer.dynamicGeometryReceipt(closedCandidate)?.frameId).toBe(
578
+ closed.receipt.frameId,
579
+ );
580
+ expect(pixelDelta(atmosphereOn.pixels, closed.pixels).changedPixels).toBeGreaterThan(0);
581
+ expect(closed.inspection.dynamicGeometry?.published).toBeGreaterThanOrEqual(1);
582
+
583
+ // Swap the ECS binding back before receipt-bound retirement. This is the
584
+ // public unload path and keeps the dynamic candidate budget bounded.
585
+ world
586
+ .set(recipe.entities.wall, MeshFilter, {
587
+ assetHandle: recipe.assets.wallApertureHandle,
588
+ })
589
+ .unwrap();
590
+ world
591
+ .set(recipe.entities.wall, MeshRenderer, {
592
+ materials: [
593
+ recipe.assets.wood,
594
+ recipe.assets.metal,
595
+ recipe.assets.wood,
596
+ recipe.assets.metal,
597
+ ],
598
+ })
599
+ .unwrap();
600
+ world.update(1 / 60).unwrap();
601
+ const retired = renderer.retireDynamicGeometry(closedCandidate);
602
+ expect(retired.ok, retired.ok ? '' : JSON.stringify(retired.error)).toBe(true);
603
+ await flushCleanup();
604
+ const afterUnload = renderer.inspect();
605
+ expect(afterUnload.dynamicGeometry?.meshBytes ?? 0).toBe(0);
606
+ expect(
607
+ (afterUnload.dynamicGeometry?.prepared ?? 0) +
608
+ (afterUnload.dynamicGeometry?.accepted ?? 0) +
609
+ (afterUnload.dynamicGeometry?.published ?? 0),
610
+ ).toBe(0);
611
+ // Make the ECS MeshFilter edit part of the LKG before the next host loss.
612
+ // Recovery rehydrates the last submitted visible workset; an undrawn
613
+ // binding change is deliberately not promoted into that seed.
614
+ const afterUnloadFrame = await submitFrame(
615
+ world,
616
+ renderer,
617
+ lease,
618
+ canvas,
619
+ `cycle-${cycle}-after-unload`,
620
+ );
621
+ expect(afterUnloadFrame.receipt.deviceGeneration).toBe(cycle);
622
+ expect(afterUnloadFrame.inspection.dynamicGeometry?.meshBytes ?? 0).toBe(0);
623
+ expect(
624
+ (afterUnloadFrame.inspection.dynamicGeometry?.prepared ?? 0) +
625
+ (afterUnloadFrame.inspection.dynamicGeometry?.accepted ?? 0) +
626
+ (afterUnloadFrame.inspection.dynamicGeometry?.published ?? 0),
627
+ ).toBe(0);
628
+ recoveryResources.push(recoveredResources, resourceSample(afterUnloadFrame.inspection));
629
+ }
630
+
631
+ expect(recoveryResources).toHaveLength(4);
632
+ expect(Math.max(...recoveryResources.map((sample) => sample.gpuCapacity))).toBe(
633
+ baselineResources.gpuCapacity,
634
+ );
635
+ expect(Math.max(...recoveryResources.map((sample) => sample.graphResources))).toBe(
636
+ baselineResources.graphResources,
637
+ );
638
+ expect(
639
+ Math.max(...recoveryResources.map((sample) => sample.gpuFullRebuilds)),
640
+ ).toBeLessThanOrEqual(1);
641
+ const expectedLossGenerations = errors
642
+ .map(expectedHostLossGeneration)
643
+ .filter((generation): generation is number => generation !== undefined);
644
+ expect(errors.filter((error) => expectedHostLossGeneration(error) === undefined)).toEqual([]);
645
+ expect(expectedLossGenerations).toEqual([0, 1]);
646
+ expect(hostLoss.signals.every((signal) => signal.device !== undefined)).toBe(true);
647
+
648
+ const disposed = await renderer.dispose();
649
+ expect(disposed.ok, disposed.ok ? '' : JSON.stringify(disposed.error)).toBe(true);
650
+ rendererDisposed = true;
651
+ expect(renderer.state()).toBe('disposed');
652
+ expect(renderer.inspect().state).toBe('disposed');
653
+ // The same renderer is terminal after dispose; no replacement Renderer or
654
+ // World was created during either recovery cycle.
655
+ expect(world.identity).toBe(worldIdentity);
656
+ } finally {
657
+ unsubscribe();
658
+ if (!rendererDisposed) await renderer.dispose();
659
+ removeAtmosphere();
660
+ for (const entity of extraAtmospheres) world.despawn(entity).unwrap();
661
+ recipe.dispose();
662
+ world.sharedRefs.release(lutHandle).unwrap();
663
+ await scene.fiber.dispose();
664
+ canvas.remove();
665
+ }
666
+ }, 120_000);