@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
@@ -573,6 +573,7 @@ type __MergedKeep =
573
573
 
574
574
  interface IntegrationComponents {
575
575
  Transform: unknown;
576
+ GlobalTransform: unknown;
576
577
  MeshFilter: unknown;
577
578
  MeshRenderer: unknown;
578
579
  Camera: unknown;
@@ -642,11 +643,15 @@ type __MergedKeep =
642
643
  }
643
644
 
644
645
  function spawnInstancedCube(
646
+ renderer: IntegrationRenderer,
645
647
  world: IntegrationWorld,
646
648
  C: IntegrationComponents,
647
649
  instanceCount: number,
648
- ): unknown {
649
- return world
650
+ ): { entity: unknown; collectionId: number } {
651
+ const collectionId = renderer.instances
652
+ .create({ transforms: new Float32Array(instanceCount * 16) })
653
+ .unwrap().collectionId as number;
654
+ const entity = world
650
655
  .spawn(
651
656
  { component: C.MeshFilter, data: { assetHandle: C.HANDLE_CUBE } },
652
657
  { component: C.MeshRenderer, data: {} },
@@ -658,9 +663,11 @@ type __MergedKeep =
658
663
  scale: [1, 1, 1],
659
664
  },
660
665
  },
661
- { component: C.Instances, data: { transforms: new Float32Array(instanceCount * 16) } },
666
+ { component: C.GlobalTransform, data: {} },
667
+ { component: C.Instances, data: { collectionId } },
662
668
  )
663
669
  .unwrap();
670
+ return { entity, collectionId };
664
671
  }
665
672
 
666
673
  // ── w14: AC-07 B-family error strategy ──
@@ -746,7 +753,7 @@ type __MergedKeep =
746
753
  const { renderer, world, C, errors } = await bootIntegrationRenderer(device);
747
754
 
748
755
  spawnCamera(world, C);
749
- const cube = spawnInstancedCube(world, C, 2);
756
+ const cube = spawnInstancedCube(renderer, world, C, 2);
750
757
 
751
758
  // Frame 1: allocate the instance buffer (fingerprint = 2 instances).
752
759
  drawPublished(renderer, world as WorldType);
@@ -754,7 +761,7 @@ type __MergedKeep =
754
761
  // Frame 2: fingerprint mismatch -> F12 destroys the old buffer, whose
755
762
  // raw .destroy() throws -> rhi-webgpu webgpu-runtime-error -> the F12
756
763
  // production fires errorRegistry and continues to set the new buffer.
757
- world.set(cube, C.Instances, { transforms: new Float32Array(3 * 16) });
764
+ renderer.instances.replace(cube.collectionId as never, new Float32Array(3 * 16));
758
765
  drawPublished(renderer, world as WorldType);
759
766
 
760
767
  // Sweep continued: a fresh (larger) instance buffer was still allocated
@@ -777,7 +784,7 @@ type __MergedKeep =
777
784
  const { renderer, world, C, errors } = await bootIntegrationRenderer(device);
778
785
 
779
786
  spawnCamera(world, C);
780
- const cube = spawnInstancedCube(world, C, 2);
787
+ const cube = spawnInstancedCube(renderer, world, C, 2);
781
788
 
782
789
  // Frame 1: cold allocate the 2-instance buffer (256 B).
783
790
  drawPublished(renderer, world as WorldType);
@@ -792,7 +799,7 @@ type __MergedKeep =
792
799
  // 256 B buffer, then sets a fresh 384 B one. No shadow-caster shader is
793
800
  // registered, so recordShadowPass early-exits and the main pass is the
794
801
  // sole F12 owner this frame.
795
- world.set(cube, C.Instances, { transforms: new Float32Array(3 * 16) });
802
+ renderer.instances.replace(cube.collectionId as never, new Float32Array(3 * 16));
796
803
  drawPublished(renderer, world as WorldType);
797
804
 
798
805
  // The destroyed buffer is specifically the old instance buffer (STORAGE
@@ -826,13 +833,13 @@ type __MergedKeep =
826
833
  mapSize: 1024,
827
834
  },
828
835
  });
829
- const cube = spawnInstancedCube(world, C, 2);
836
+ const cube = spawnInstancedCube(renderer, world, C, 2);
830
837
 
