@forgeax/engine-runtime 0.1.20 → 0.1.21

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 (58) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/createRenderer.d.ts.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/index.mjs.map +1 -1
  12. package/package.json +30 -30
  13. package/src/__tests__/active-camera.unit.test.ts +6 -8
  14. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  15. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  16. package/src/__tests__/components.test-d.ts +10 -3
  17. package/src/__tests__/components.unit.test.ts +13 -7
  18. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  19. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  20. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  21. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  22. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  23. package/src/__tests__/errors.unit.test.ts +51 -20
  24. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  25. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  26. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  27. package/src/__tests__/geometry.unit.test.ts +7 -10
  28. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
  29. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  30. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  31. package/src/__tests__/lights.unit.test.ts +75 -2235
  32. package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
  33. package/src/__tests__/pipeline.unit.test.ts +48 -202
  34. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  35. package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
  36. package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
  37. package/src/__tests__/render-system-mega.test.ts +30 -41
  38. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
  39. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
  40. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  41. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  42. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  43. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  44. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  45. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  46. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  47. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  48. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  49. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  50. package/src/__tests__/shadow-fields-observable.dawn.test.ts +85 -20
  51. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  52. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  53. package/src/__tests__/ssao-bgl.test.ts +10 -16
  54. package/src/__tests__/ssao-passes.test.ts +19 -2
  55. package/src/__tests__/systems.unit.test.ts +44 -31
  56. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  57. package/src/createRenderer.ts +1 -0
  58. package/src/index.ts +1 -2
@@ -96,6 +96,7 @@ function makeMockGPUDevice(spies: DeviceSpies): { device: unknown } {
96
96
  limits: {},
97
97
  queue: {
98
98
  submit: () => undefined,
99
+ onSubmittedWorkDone: async () => undefined,
99
100
  writeBuffer: () => undefined,
100
101
  writeTexture: () => undefined,
101
102
  },
@@ -413,7 +414,9 @@ describe('record: per-submesh transparency (feat-city-glb Bug 5)', () => {
413
414
  const { renderer } = await setupRenderer(spies);
414
415
  const errors: string[] = [];
415
416
  renderer.subscribe((event) => {
416
- if (event.kind === 'error') errors.push(event.error.code);
417
+ if (event.kind === 'error') {
418
+ errors.push(event.error.code);
419
+ }
417
420
  });
418
421
 
419
422
  const { world } = await spawnMixedTransparentScene();
@@ -1,8 +1,9 @@
1
1
  // feat-20260611 R2 / M8 / w29 (IS-14): record-stage skin BindGroup unit test.
2
2
  //
3
3
  // Why this file exists:
4
- // M8 wires the record stage to bind a 2-binding `pbr-skin-mesh-array-bgl`
5
- // (binding 0 mesh-array UBO + binding 1 palette UBO) at group(2) when
4
+ // M8 wires the record stage to bind a 3-binding `pbr-skin-mesh-array-bgl`
5
+ // (binding 0 mesh-array UBO + binding 1 current palette UBO + binding 2
6
+ // previous palette UBO) at group(2) when
6
7
  // the per-entry skin discriminator (`entry.source.skin !== undefined`) is
7
8
  // set. R1 of this loop discovered the prior 1-binding `pbr-mesh-bg`
8
9
  // build path triggered every-frame BGL-mismatch device errors against
@@ -13,8 +14,8 @@
13
14
  // fast in CI rather than at frame N.
14
15
  //
15
16
  // What this test asserts:
16
- // (1) `buildPbrSkinLayouts` returns a 2-entry meshArrayBgl labeled
17
- // `pbr-skin-mesh-array-bgl` with bindings 0 + 1 both
17
+ // (1) `buildPbrSkinLayouts` returns a 3-entry meshArrayBgl labeled
18
+ // `pbr-skin-mesh-array-bgl` with bindings 0 + 1 + 2 all
18
19
  // `hasDynamicOffset: true`. This is the BGL the record stage
19
20
  // must use to build a BG matching `pbr-skin-pl`.
20
21
  // (2) The skin pipeline layout `pbr-skin-pl` references the SAME
@@ -23,7 +24,7 @@
23
24
  // contract that R1 violated.
24
25
  // (3) Regression guard: `buildPbrPipelineLayouts` (URP) returns a
25
26
  // 1-entry meshArrayBgl labeled `pbr-mesh-array-bgl`. M8 must
26
- // NOT leak the 2-entry skin BGL into the URP path.
27
+ // NOT leak the 3-entry skin BGL into the URP path.
27
28
  //
28
29
  // What this test deliberately does NOT cover:
29
30
  // - The actual `pass.setBindGroup(2, ...)` dispatch site is exercised
@@ -83,7 +84,7 @@ function makeMockDevice(): { device: PbrPipelineDevice; capture: MockDeviceCaptu
83
84
  }
84
85
 
85
86
  describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contract', () => {
86
- it('(1) buildPbrSkinLayouts returns a 2-entry meshArrayBgl labeled pbr-skin-mesh-array-bgl with bindings 0 + 1 both hasDynamicOffset:true (storage path)', () => {
87
+ it('(1) buildPbrSkinLayouts returns a 3-entry meshArrayBgl labeled pbr-skin-mesh-array-bgl with bindings 0 + 1 + 2 all hasDynamicOffset:true (storage path)', () => {
87
88
  const { device, capture } = makeMockDevice();
88
89
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: true });
89
90
  const skin = buildPbrSkinLayouts(device, { storageBuffer: true }, pbr);
@@ -91,16 +92,19 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
91
92
  const skinDesc = capture.bglByHandle.get(skin.meshArrayBgl);
92
93
  expect(skinDesc).toBeDefined();
93
94
  expect(skinDesc?.label).toBe('pbr-skin-mesh-array-bgl');
94
- expect(skinDesc?.entries).toHaveLength(2);
95
+ expect(skinDesc?.entries).toHaveLength(3);
95
96
  expect(skinDesc?.entries[0]?.binding).toBe(0);
96
97
  expect(skinDesc?.entries[0]?.buffer?.hasDynamicOffset).toBe(true);
97
98
  expect(skinDesc?.entries[0]?.buffer?.type).toBe('read-only-storage');
98
99
  expect(skinDesc?.entries[1]?.binding).toBe(1);
99
100
  expect(skinDesc?.entries[1]?.buffer?.hasDynamicOffset).toBe(true);
100
101
  expect(skinDesc?.entries[1]?.buffer?.type).toBe('read-only-storage');
102
+ expect(skinDesc?.entries[2]?.binding).toBe(2);
103
+ expect(skinDesc?.entries[2]?.buffer?.hasDynamicOffset).toBe(true);
104
+ expect(skinDesc?.entries[2]?.buffer?.type).toBe('read-only-storage');
101
105
  });
102
106
 
103
- it('(1b) uniform fallback path: buildPbrSkinLayouts BGL bindings 0 + 1 both type=uniform', () => {
107
+ it('(1b) uniform fallback path: buildPbrSkinLayouts BGL bindings 0 + 1 + 2 all type=uniform', () => {
104
108
  const { device, capture } = makeMockDevice();
105
109
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: false });
106
110
  const skin = buildPbrSkinLayouts(device, { storageBuffer: false }, pbr);
@@ -108,6 +112,7 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
108
112
  const skinDesc = capture.bglByHandle.get(skin.meshArrayBgl);
109
113
  expect(skinDesc?.entries[0]?.buffer?.type).toBe('uniform');
110
114
  expect(skinDesc?.entries[1]?.buffer?.type).toBe('uniform');
115
+ expect(skinDesc?.entries[2]?.buffer?.type).toBe('uniform');
111
116
  });
112
117
 
113
118
  it('(2) pbr-skin-pl slot 2 === skin meshArrayBgl handle (NOT the standard pbr-mesh-array-bgl) — pins the BG vs PipelineLayout contract R1 violated', () => {
@@ -129,7 +134,7 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
129
134
  expect(skinPlDesc?.bindGroupLayouts[3]).toBe(pbr.instancesBgl);
130
135
  });
131
136
 
132
- it('(3) regression guard: buildPbrPipelineLayouts (URP) returns 1-entry meshArrayBgl labeled pbr-mesh-array-bgl — M8 must NOT leak the 2-entry skin BGL into URP', () => {
137
+ it('(3) regression guard: buildPbrPipelineLayouts (URP) returns 1-entry meshArrayBgl labeled pbr-mesh-array-bgl — M8 must NOT leak the 3-entry skin BGL into URP', () => {
133
138
  const { device, capture } = makeMockDevice();
134
139
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: true });
135
140
 
@@ -20,6 +20,7 @@ describe('renderer recovery and receipt contract', () => {
20
20
  expect(result.error.code).toBe('renderer-state-invalid');
21
21
  expect(result.error.hint.length).toBeGreaterThan(0);
22
22
  }
23
+ expect(renderer.inspect().state).toBe('alive');
23
24
  await renderer.dispose();
24
25
  });
25
26
 
@@ -38,6 +39,13 @@ describe('renderer recovery and receipt contract', () => {
38
39
  expect(frame.ok).toBe(true);
39
40
  if (!frame.ok) return;
40
41
  const receipt: FrameReceipt = frame.value;
42
+ expect(world.update().ok).toBe(true);
43
+ const nextFrame = renderer.draw({
44
+ leases: [attached.value],
45
+ camera: { lease: attached.value },
46
+ environment: { lease: attached.value },
47
+ });
48
+ expect(nextFrame.ok).toBe(true);
41
49
  const observed = await renderer.observe(receipt, { include: ['draws', 'bindings'] });
42
50
  expect(observed.ok).toBe(true);
43
51
  if (observed.ok) expect(observed.value.frameId).toBe(receipt.frameId);
@@ -164,7 +164,7 @@ function makeLights(): MockObj {
164
164
  shadowMapSize: 0,
165
165
  depthBias: 0,
166
166
  normalBias: 0,
167
- pcfKernelSize: 0,
167
+ directionalShadowQuality: undefined,
168
168
  pointShadow: [],
169
169
  };
170
170
  }
@@ -174,6 +174,7 @@ function makeFrameState(): MockObj {
174
174
  frameNumber: 1,
175
175
  compiledFrameGraph: null,
176
176
  compiledFrameGraphTopologyKey: null,
177
+ compiledFrameGraphGeneration: 0,
177
178
  retiredCompiledFrameGraphs: new Set(),
178
179
  instanceBuffers: new Map(),
179
180
  transientInstanceBuffers: [],
@@ -193,8 +194,7 @@ function makeFrameState(): MockObj {
193
194
  installedPipelineHandle: 0,
194
195
  activePipeline: { buildGraph: () => null, execute: () => undefined },
195
196
  installedPipelineConfig: undefined,
196
- isHdrpActive: false,
197
- hdrpOncePerFrameFired: new Set(),
197
+ standardOncePerFrameFired: new Set(),
198
198
  pointShadowAtlas: null,
199
199
  pointShadowSnapshots: [],
200
200
  lastFoldBucketCount: 0,
@@ -231,6 +231,232 @@ function callRecordFrame(
231
231
  acquireSwapChainTarget(internals, internals.getPipelineState());
232
232
  }
233
233
 
234
+ type DirectionalShadowAdmission = {
235
+ readonly requested: 'off' | 'pcf1' | 'pcf3' | 'pcf5' | 'pcssMedium' | 'pcssHigh';
236
+ readonly effective:
237
+ | 'off'
238
+ | 'pcf1'
239
+ | 'pcf3'
240
+ | 'pcf5'
241
+ | 'pcssMedium'
242
+ | 'pcssHigh'
243
+ | 'rhi-null-structural';
244
+ readonly status: 'accepted' | 'fallback' | 'rejected';
245
+ readonly fallbackReason?: 'webgl2-unsupported' | 'rhi-null-structural' | 'candidate-failed';
246
+ readonly lastKnownGood: boolean;
247
+ readonly pixelEvidence: 'available' | 'not-available';
248
+ };
249
+
250
+ async function resolveDirectionalShadowAdmission(input: {
251
+ readonly backendKind: 'webgpu' | 'wgpu-webgl2' | 'null';
252
+ readonly requested: DirectionalShadowAdmission['requested'];
253
+ readonly candidate: 'accepted' | 'failed';
254
+ readonly lastKnownGood?: Exclude<
255
+ DirectionalShadowAdmission['effective'],
256
+ 'off' | 'rhi-null-structural'
257
+ >;
258
+ }): Promise<DirectionalShadowAdmission> {
259
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
260
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
261
+ expect(typeof resolver).toBe('function');
262
+ return (resolver as (value: typeof input) => DirectionalShadowAdmission)(input);
263
+ }
264
+
265
+ describe('M3 backend admission and fallback truthfulness', () => {
266
+ it('keeps an explicitly disabled Directional shadow off without fabricating a candidate', async () => {
267
+ await expect(
268
+ resolveDirectionalShadowAdmission({
269
+ backendKind: 'webgpu',
270
+ requested: 'off',
271
+ candidate: 'failed',
272
+ }),
273
+ ).resolves.toMatchObject({
274
+ requested: 'off',
275
+ effective: 'off',
276
+ status: 'accepted',
277
+ lastKnownGood: false,
278
+ pixelEvidence: 'not-available',
279
+ });
280
+ });
281
+
282
+ it('admits a production WebGPU/Dawn candidate without converting PCSS to PCF', async () => {
283
+ await expect(
284
+ resolveDirectionalShadowAdmission({
285
+ backendKind: 'webgpu',
286
+ requested: 'pcssMedium',
287
+ candidate: 'accepted',
288
+ }),
289
+ ).resolves.toMatchObject({
290
+ requested: 'pcssMedium',
291
+ effective: 'pcssMedium',
292
+ status: 'accepted',
293
+ lastKnownGood: false,
294
+ pixelEvidence: 'available',
295
+ });
296
+ });
297
+
298
+ it.each([
299
+ ['pcssMedium', 'pcf3'],
300
+ ['pcssHigh', 'pcf5'],
301
+ ] as const)('maps WebGL2 %s to fixed effective %s', async (requested, effective) => {
302
+ await expect(
303
+ resolveDirectionalShadowAdmission({
304
+ backendKind: 'wgpu-webgl2',
305
+ requested,
306
+ candidate: 'accepted',
307
+ }),
308
+ ).resolves.toMatchObject({
309
+ requested,
310
+ effective,
311
+ status: 'fallback',
312
+ fallbackReason: 'webgl2-unsupported',
313
+ pixelEvidence: 'available',
314
+ });
315
+ });
316
+
317
+ it('projects RhiNull as structural evidence and never as pixel evidence', async () => {
318
+ await expect(
319
+ resolveDirectionalShadowAdmission({
320
+ backendKind: 'null',
321
+ requested: 'pcssHigh',
322
+ candidate: 'accepted',
323
+ }),
324
+ ).resolves.toMatchObject({
325
+ requested: 'pcssHigh',
326
+ effective: 'rhi-null-structural',
327
+ status: 'fallback',
328
+ fallbackReason: 'rhi-null-structural',
329
+ pixelEvidence: 'not-available',
330
+ });
331
+ });
332
+
333
+ it('rejects a capable candidate failure while retaining compatible LKG', async () => {
334
+ const result = await resolveDirectionalShadowAdmission({
335
+ backendKind: 'webgpu',
336
+ requested: 'pcssHigh',
337
+ candidate: 'failed',
338
+ lastKnownGood: 'pcf3',
339
+ });
340
+ expect(result).toMatchObject({
341
+ requested: 'pcssHigh',
342
+ effective: 'pcf3',
343
+ status: 'rejected',
344
+ fallbackReason: 'candidate-failed',
345
+ lastKnownGood: true,
346
+ pixelEvidence: 'available',
347
+ });
348
+ expect(result.effective).not.toBe('pcf5');
349
+ });
350
+
351
+ it('keeps falsifiers observable instead of allowing fake effective mappings', async () => {
352
+ const result = await resolveDirectionalShadowAdmission({
353
+ backendKind: 'wgpu-webgl2',
354
+ requested: 'pcssMedium',
355
+ candidate: 'accepted',
356
+ });
357
+ expect(result.fallbackReason).toBe('webgl2-unsupported');
358
+ expect(result.effective).toBe('pcf3');
359
+ expect(result).not.toMatchObject({ effective: 'pcssMedium' });
360
+ });
361
+ });
362
+
363
+ describe('M3 bounded Directional shadow inspection', () => {
364
+ it('requires one detached projection with bounded tap and generation facts', async () => {
365
+ const module = (await import(
366
+ '../../../render/src/assembly/directional-shadow-inspection'
367
+ )) as Record<string, unknown>;
368
+ const project = module.projectDirectionalShadowInspection;
369
+ expect(typeof project).toBe('function');
370
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
371
+ admission: {
372
+ requested: 'pcssHigh',
373
+ effective: 'pcssHigh',
374
+ status: 'accepted',
375
+ pixelEvidence: 'available',
376
+ },
377
+ cascadeCount: 4,
378
+ mapSize: 2048,
379
+ atlasBytes: 67_108_864,
380
+ writerPasses: 4,
381
+ blockerTaps: 16,
382
+ filterTapUpperBound: 32,
383
+ seamTapUpperBound: 96,
384
+ deviceGeneration: 2,
385
+ graphGeneration: 7,
386
+ });
387
+ expect(result).toMatchObject({
388
+ requested: 'pcssHigh',
389
+ effective: 'pcssHigh',
390
+ cascadeCount: 4,
391
+ mapSize: 2048,
392
+ blockerTaps: 16,
393
+ filterTapUpperBound: 32,
394
+ seamTapUpperBound: 96,
395
+ deviceGeneration: 2,
396
+ graphGeneration: 7,
397
+ });
398
+ expect(Object.keys(result)).not.toContain('perPixel');
399
+ expect(Object.isFrozen(result)).toBe(true);
400
+ });
401
+
402
+ it('projects production facts instead of fixed request, taps, or generations', async () => {
403
+ const module = (await import(
404
+ '../../../render/src/assembly/directional-shadow-inspection'
405
+ )) as Record<string, unknown>;
406
+ const projectSource = module.projectDirectionalShadowInspectionSource;
407
+ expect(typeof projectSource).toBe('function');
408
+ const project = projectSource as (input: Record<string, unknown>) => Record<string, unknown>;
409
+ const first = project({
410
+ requested: 'pcf3',
411
+ candidate: 'accepted',
412
+ cascadeCount: 3,
413
+ mapSize: 1024,
414
+ atlasBytes: 12_582_912,
415
+ writerPasses: 3,
416
+ deviceGeneration: 4,
417
+ graphGeneration: 11,
418
+ shadowReady: true,
419
+ });
420
+ const second = project({
421
+ requested: 'pcssHigh',
422
+ candidate: 'failed',
423
+ lastKnownGood: 'pcf3',
424
+ cascadeCount: 4,
425
+ mapSize: 2048,
426
+ atlasBytes: 67_108_864,
427
+ writerPasses: 4,
428
+ deviceGeneration: 5,
429
+ graphGeneration: 12,
430
+ shadowReady: false,
431
+ });
432
+ expect(first).toMatchObject({
433
+ requested: 'pcf3',
434
+ effective: 'pcf3',
435
+ status: 'accepted',
436
+ filterTapUpperBound: 9,
437
+ seamTapUpperBound: 9,
438
+ cascadeCount: 3,
439
+ mapSize: 1024,
440
+ deviceGeneration: 4,
441
+ graphGeneration: 11,
442
+ });
443
+ expect(second).toMatchObject({
444
+ requested: 'pcssHigh',
445
+ effective: 'pcf3',
446
+ status: 'rejected',
447
+ lastKnownGood: true,
448
+ blockerTaps: 16,
449
+ filterTapUpperBound: 32,
450
+ seamTapUpperBound: 96,
451
+ cascadeCount: 4,
452
+ mapSize: 2048,
453
+ deviceGeneration: 5,
454
+ graphGeneration: 12,
455
+ });
456
+ expect(second).not.toMatchObject({ requested: 'pcf3', status: 'accepted', atlasBytes: 0 });
457
+ });
458
+ });
459
+
234
460
  // ── w5: surface retry — reconfigure + retry once (AC-03) ─────────────────────
235
461
 
236
462
  describe('Surface retry (w5)', () => {
@@ -6,6 +6,8 @@
6
6
  // cascade tile: col = i % tilesPerSide, row = floor(i / tilesPerSide).
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
10
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
9
11
 
10
12
  /**
11
13
  * Pure helper mirroring urp-pipeline.ts atlas sizing logic.
@@ -176,3 +178,42 @@ describe('CSM atlas layout (w13)', () => {
176
178
  });
177
179
  });
178
180
  });
181
+
182
+ describe('M3 inspection zero-delta structural budget', () => {
183
+ it('keeps PCF and PCSS on the same atlas and writer budget', () => {
184
+ const common = {
185
+ cascadeCount: 4,
186
+ mapSize: 2048,
187
+ atlasBytes: 67_108_864,
188
+ writerPasses: 4,
189
+ deviceGeneration: 1,
190
+ graphGeneration: 2,
191
+ } as const;
192
+ const pcf = projectDirectionalShadowInspection({
193
+ admission: resolveDirectionalShadowBackendAdmission({
194
+ backendKind: 'webgpu',
195
+ requested: 'pcf5',
196
+ candidate: 'accepted',
197
+ }),
198
+ ...common,
199
+ blockerTaps: 0,
200
+ filterTapUpperBound: 25,
201
+ seamTapUpperBound: 25,
202
+ });
203
+ const pcss = projectDirectionalShadowInspection({
204
+ admission: resolveDirectionalShadowBackendAdmission({
205
+ backendKind: 'webgpu',
206
+ requested: 'pcssHigh',
207
+ candidate: 'accepted',
208
+ }),
209
+ ...common,
210
+ blockerTaps: 16,
211
+ filterTapUpperBound: 32,
212
+ seamTapUpperBound: 96,
213
+ });
214
+ expect(pcss).toMatchObject({ effective: 'pcssHigh', cascadeCount: 4, mapSize: 2048 });
215
+ expect(pcf.writerPasses).toBe(pcss.writerPasses);
216
+ expect(pcf.atlasBytes).toBe(pcss.atlasBytes);
217
+ expect(pcf.graphGeneration).toBe(pcss.graphGeneration);
218
+ });
219
+ });
@@ -83,7 +83,11 @@ describe('CSM component schema (w1)', () => {
83
83
  error: {
84
84
  code?: string;
85
85
  hint?: string;
86
- detail?: { field?: string; value?: number; min?: number; max?: number };
86
+ detail?: {
87
+ field?: string;
88
+ actual?: number;
89
+ bound?: { kind?: string; min?: number; max?: number };
90
+ };
87
91
  };
88
92
  };
89
93
 
@@ -97,7 +101,7 @@ describe('CSM component schema (w1)', () => {
97
101
  expect(r.error.code).toBe('shadow-invalid-config');
98
102
  expect(r.error.hint).toContain('cascadeCount');
99
103
  expect(r.error.detail?.field).toBe('cascadeCount');
100
- expect(r.error.detail?.value).toBe(0);
104
+ expect(r.error.detail?.actual).toBe(0);
101
105
  });
102
106
 
103
107
  it('cascadeCount=5 -> ShadowInvalidConfigError (max=4)', () => {
@@ -110,8 +114,8 @@ describe('CSM component schema (w1)', () => {
110
114
  expect(r.error.code).toBe('shadow-invalid-config');
111
115
  expect(r.error.hint).toContain('cascadeCount');
112
116
  expect(r.error.detail?.field).toBe('cascadeCount');
113
- expect(r.error.detail?.value).toBe(5);
114
- expect(r.error.detail?.max).toBe(4);
117
+ expect(r.error.detail?.actual).toBe(5);
118
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 1, max: 4 });
115
119
  });
116
120
 
117
121
  it('splitLambda=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -124,7 +128,8 @@ describe('CSM component schema (w1)', () => {
124
128
  expect(r.error.code).toBe('shadow-invalid-config');
125
129
  expect(r.error.hint).toContain('splitLambda');
126
130
  expect(r.error.detail?.field).toBe('splitLambda');
127
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
131
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
132
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
128
133
  });
129
134
 
130
135
  it('splitLambda=1.1 -> ShadowInvalidConfigError (max=1)', () => {
@@ -137,8 +142,8 @@ describe('CSM component schema (w1)', () => {
137
142
  expect(r.error.code).toBe('shadow-invalid-config');
138
143
  expect(r.error.hint).toContain('splitLambda');
139
144
  expect(r.error.detail?.field).toBe('splitLambda');
140
- expect(r.error.detail?.value).toBeCloseTo(1.1, 5);
141
- expect(r.error.detail?.max).toBe(1);
145
+ expect(r.error.detail?.actual).toBeCloseTo(1.1, 5);
146
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
142
147
  });
143
148
 
144
149
  it('cascadeBlend=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
@@ -151,7 +156,8 @@ describe('CSM component schema (w1)', () => {
151
156
  expect(r.error.code).toBe('shadow-invalid-config');
152
157
  expect(r.error.hint).toContain('cascadeBlend');
153
158
  expect(r.error.detail?.field).toBe('cascadeBlend');
154
- expect(r.error.detail?.value).toBeCloseTo(-0.1, 5);
159
+ expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
160
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
155
161
  });
156
162
 
157
163
  it('cascadeBlend=0.6 -> ShadowInvalidConfigError (max=0.5)', () => {
@@ -164,8 +170,8 @@ describe('CSM component schema (w1)', () => {
164
170
  expect(r.error.code).toBe('shadow-invalid-config');
165
171
  expect(r.error.hint).toContain('cascadeBlend');
166
172
  expect(r.error.detail?.field).toBe('cascadeBlend');
167
- expect(r.error.detail?.value).toBeCloseTo(0.6, 5);
168
- expect(r.error.detail?.max).toBeCloseTo(0.5, 5);
173
+ expect(r.error.detail?.actual).toBeCloseTo(0.6, 5);
174
+ expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
169
175
  });
170
176
 
171
177
  it('all three new fields within valid range spawn succeeds', () => {
@@ -180,29 +186,28 @@ describe('CSM component schema (w1)', () => {
180
186
 
181
187
  // ── w2: ShadowInvalidConfigDetail.max field constructor tests ──────────
182
188
 
183
- describe('w2: ShadowInvalidConfigDetail.max constructor', () => {
184
- it('cascadeCount=5 detail.max=4 via constructor', () => {
189
+ describe('w2: ShadowInvalidConfigDetail.bound constructor', () => {
190
+ it('cascadeCount=5 bound.max=4 via constructor', () => {
185
191
  // TDD red: max param not yet on constructor.
186
192
  const err = new (ShadowInvalidConfigError as any)('cascadeCount', 5, 1, 4);
187
193
  expect(err.code).toBe('shadow-invalid-config');
188
194
  expect(err.detail.field).toBe('cascadeCount');
189
- expect(err.detail.value).toBe(5);
190
- expect(err.detail.min).toBe(1);
191
- expect(err.detail.max).toBe(4);
195
+ expect(err.detail.actual).toBe(5);
196
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 1, max: 4 });
192
197
  expect(err.hint).toContain('[1, 4]');
193
198
  });
194
199
 
195
- it('cascadeBlend=0.6 detail.max=0.5 via constructor', () => {
200
+ it('cascadeBlend=0.6 bound.max=0.5 via constructor', () => {
196
201
  const err = new (ShadowInvalidConfigError as any)('cascadeBlend', 0.6, 0, 0.5);
197
202
  expect(err.detail.field).toBe('cascadeBlend');
198
- expect(err.detail.max).toBeCloseTo(0.5, 5);
203
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
199
204
  expect(err.hint).toContain('[0, 0.5]');
200
205
  });
201
206
 
202
- it('splitLambda=1.1 detail.max=1 via constructor', () => {
207
+ it('splitLambda=1.1 bound.max=1 via constructor', () => {
203
208
  const err = new (ShadowInvalidConfigError as any)('splitLambda', 1.1, 0, 1);
204
209
  expect(err.detail.field).toBe('splitLambda');
205
- expect(err.detail.max).toBe(1);
210
+ expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 1 });
206
211
  expect(err.hint).toContain('[0, 1]');
207
212
  });
208
213
  });
@@ -260,15 +265,17 @@ describe('CSM component schema (w1)', () => {
260
265
  const err = r.error as {
261
266
  code?: string;
262
267
  hint?: string;
263
- detail?: { field?: string; value?: number; min?: number; max?: number };
268
+ detail?: {
269
+ field?: string;
270
+ actual?: number;
271
+ bound?: { kind?: string; min?: number; max?: number };
272
+ };
264
273
  };
265
274
  expect(err.code).toBe('shadow-invalid-config');
266
275
  expect(err.hint).toContain('mapSize');
267
276
  expect(err.detail?.field).toBe('mapSize');
268
- expect(err.detail?.value).toBe(0);
269
- expect(err.detail?.min).toBe(1);
270
- // mapSize < 1 has no max (lower-bound only), compatible with old callers.
271
- expect(err.detail?.max).toBeUndefined();
277
+ expect(err.detail?.actual).toBe(0);
278
+ expect(err.detail?.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
272
279
  }
273
280
  });
274
281