831
838
  drawPublished(renderer, world as WorldType);
832
839
  expect(log.destroyed).toHaveLength(0);
833
840
  const oldInstanceBuffer = latestInstanceBuffer(log, 2);
834
841
 
835
- world.set(cube, C.Instances, { transforms: new Float32Array(3 * 16) });
842
+ renderer.instances.replace(cube.collectionId as never, new Float32Array(3 * 16));
836
843
  drawPublished(renderer, world as WorldType);
837
844
 
838
845
  // The shadow pass records the instance entity before the main pass, so
@@ -43,7 +43,6 @@ describe('M5 handle-migration', () => {
43
43
  iboBytes: 0,
44
44
  indexCount: 0,
45
45
  indexFormat: 'uint16' as const,
46
- layout: '12F' as const,
47
46
  vertexCount: 0,
48
47
  indexed: false,
49
48
  topology: 'triangle-list' as const,
@@ -87,6 +87,15 @@ it.each([
87
87
  requestDevice: async () => ok(++requests === 1 ? first : replacement),
88
88
  } as never),
89
89
  } as never;
90
+ const a = requireValue(
91
+ await constructRendererHost(canvas(), { rhi }, { shaderManifestUrl: manifest }),
92
+ ).renderer;
93
+ const b = requireValue(
94
+ await constructRendererHost(canvas(), { rhi: recoverable }, { shaderManifestUrl: manifest }),
95
+ ).renderer;
96
+ const collectionA = requireValue(a.instances.create({ transforms: matrices(0, count) }));
97
+ const collectionB = requireValue(b.instances.create({ transforms: matrices(0, count) }));
98
+ expect(collectionA.collectionId).toBe(collectionB.collectionId);
90
99
  const world = new World();
91
100
  const material = world.allocSharedRef('MaterialAsset', {
92
101
  kind: 'material',
@@ -99,12 +108,12 @@ it.each([
99
108
  ],
100
109
  values: { baseColor: [1, 0, 0] },
101
110
  });
102
- const entity = world
111
+ world
103
112
  .spawn(
104
113
  { component: Transform, data: {} },
105
114
  { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
106
115
  { component: MeshRenderer, data: { materials: [material] } },
107
- { component: Instances, data: { transforms: matrices(0, count) } },
116
+ { component: Instances, data: { collectionId: collectionA.collectionId } },
108
117
  )
109
118
  .unwrap();
110
119
  world
@@ -113,12 +122,6 @@ it.each([
113
122
  { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
114
123
  )
115
124
  .unwrap();
116
- const a = requireValue(
117
- await constructRendererHost(canvas(), { rhi }, { shaderManifestUrl: manifest }),
118
- ).renderer;
119
- const b = requireValue(
120
- await constructRendererHost(canvas(), { rhi: recoverable }, { shaderManifestUrl: manifest }),
121
- ).renderer;
122
125
  const la = requireValue(a.attach(world));
123
126
  const lb = requireValue(b.attach(world));
124
127
  const ra = { leases: [la], camera: { lease: la }, environment: { lease: la } };
@@ -132,12 +135,14 @@ it.each([
132
135
  requireValue(b.draw(rb));
133
136
  expect(b.inspect().instanceCollections[0]?.uploadedBytes).toBe(0);
134
137
  const firstRevision = b.inspect().instanceCollections[0]?.revision;
135
- world.set(entity, Instances, { transforms: matrices(0.25, count) }).unwrap();
138
+ requireValue(a.instances.replace(collectionA.collectionId, matrices(0.25, count)));
139
+ requireValue(b.instances.replace(collectionB.collectionId, matrices(0.25, count)));
136
140
  requireValue(a.draw(ra));
137
- world.set(entity, Instances, { transforms: matrices(0.5, count) }).unwrap();
141
+ requireValue(a.instances.replace(collectionA.collectionId, matrices(0.5, count)));
142
+ requireValue(b.instances.replace(collectionB.collectionId, matrices(0.5, count)));
138
143
  requireValue(a.draw(ra));
139
144
  await a.dispose();
140
- expect(world.get(entity, Instances).unwrap().transforms[12]).toBe(0.5);
145
+ expect(b.instances.snapshot(collectionB.collectionId).unwrap().transforms[12]).toBe(0.5);
141
146
  requireValue(b.draw(rb));
142
147
  expect(b.inspect().instanceCollections[0]?.revision).toBeGreaterThan(firstRevision ?? 0);
143
148
  lose({ reason: 'unknown', message: 'instance recovery regression' });
@@ -145,17 +150,18 @@ it.each([
145
150
  await Promise.resolve();
146
151
  expect(b.state()).toBe('device-lost');
147
152
  const finalX = count === 129 ? 0.75 : 0.5;
148
- if (count === 129) world.set(entity, Instances, { transforms: matrices(finalX, count) }).unwrap();
153
+ if (count === 129)
154
+ requireValue(b.instances.replace(collectionB.collectionId, matrices(finalX, count)));
149
155
  const recovered = await b.recover();
150
156
  expect(recovered.ok, recovered.ok ? '' : JSON.stringify(recovered.error)).toBe(true);
151
157
  requireValue(b.draw(rb));
152
- expect(b.inspect().instanceCollections[0]?.uploadedBytes).toBe(count === 129 ? count * 64 : 0);
158
+ expect(b.inspect().instanceCollections[0]?.uploadedBytes).toBeGreaterThanOrEqual(count * 64);
153
159
  expect(b.inspect().recoveryEvidence.submissions.lastGeneration).toBe(1);
154
160
  expect(b.inspect().frame.deviceGeneration).toBe(1);
155
161
  // The GPU-driven primary lane owns no legacy instance buffer; its published
156
162
  // device generation is evidenced by the submitted frame above.
157
163
  if (count < 20000) expect(b.inspect().instanceCollections[0]?.residentGeneration).toBe(1);
158
- const retained = world.get(entity, Instances).unwrap().transforms;
164
+ const retained = b.instances.snapshot(collectionB.collectionId).unwrap().transforms;
159
165
  expect(retained.length).toBe(count * 16);
160
166
  expect(retained[12]).toBe(finalX);
161
167
  expect(retained[(count - 1) * 16 + 12]).toBe(finalX);
@@ -1,61 +1,22 @@
1
- // instances.test-d - type-level shape assertions for the migrated `Instances`
2
- // ECS component (feat-20260514-ecs-children-instances-managed-buffer-array
3
- // M3 / w14).
4
- //
5
- // Pairs with components/instances.ts (w14) which migrates the schema from
6
- // the legacy `{ buffer: 'ref', count: 'u32' }` pair (cross-coupled with the
7
- // retired `AssetRegistry.createInstancedBuffer` pipeline + the deleted
8
- // `InstancedBufferAsset` POD) to the ECS-managed
9
- // `{ transforms: 'array<f32>' }` path with `arrayStride: { transforms: 16 }`.
10
- //
11
- // The type-d coverage now locks the AC-06 spawn shape:
12
- // 1. `InstancesData.transforms` narrows to `Float32Array` (no number / no
13
- // Handle brand on the spawn surface);
14
- // 2. omitting `transforms` from `InstancesData` is a TS error;
15
- // 3. assigning a non-Float32Array (plain `number[]` or `Uint32Array`) to
16
- // `InstancesData.transforms` is a TS error.
17
- //
18
- // The legacy three @ts-expect-error block (cross-brand `Handle<MeshAsset>` /
19
- // untagged `number` / missing `count`) is retired alongside the
20
- // `Handle<InstancedBufferAsset>` brand — the spawn shape no longer carries
21
- // a handle field at all (charter "Optimal > compatible": new shape replaces
22
- // the old one in the same PR; migration registry row in AGENTS.md).
23
- //
24
- // File location note: project convention places runtime tests under
25
- // `src/__tests__/` (TS rootDir = `./src`); the plan-tasks.json target
26
- // path matches this layout.
1
+ // Type-level contract for the renderer-owned large Instances association.
27
2
 
28
- import type { InstancesData } from '@forgeax/engine-render';
3
+ import type { InstanceCollectionId, InstancesData } from '@forgeax/engine-render';
29
4
  import { describe, expectTypeOf, it } from 'vitest';
30
5
 
31
- describe('InstancesData shape (AC-06 ECS-managed array<f32> form)', () => {
32
- it('InstancesData carries a readonly transforms: Float32Array field', () => {
6
+ describe('InstancesData renderer-owned collection shape', () => {
7
+ it('carries exactly one branded collection identity', () => {
33
8
  expectTypeOf<InstancesData>().toEqualTypeOf<{
34
- readonly transforms: Float32Array;
9
+ readonly collectionId: InstanceCollectionId;
35
10
  }>();
11
+ expectTypeOf<InstancesData['collectionId']>().toEqualTypeOf<InstanceCollectionId>();
36
12
  });
37
13
 
38
- it('InstancesData.transforms narrows to Float32Array (no number / no Handle brand)', () => {
39
- expectTypeOf<InstancesData['transforms']>().toEqualTypeOf<Float32Array>();
40
- });
41
- });
42
-
43
- describe('@ts-expect-error negative assertions (AC-06 minimum 2)', () => {
44
- it('1. omitting `transforms` from InstancesData is a TS error (required field)', () => {
45
- // @ts-expect-error `transforms` is required; partial shape must not type-check.
46
- const wrong: InstancesData = {};
47
- void wrong;
48
- });
49
-
50
- it('2. plain `number[]` is not assignable to InstancesData.transforms (must be Float32Array)', () => {
51
- // @ts-expect-error `number[]` lacks the Float32Array brand.
52
- const wrong: InstancesData = { transforms: [0, 0, 0, 0] };
53
- void wrong;
54
- });
55
-
56
- it('3. `Uint32Array` is not assignable to InstancesData.transforms (Float32Array nominal)', () => {
57
- // @ts-expect-error TypedArray nominal types do not cross-assign in TS.
58
- const wrong: InstancesData = { transforms: new Uint32Array(16) };
59
- void wrong;
14
+ it('rejects an omitted association and an unbranded number', () => {
15
+ // @ts-expect-error collectionId is required.
16
+ const missing: InstancesData = {};
17
+ void missing;
18
+ // @ts-expect-error collection ids are minted by renderer.instances.create().
19
+ const unbranded: InstancesData = { collectionId: 1 };
20
+ void unbranded;
60
21
  });
61
22
  });
@@ -1,50 +1,19 @@
1
- // instantiate-mount-deferred-wiring.test.ts — D-8 red-first test for the
2
- // pre-existing structural hole that mount-collapse's serialized-Children
3
- // dependency was masking (feat-20260707-engine-world-clone-transient-for-
4
- // editor-ssot, plan-strategy D-8 / §3 static chain).
5
- //
6
- // The hole: _instantiateSceneAsset expands mounts[] FIRST (world.ts:3672-3676)
7
- // and spawns owned entities SECOND (world.ts:3768-3790). When a mount has
8
- // `mount.parent` pointing at an OWNED entity slot, that slot is still
9
- // ENTITY_NULL_RAW at mount-processing time, so the ChildOf wiring guard
10
- // (world.ts:3740-3757) silently skips — and there is NO later pass that
11
- // re-attempts the wire. The mount carrier is therefore left WITHOUT a ChildOf
12
- // edge to its owned parent.
13
- //
14
- // Before M1, the serialized `Children` array on the owned parent was written
15
- // back on reload and re-established the W -> carrier down-link (world.ts:
16
- // 3975-3987 array-of-entity remap), so collect's BFS could still reach the
17
- // anchor. M1 declared `Children` transient (no longer serialized), unmasking
18
- // the missing edge. This test asserts the edge directly, independent of
19
- // serialized Children — so it FAILS on main too (proving the hole is
20
- // PRE-EXISTING, not introduced by M1). m2t4's deferred wiring turns it green.
21
-
22
- import type { Asset } from '@forgeax/engine-assets-runtime';
23
1
  import { AssetRegistry } from '@forgeax/engine-assets-runtime';
24
2
  import { type EntityHandle, World } from '@forgeax/engine-ecs';
25
3
  import { AssetGuid } from '@forgeax/engine-pack/guid';
26
4
  import { SceneInstance } from '@forgeax/engine-render';
27
5
  import { ChildOf, Children, Transform } from '@forgeax/engine-scene';
28
- import type { Handle, SceneAsset } from '@forgeax/engine-types';
6
+ import type { Asset, Handle, SceneAsset } from '@forgeax/engine-types';
29
7
  import { describe, expect, it } from 'vitest';
30
8
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
31
9
 
32
10
  const G_CHILD = '11111111-1111-4111-8111-111111111111';
33
11
  const G_PARENT = '22222222-2222-4222-8222-222222222222';
34
12
 
35
- function mkReg(): AssetRegistry {
36
- return new AssetRegistry(makeMockShaderRegistry());
37
- }
38
- function pg(s: string): AssetGuid {
39
- const r = AssetGuid.parse(s);
40
- if (!r.ok) throw new Error(`bad GUID: ${s}`);
41
- return r.value;
42
- }
43
- function cat(reg: AssetRegistry, g: string, p: SceneAsset): void {
44
- reg.catalog(pg(g), p as Asset);
45
- }
46
- function rs(w: World, a: SceneAsset): Handle<'SceneAsset', 'shared'> {
47
- return w.allocSharedRef('SceneAsset', a);
13
+ function guid(value: string): AssetGuid {
14
+ const parsed = AssetGuid.parse(value);
15
+ if (!parsed.ok) throw new Error(`bad GUID: ${value}`);
16
+ return parsed.value;
48
17
  }
49
18
 
50
19
  function makeWorld(): World {
@@ -55,58 +24,51 @@ function makeWorld(): World {
55
24
  return world;
56
25
  }
57
26
 
58
- describe('instantiate deferred ChildOf wiring for owned-parent mounts (D-8)', () => {
59
- it('mount carrier whose parent is an owned entity gets ChildOf to that owned entity', () => {
60
- const reg = mkReg();
61
- const w = makeWorld();
62
-
63
- // Child scene = one owned entity.
27
+ describe('instantiate keyed nested instance wiring', () => {
28
+ it('attaches a nested SceneInstance root under the keyed instance entity', () => {
29
+ const registry = new AssetRegistry(makeMockShaderRegistry());
30
+ const world = makeWorld();
64
31
  const child: SceneAsset = {
65
32
  kind: 'scene',
66
- entities: [{ localId: 0 as never, components: { Transform: { pos: [7, 0, 0] } } }],
33
+ entities: { childRoot: { components: { Transform: { pos: [7, 0, 0] } } } },
67
34
  };
68
- cat(reg, G_CHILD, child);
69
-
70
- // Parent scene: one OWNED entity W (localId 0) + one mount whose parent
71
- // points at W's slot (localId 0). This is the editor's
72
- // spawnGlbSceneAsMount on-disk shape (wrapper W + nested mount).
35
+ registry.catalog(guid(G_CHILD), child as Asset);
73
36
  const parent: SceneAsset = {
74
37
  kind: 'scene',
75
- entities: [{ localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } }],
76
- mounts: [
77
- {
78
- localId: 1 as never,
79
- source: G_CHILD,
80
- memberFirst: 2 as never,
81
- memberCount: 1,
82
- parent: 0 as never,
38
+ entities: {
39
+ wrapper: {
40
+ components: { Transform: { pos: [1, 0, 0] } },
41
+ instance: { source: G_CHILD },
83
42
  },
84
- ],
43
+ },
85
44
  };
86
- cat(reg, G_PARENT, parent);
87
-
88
- const inst = reg.instantiate(rs(w, parent), w);
89
- expect(inst.ok).toBe(true);
90
- if (!inst.ok) return;
91
-
92
- // The synthetic root carries SceneInstance; mapping[localId] -> live Entity.
93
- const si = w.get(inst.value, SceneInstance);
94
- expect(si.ok).toBe(true);
95
- if (!si.ok) return;
96
- const mapping = si.value.mapping;
45
+ registry.catalog(guid(G_PARENT), parent as Asset);
97
46
 
98
- const ownedW = mapping[0] as unknown as number; // owned entity W (localId 0)
99
- const carrier = mapping[1] as unknown as EntityHandle; // mount carrier (localId 1)
100
- expect(ownedW).toBeDefined();
101
- expect(carrier).toBeDefined();
47
+ const result = registry.instantiate(
48
+ world.allocSharedRef('SceneAsset', parent) as Handle<'SceneAsset', 'shared'>,
49
+ world,
50
+ );
51
+ expect(result.ok).toBe(true);
52
+ if (!result.ok) return;
53
+ const outer = world.get(result.value, SceneInstance);
54
+ expect(outer.ok).toBe(true);
55
+ if (!outer.ok) return;
56
+ const wrapper = outer.value.mapping[0] as unknown as EntityHandle;
57
+ expect(wrapper).toBeDefined();
102
58
 
103
- // The carrier MUST have a ChildOf whose parent is the owned entity W.
104
- const co = w.get(carrier, ChildOf);
105
- expect(co.ok, 'mount carrier must carry a ChildOf component').toBe(true);
106
- if (!co.ok) return;
107
- expect(
108
- (co.value as { parent: number }).parent,
109
- 'carrier ChildOf.parent must equal the owned parent entity W',
110
- ).toBe(ownedW);
59
+ let nestedRoot: EntityHandle | undefined;
60
+ for (const entity of world.iterDescendants(result.value)) {
61
+ if (entity === result.value) continue;
62
+ if (world.get(entity, SceneInstance).ok) {
63
+ nestedRoot = entity;
64
+ break;
65
+ }
66
+ }
67
+ expect(nestedRoot).toBeDefined();
68
+ if (nestedRoot === undefined) return;
69
+ const childOf = world.get(nestedRoot, ChildOf);
70
+ expect(childOf.ok).toBe(true);
71
+ if (!childOf.ok) return;
72
+ expect(childOf.value.parent).toBe(wrapper);
111
73
  });
112
74
  });
@@ -602,6 +602,18 @@ type __MergedKeep =
602
602
  fileURLToPath(new URL('../../../render/src/assembly/webgpu-renderer.ts', import.meta.url)),
603
603
  'utf-8',
604
604
  );
605
+ const rendererBootstrapSrc = readFileSync(
606
+ fileURLToPath(
607
+ new URL('../../../render/src/assembly/webgpu-renderer-bootstrap.ts', import.meta.url),
608
+ ),
609
+ 'utf-8',
610
+ );
611
+ const rendererInternalsSrc = readFileSync(
612
+ fileURLToPath(
613
+ new URL('../../../render/src/assembly/webgpu-renderer-contract.ts', import.meta.url),
614
+ ),
615
+ 'utf-8',
616
+ );
605
617
  const rendererTypeSrc = readFileSync(
606
618
  fileURLToPath(new URL('../../../render/src/render-contract.ts', import.meta.url)),
607
619
  'utf-8',
@@ -626,9 +638,11 @@ type __MergedKeep =
626
638
  expect(createRendererSrc).toMatch(
627
639
  /new AssetRegistry\(\s*shaderCatalog,\s*internals\.importTransport\s*,\s*\[audioLoader\]\s*,\s*postSpawnResolveJoints/,
628
640
  );
629
- // The injection arrives through a dedicated non-RendererOptions internal
630
- // parameter named `importTransport` on createRenderer.
631
- expect(createRendererSrc).toMatch(/importTransport\?\s*:\s*ImportTransport/);
641
+ // The renderer implementation and backend bootstrap are split owners:
642
+ // WebGPURendererInternals carries the dedicated non-RendererOptions
643
+ // channel, and the bootstrap derives it from BundlerOptions.
644
+ expect(rendererInternalsSrc).toMatch(/importTransport\?\s*:\s*ImportTransport/);
645
+ expect(rendererBootstrapSrc).toMatch(/const importTransport = bundler\?\.importTransport/);
632
646
  });
633
647
 
634
648
  it('(AC-05) RendererOptions gains no asset-layer transport field', () => {
@@ -519,14 +519,14 @@ type __MergedKeep =
519
519
  });
520
520
 
521
521
  it('sceneLoader produces a SceneAsset for a valid payload', () => {
522
- const out = sceneLoaderLoad({ entities: [{ localId: 0, components: {} }] }, undefined);
522
+ const out = sceneLoaderLoad({ entities: { 'entity-0': { components: {} } } }, undefined);
523
523
  expect(out).toMatchObject({ kind: 'scene' });
524
524
  });
525
525
 
526
526
  it('sceneLoader returns structured error { ok: false } on refs out-of-bounds (F21)', () => {
527
527
  const ctx = mockCtx();
528
528
  const out = sceneLoader.load(
529
- { entities: [{ localId: 7, components: { MeshFilter: { assetHandle: 5 } } }] },
529
+ { entities: { 'entity-7': { components: { MeshFilter: { assetHandle: 5 } } } } },
530
530
  ['guid-a'], // length 1; index 5 is out of bounds
531
531
  ctx,
532
532
  );
@@ -535,10 +535,10 @@ type __MergedKeep =
535
535
  // instead of writing to ctx.reportParseError.
536
536
  const errResult = out as {
537
537
  ok: boolean;
538
- error?: { localId: number; index: number; refsLength: number };
538
+ error?: { entityKey: string; index: number; refsLength: number };
539
539
  };
540
540
  expect(errResult.ok).toBe(false);
541
- expect(errResult.error).toMatchObject({ localId: 7, index: 5, refsLength: 1 });
541
+ expect(errResult.error).toMatchObject({ entityKey: 'entity-7', index: 5, refsLength: 1 });
542
542
  });
543
543
 
544
544
  it('materialLoader carries parentGuid resolved from refs index', () => {
@@ -0,0 +1,29 @@
1
+ import type { RuntimeAssetBinding } from '@forgeax/engine-types';
2
+ import { it } from 'vitest';
3
+ import { commands } from 'vitest/browser';
4
+ import { verifyMaterialPublication } from './material-publication.fixture';
5
+
6
+ declare module 'vitest/browser' {
7
+ interface BrowserCommands {
8
+ startMaterialPublication(): Promise<{
9
+ id: string;
10
+ binding: RuntimeAssetBinding;
11
+ guids: string[];
12
+ }>;
13
+ updateMaterialPublication(id: string, value: 'red' | 'blue' | 'broken'): Promise<void>;
14
+ stopMaterialPublication(id: string): Promise<void>;
15
+ }
16
+ }
17
+
18
+ it('loads and rebuilds independent material programs through real Vite HTTP on browser WebGPU', async () => {
19
+ const server = await commands.startMaterialPublication();
20
+ try {
21
+ await verifyMaterialPublication({
22
+ ...server,
23
+ shaderManifestUrl: '/shaders/manifest.json',
24
+ update: (value) => commands.updateMaterialPublication(server.id, value),
25
+ });
26
+ } finally {
27
+ await commands.stopMaterialPublication(server.id);
28
+ }
29
+ }, 120_000);
@@ -0,0 +1,23 @@
1
+ import { startMaterialPublicationServer } from './material-publication.server';
2
+
3
+ const servers = new Map<string, Awaited<ReturnType<typeof startMaterialPublicationServer>>>();
4
+
5
+ export const materialPublicationCommands = {
6
+ async startMaterialPublication(_context: unknown) {
7
+ const server = await startMaterialPublicationServer();
8
+ const id = server.binding.catalogUrl;
9
+ servers.set(id, server);
10
+ return { id, binding: server.binding, guids: server.guids };
11
+ },
12
+ async updateMaterialPublication(_context: unknown, id: string, value: 'red' | 'blue' | 'broken') {
13
+ const server = servers.get(id);
14
+ if (server === undefined) throw new Error('unknown material publication test host');
15
+ await server.update(value);
16
+ },
17
+ async stopMaterialPublication(_context: unknown, id: string) {
18
+ const server = servers.get(id);
19
+ if (server === undefined) return;
20
+ servers.delete(id);
21
+ await server.close();
22
+ },
23
+ };
@@ -0,0 +1,24 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
3
+ import { it } from 'vitest';
4
+ import { verifyMaterialPublication } from './material-publication.fixture';
5
+ import { startMaterialPublicationServer } from './material-publication.server';
6
+
7
+ it('loads independent material programs through real Vite transport and rebuilds raw WGSL on Dawn', async () => {
8
+ const server = await startMaterialPublicationServer();
9
+ try {
10
+ const manifest = await buildEngineShaderManifest();
11
+ await verifyMaterialPublication({
12
+ ...server,
13
+ shaderManifestUrl: `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`,
14
+ ...(process.env.FORGEAX_MATERIAL_TAPE === undefined
15
+ ? {}
16
+ : {
17
+ saveTape: (bytes: Uint8Array) =>
18
+ writeFileSync(process.env.FORGEAX_MATERIAL_TAPE as string, bytes),
19
+ }),
20
+ });
21
+ } finally {
22
+ await server.close();
23
+ }
24
+ }, 120_000);