@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
@@ -8,9 +8,8 @@ import * as SceneOwner from '@forgeax/engine-scene';
8
8
  // via AssetRegistry, no external handleToGuid table needed. Those scenarios
9
9
  // are covered by m2-t3 in roots-to-scene-asset.test.ts.
10
10
  //
11
- // NOTE: rootsToSceneAsset collects the root entity itself in the BFS closure,
12
- // unlike old collectSceneAsset (which only returned SceneAsset entities).
13
- // Entity counts here include the synthetic root from instantiateScene.
11
+ // NOTE: rootsToSceneAsset omits the transient synthetic SceneInstance anchor.
12
+ // Entity counts therefore describe authored keyed declarations only.
14
13
  //
15
14
  // M1T7 AUDIT (feat-20260707-engine-world-clone-transient-for-editor-ssot):
16
15
  // Classification: (b) false-positive modification risk — all 10 tests
@@ -27,7 +26,7 @@ import { type Component, defineComponent as defineEcsComponent, World } from '@f
27
26
  import { AssetGuid } from '@forgeax/engine-pack/guid';
28
27
  import { SceneInstance } from '@forgeax/engine-render';
29
28
  import { ChildOf, Children, Transform } from '@forgeax/engine-scene';
30
- import type { LocalEntityId, SceneAsset, SceneEntity } from '@forgeax/engine-types';
29
+ import type { SceneAsset, SceneEntity } from '@forgeax/engine-types';
31
30
  import { describe, expect, it } from 'vitest';
32
31
  import { rootsToSceneAsset, serializeSceneAssetToPack } from '../collect-scene-asset';
33
32
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
@@ -50,10 +49,6 @@ function makeRegistry(): AssetRegistry {
50
49
  return new AssetRegistry(makeMockShaderRegistry());
51
50
  }
52
51
 
53
- function localId(n: number): LocalEntityId {
54
- return n as LocalEntityId;
55
- }
56
-
57
52
  // biome-ignore lint/suspicious/noExplicitAny: allocSharedRef('SceneAsset') returns branded Handle<"SceneAsset", ...> but instantiateScene expects Handle<string, ...>
58
53
  function registerSceneAsset(world: World, asset: SceneAsset): any {
59
54
  registerSceneComponents(world);
@@ -71,8 +66,12 @@ function def<T>(v: T | undefined | null, label = 'value'): T {
71
66
  }
72
67
 
73
68
  /** Find the entity in the scene that has the given component. */
74
- function findEntityWith(entities: readonly SceneEntity[], compName: string): SceneEntity {
75
- const found = entities.find(
69
+ function findEntityWith(
70
+ entities: Readonly<Record<string, SceneEntity>> | readonly SceneEntity[],
71
+ compName: string,
72
+ ): SceneEntity {
73
+ const list = Array.isArray(entities) ? entities : Object.values(entities);
74
+ const found = list.find(
76
75
  (e) => (e.components as Record<string, Record<string, unknown>>)[compName] !== undefined,
77
76
  );
78
77
  if (!found) throw new Error(`no entity with component ${compName}`);
@@ -87,11 +86,11 @@ describe('w9 — round-trip semantic equivalence', () => {
87
86
 
88
87
  const asset: SceneAsset = {
89
88
  kind: 'scene',
90
- entities: [
91
- { localId: localId(0), components: { Test_Transform: { pos: [1, 2, 3] } } },
92
- { localId: localId(1), components: { Test_Transform: { pos: [4, 5, 6] } } },
93
- { localId: localId(2), components: { Test_Transform: { pos: [7, 8, 9] } } },
94
- ],
89
+ entities: {
90
+ 'entity-0': { components: { Test_Transform: { pos: [1, 2, 3] } } },
91
+ 'entity-1': { components: { Test_Transform: { pos: [4, 5, 6] } } },
92
+ 'entity-2': { components: { Test_Transform: { pos: [7, 8, 9] } } },
93
+ },
95
94
  };
96
95
 
97
96
  const world = new World();
@@ -109,8 +108,8 @@ describe('w9 — round-trip semantic equivalence', () => {
109
108
  if (!collected.ok) return;
110
109
  const scene = collected.value;
111
110
  expect(scene.kind).toBe('scene');
112
- // rootsToSceneAsset includes the root entity + all children = 4 entities.
113
- expect(scene.entities).toHaveLength(4);
111
+ // Three authored declarations; the transient anchor is omitted.
112
+ expect(Object.keys(scene.entities)).toHaveLength(3);
114
113
 
115
114
  const entityWithTransform = findEntityWith(scene.entities, 'Test_Transform');
116
115
  expect(entityWithTransform).toBeDefined();
@@ -125,7 +124,7 @@ describe('w9 — round-trip semantic equivalence', () => {
125
124
 
126
125
  const asset: SceneAsset = {
127
126
  kind: 'scene',
128
- entities: [{ localId: localId(0), components: { Test_Pos3: { x: 10.5, y: 20.5, z: 30.5 } } }],
127
+ entities: { 'entity-0': { components: { Test_Pos3: { x: 10.5, y: 20.5, z: 30.5 } } } },
129
128
  };
130
129
 
131
130
  const world = new World();
@@ -142,8 +141,8 @@ describe('w9 — round-trip semantic equivalence', () => {
142
141
  expect(collected.ok).toBe(true);
143
142
  if (!collected.ok) return;
144
143
  const scene = collected.value;
145
- // root + 1 child = 2 entities.
146
- expect(scene.entities).toHaveLength(2);
144
+ // one authored entity; the transient anchor is omitted.
145
+ expect(Object.keys(scene.entities)).toHaveLength(1);
147
146
  const e = findEntityWith(scene.entities, 'Test_Pos3');
148
147
  const tp = def(comp(e, 'Test_Pos3'), 'Test_Pos3');
149
148
  expect(Math.abs((tp.x as number) - 10.5)).toBeLessThan(0.001);
@@ -159,7 +158,7 @@ describe('w9 — round-trip semantic equivalence', () => {
159
158
 
160
159
  const asset: SceneAsset = {
161
160
  kind: 'scene',
162
- entities: [{ localId: localId(0), components: { Test_WithDefault: { required: 7 } } }],
161
+ entities: { 'entity-0': { components: { Test_WithDefault: { required: 7 } } } },
163
162
  };
164
163
 
165
164
  const world = new World();
@@ -176,7 +175,7 @@ describe('w9 — round-trip semantic equivalence', () => {
176
175
  expect(collected.ok).toBe(true);
177
176
  if (!collected.ok) return;
178
177
  const scene = collected.value;
179
- expect(scene.entities).toHaveLength(2);
178
+ expect(Object.keys(scene.entities)).toHaveLength(1);
180
179
  const e = findEntityWith(scene.entities, 'Test_WithDefault');
181
180
  const tw = def(comp(e, 'Test_WithDefault'), 'Test_WithDefault');
182
181
  expect(tw.required).toBe(7);
@@ -184,7 +183,7 @@ describe('w9 — round-trip semantic equivalence', () => {
184
183
  });
185
184
 
186
185
  it('(d) empty scene (no entities) round-trips to empty entities[]', () => {
187
- const asset: SceneAsset = { kind: 'scene', entities: [] };
186
+ const asset: SceneAsset = { kind: 'scene', entities: {} };
188
187
  const world = new World();
189
188
  const reg = makeRegistry();
190
189
  const sg = AssetGuid.parse('00000000-0000-0000-0000-000000000000');
@@ -201,8 +200,8 @@ describe('w9 — round-trip semantic equivalence', () => {
201
200
  if (!collected.ok) return;
202
201
  const scene = collected.value;
203
202
  expect(scene.kind).toBe('scene');
204
- // Empty scene: root only (1 entity in closure — just the root).
205
- expect(scene.entities.length).toBeGreaterThanOrEqual(1);
203
+ // Empty scene has no authored declarations.
204
+ expect(Object.keys(scene.entities)).toHaveLength(0);
206
205
  });
207
206
 
208
207
  it('(e) string field round-trips through rootsToSceneAsset', () => {
@@ -212,7 +211,7 @@ describe('w9 — round-trip semantic equivalence', () => {
212
211
 
213
212
  const asset: SceneAsset = {
214
213
  kind: 'scene',
215
- entities: [{ localId: localId(0), components: { Test_Name: { label: 'hello' } } }],
214
+ entities: { 'entity-0': { components: { Test_Name: { label: 'hello' } } } },
216
215
  };
217
216
 
218
217
  const world = new World();
@@ -229,7 +228,7 @@ describe('w9 — round-trip semantic equivalence', () => {
229
228
  expect(collected.ok).toBe(true);
230
229
  if (!collected.ok) return;
231
230
  const scene = collected.value;
232
- expect(scene.entities).toHaveLength(2);
231
+ expect(Object.keys(scene.entities)).toHaveLength(1);
233
232
  const e = findEntityWith(scene.entities, 'Test_Name');
234
233
  const tn = def(comp(e, 'Test_Name'), 'Test_Name');
235
234
  expect(tn.label).toBe('hello');
@@ -242,16 +241,15 @@ describe('w9 — round-trip semantic equivalence', () => {
242
241
 
243
242
  const asset: SceneAsset = {
244
243
  kind: 'scene',
245
- entities: [
246
- {
247
- localId: localId(0),
244
+ entities: {
245
+ 'entity-0': {
248
246
  components: {
249
247
  Test_A: { val: 1 },
250
248
  Test_B: { flag: true },
251
249
  Test_C: { name: 'multi' },
252
250
  },
253
251
  },
254
- ],
252
+ },
255
253
  };
256
254
 
257
255
  const world = new World();
@@ -268,7 +266,7 @@ describe('w9 — round-trip semantic equivalence', () => {
268
266
  expect(collected.ok).toBe(true);
269
267
  if (!collected.ok) return;
270
268
  const scene = collected.value;
271
- expect(scene.entities).toHaveLength(2);
269
+ expect(Object.keys(scene.entities)).toHaveLength(1);
272
270
  const e = findEntityWith(scene.entities, 'Test_A');
273
271
  expect(def(comp(e, 'Test_A'), 'Test_A').val).toBe(1);
274
272
  expect(def(comp(e, 'Test_B'), 'Test_B').flag).toBe(true);
@@ -280,10 +278,10 @@ describe('w9 — round-trip semantic equivalence', () => {
280
278
 
281
279
  const asset: SceneAsset = {
282
280
  kind: 'scene',
283
- entities: [
284
- { localId: localId(0), components: { Test_Flag: { enabled: true } } },
285
- { localId: localId(1), components: { Test_Flag: { enabled: false } } },
286
- ],
281
+ entities: {
282
+ 'entity-0': { components: { Test_Flag: { enabled: true } } },
283
+ 'entity-1': { components: { Test_Flag: { enabled: false } } },
284
+ },
287
285
  };
288
286
 
289
287
  const world = new World();
@@ -300,11 +298,11 @@ describe('w9 — round-trip semantic equivalence', () => {
300
298
  expect(collected.ok).toBe(true);
301
299
  if (!collected.ok) return;
302
300
  const scene = collected.value;
303
- // root + 2 children = 3 entities.
304
- expect(scene.entities).toHaveLength(3);
301
+ // two authored children; the transient anchor is omitted.
302
+ expect(Object.keys(scene.entities)).toHaveLength(2);
305
303
 
306
304
  // Find the two entities with Test_Flag.
307
- const flagged = scene.entities.filter(
305
+ const flagged = Object.values(scene.entities).filter(
308
306
  (e) => (e.components as Record<string, Record<string, unknown>>).Test_Flag !== undefined,
309
307
  );
310
308
  expect(flagged).toHaveLength(2);
@@ -322,7 +320,7 @@ describe('w9 — round-trip semantic equivalence', () => {
322
320
 
323
321
  const asset: SceneAsset = {
324
322
  kind: 'scene',
325
- entities: [{ localId: localId(0), components: { Test_Pack: { a: 99 } } }],
323
+ entities: { 'entity-0': { components: { Test_Pack: { a: 99 } } } },
326
324
  };
327
325
 
328
326
  const packResult = serializeSceneAssetToPack(
@@ -344,12 +342,16 @@ describe('w9 — round-trip semantic equivalence', () => {
344
342
  expect(assetEntry.guid).toBe('aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee');
345
343
  expect(assetEntry.kind).toBe('scene');
346
344
  const payload = assetEntry.payload as Record<string, unknown>;
347
- expect(Array.isArray(payload.entities)).toBe(true);
348
- expect(payload.entities as Array<unknown>).toHaveLength(1);
345
+ expect(
346
+ payload.entities !== null &&
347
+ typeof payload.entities === 'object' &&
348
+ !Array.isArray(payload.entities),
349
+ ).toBe(true);
350
+ expect(Object.keys(payload.entities as Record<string, unknown>)).toHaveLength(1);
349
351
  });
350
352
 
351
353
  it('(i) serializeSceneAssetToPack without guid uses a generated guid', () => {
352
- const asset: SceneAsset = { kind: 'scene', entities: [] };
354
+ const asset: SceneAsset = { kind: 'scene', entities: {} };
353
355
 
354
356
  const packResult = serializeSceneAssetToPack(asset, new Map());
355
357
  expect(packResult.ok).toBe(true);
@@ -375,10 +377,10 @@ describe('w9 — round-trip semantic equivalence', () => {
375
377
 
376
378
  const asset: SceneAsset = {
377
379
  kind: 'scene',
378
- entities: [
379
- { localId: localId(0), components: { Test_Full: { posX: 1.5, posY: 2.5, name: 'e0' } } },
380
- { localId: localId(1), components: { Test_Full: { posX: 3.5, posY: 4.5, name: 'e1' } } },
381
- ],
380
+ entities: {
381
+ 'entity-0': { components: { Test_Full: { posX: 1.5, posY: 2.5, name: 'e0' } } },
382
+ 'entity-1': { components: { Test_Full: { posX: 3.5, posY: 4.5, name: 'e1' } } },
383
+ },
382
384
  };
383
385
 
384
386
  const world = new World();
@@ -394,8 +396,8 @@ describe('w9 — round-trip semantic equivalence', () => {
394
396
  const collected = rootsToSceneAsset(reg, world, [root]);
395
397
  expect(collected.ok).toBe(true);
396
398
  if (!collected.ok) return;
397
- // root + 2 children = 3 entities.
398
- expect(collected.value.entities).toHaveLength(3);
399
+ // Two authored declarations; the transient anchor is omitted.
400
+ expect(Object.keys(collected.value.entities)).toHaveLength(2);
399
401
 
400
402
  const packResult = serializeSceneAssetToPack(
401
403
  collected.value,
@@ -408,8 +410,8 @@ describe('w9 — round-trip semantic equivalence', () => {
408
410
  expect(pack.kind).toBe('internal-text-package');
409
411
  const assets = pack.assets as Array<Record<string, unknown>>;
410
412
  const payload = def(assets[0], 'assets[0]').payload as Record<string, unknown>;
411
- const entities = payload.entities as Array<Record<string, unknown>>;
412
- expect(entities).toHaveLength(3);
413
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
414
+ expect(entities).toHaveLength(2);
413
415
 
414
416
  // Find entities with Test_Full in serialized output.
415
417
  const tfEntities = entities.filter(
@@ -444,7 +446,7 @@ describe('w1 — field-level transient collect skip (AC-02 + AC-03)', () => {
444
446
  it('(AC-02) Transform entity serializes without a world key', () => {
445
447
  const asset: SceneAsset = {
446
448
  kind: 'scene',
447
- entities: [{ localId: localId(0), components: { Transform: { pos: [1, 2, 3] } } }],
449
+ entities: { 'entity-0': { components: { Transform: { pos: [1, 2, 3] } } } },
448
450
  };
449
451
 
450
452
  const world = new World();
@@ -462,7 +464,7 @@ describe('w1 — field-level transient collect skip (AC-02 + AC-03)', () => {
462
464
 
463
465
  // Every Transform-carrying entity (synthetic identity root + the authored
464
466
  // child) must omit the transient world field.
465
- const transformEntities = collected.value.entities.filter(
467
+ const transformEntities = Object.values(collected.value.entities).filter(
466
468
  (e) => (e.components as Record<string, Record<string, unknown>>).Transform !== undefined,
467
469
  );
468
470
  expect(transformEntities.length).toBeGreaterThan(0);
@@ -472,11 +474,12 @@ describe('w1 — field-level transient collect skip (AC-02 + AC-03)', () => {
472
474
  expect('world' in t).toBe(false);
473
475
  }
474
476
 
475
- // The authored (non-root) entity retains its persisted local TRS. Locate it
476
- // by its ChildOf link (the synthetic root carries no ChildOf).
477
- const authored = transformEntities.find(
478
- (e) => (e.components as Record<string, Record<string, unknown>>).ChildOf !== undefined,
479
- );
477
+ // The authored entity retains its persisted local TRS. Its implicit
478
+ // ChildOf edge to the transient anchor is omitted, so identify it by data.
479
+ const authored = transformEntities.find((e) => {
480
+ const pos = (e.components as Record<string, Record<string, unknown>>).Transform?.pos;
481
+ return Array.isArray(pos) && pos[0] === 1 && pos[1] === 2 && pos[2] === 3;
482
+ });
480
483
  const t = def(comp(def(authored, 'authored'), 'Transform'), 'Transform');
481
484
  expect(t.pos).toEqual([1, 2, 3]);
482
485
  // Reference the imported Transform token so the schema is registered.
@@ -493,12 +496,9 @@ describe('w1 — field-level transient collect skip (AC-02 + AC-03)', () => {
493
496
 
494
497
  const asset: SceneAsset = {
495
498
  kind: 'scene',
496
- entities: [
497
- {
498
- localId: localId(0),
499
- components: { W1_GenericTransient: { persisted: 7, cache: [9, 9, 9, 9] } },
500
- },
501
- ],
499
+ entities: {
500
+ 'entity-0': { components: { W1_GenericTransient: { persisted: 7, cache: [9, 9, 9, 9] } } },
501
+ },
502
502
  };
503
503
 
504
504
  const world = new World();
@@ -530,7 +530,7 @@ describe('w1 — field-level transient collect skip (AC-02 + AC-03)', () => {
530
530
 
531
531
  const asset: SceneAsset = {
532
532
  kind: 'scene',
533
- entities: [{ localId: localId(0), components: { W1_NoTransient: { a: 3, b: 4 } } }],
533
+ entities: { 'entity-0': { components: { W1_NoTransient: { a: 3, b: 4 } } } },
534
534
  };
535
535
 
536
536
  const world = new World();
@@ -561,7 +561,7 @@ describe('marker component scene-pack round-trip', () => {
561
561
  defineComponent('Test_EmptySchemaMarker', {});
562
562
  const asset: SceneAsset = {
563
563
  kind: 'scene',
564
- entities: [{ localId: localId(0), components: { Test_EmptySchemaMarker: {} } }],
564
+ entities: { 'entity-0': { components: { Test_EmptySchemaMarker: {} } } },
565
565
  };
566
566
 
567
567
  const world = new World();
@@ -589,7 +589,7 @@ describe('marker component scene-pack round-trip', () => {
589
589
  const pack = serialized.value;
590
590
  const payload = def((pack.assets as Array<Record<string, unknown>>)[0], 'assets[0]')
591
591
  .payload as Record<string, unknown>;
592
- const entities = payload.entities as Array<Record<string, unknown>>;
592
+ const entities = Object.values(payload.entities as Record<string, Record<string, unknown>>);
593
593
  const markerEntity = entities.find((entity) => {
594
594
  const components = entity.components as Record<string, unknown>;
595
595
  return components.Test_EmptySchemaMarker !== undefined;
@@ -632,22 +632,20 @@ describe('w4 -- Transform vec serialization shape (AC-05)', () => {
632
632
  });
633
633
  });
634
634
 
635
- describe('w4 -- old-shape scene JSON downgrade regression (research Finding 3)', () => {
636
- it('unknown per-axis keys are silently skipped with diagnostics, known keys still apply', () => {
635
+ describe('w4 -- old-shape scene JSON validation (research Finding 3)', () => {
636
+ it('rejects unknown per-axis keys before spawning a partial scene', () => {
637
637
  // Old 10-scalar shape scene JSON: every per-axis key is unknown after the
638
- // M2 schema cut. instantiateScene must not abort and must not dirty-write;
639
- // each unknown key surfaces one production-observable diagnostic and the
640
- // entity lands the default identity transform (Finding 3 + 4 downgrade).
638
+ // keyed SceneAsset schema cut. The producer must repair the source rather
639
+ // than silently dropping authored data.
641
640
  const asset: SceneAsset = {
642
641
  kind: 'scene',
643
- entities: [
644
- {
645
- localId: localId(0),
642
+ entities: {
643
+ 'entity-0': {
646
644
  components: {
647
645
  Transform: { posX: 5, posY: 6, posZ: 7, quatW: 1, scaleX: 2 },
648
646
  },
649
647
  },
650
- ],
648
+ },
651
649
  };
652
650
 
653
651
  const world = new World();
@@ -656,29 +654,17 @@ describe('w4 -- old-shape scene JSON downgrade regression (research Finding 3)',
656
654
  if (sg.ok) reg.catalog(sg.value, asset);
657
655
  const handle = registerSceneAsset(world, asset);
658
656
  const res = SceneOwner.worldInstantiateScene(world, handle);
659
- expect(res.ok).toBe(true);
657
+ expect(res.ok).toBe(false);
660
658
  if (!res.ok) return;
661
-
662
- // One diagnostic per unknown key, all attributed to Transform.
663
- const fields = res.value.diagnostics
664
- .filter((d) => d.component === 'Transform')
665
- .map((d) => d.field)
666
- .sort();
667
- expect(fields).toEqual(['posX', 'posY', 'posZ', 'quatW', 'scaleX']);
668
-
669
- // The carrying entity degrades to the identity transform (defaults).
670
- const tfEntity = findEntityWith(
671
- (() => {
672
- const collected = rootsToSceneAsset(reg, world, [res.value.root]);
673
- if (!collected.ok) throw new Error('collect failed');
674
- return collected.value.entities;
675
- })(),
676
- 'Transform',
677
- );
678
- const tf = def(comp(tfEntity, 'Transform'), 'Transform');
679
- expect(tf.pos).toEqual([0, 0, 0]);
680
- expect(tf.quat).toEqual([0, 0, 0, 1]);
681
- expect(tf.scale).toEqual([1, 1, 1]);
659
+ expect(res.error).toMatchObject({
660
+ code: 'asset-package-invalid',
661
+ detail: {
662
+ reason: 'unknown component field',
663
+ component: 'Transform',
664
+ field: 'posX',
665
+ entity: 'entity-0',
666
+ },
667
+ });
682
668
  });
683
669
  });
684
670
 
@@ -701,7 +687,7 @@ describe('m3 — runtime collector kernel parity', () => {
701
687
  const world = new World();
702
688
  const asset: SceneAsset = {
703
689
  kind: 'scene',
704
- entities: [{ localId: localId(0), components: { CollectorParity: { count: 42 } } }],
690
+ entities: { 'entity-0': { components: { CollectorParity: { count: 42 } } } },
705
691
  };
706
692
  const handle = registerSceneAsset(world, asset);
707
693
  const r = SceneOwner.worldInstantiateScene(world, handle);
@@ -723,10 +709,12 @@ describe('m3 — runtime collector kernel parity', () => {
723
709
  const world = new World();
724
710
  const asset: SceneAsset = {
725
711
  kind: 'scene',
726
- entities: [
727
- { localId: localId(0), components: { FixedEnt_CollectorParity: { refs: [0, 1] } } },
728
- { localId: localId(1), components: {} },
729
- ],
712
+ entities: {
713
+ 'entity-0': {
714
+ components: { FixedEnt_CollectorParity: { refs: ['entity-0', 'entity-1'] } },
715
+ },
716
+ 'entity-1': { components: {} },
717
+ },
730
718
  };
731
719
  const handle = registerSceneAsset(world, asset);
732
720
  const r = SceneOwner.worldInstantiateScene(world, handle);
@@ -767,12 +755,9 @@ describe('m3 — runtime collector kernel parity', () => {
767
755
  const world = new World();
768
756
  const asset: SceneAsset = {
769
757
  kind: 'scene',
770
- entities: [
771
- {
772
- localId: localId(0),
773
- components: { TransientField_CollectorParity: { keep: 5, derived: 99 } },
774
- },
775
- ],
758
+ entities: {
759
+ 'entity-0': { components: { TransientField_CollectorParity: { keep: 5, derived: 99 } } },
760
+ },
776
761
  };
777
762
  const handle = registerSceneAsset(world, asset);
778
763
  const r = SceneOwner.worldInstantiateScene(world, handle);
@@ -829,7 +814,7 @@ describe('m3 — runtime collector kernel parity supplemental', () => {
829
814
  const world = new World();
830
815
  const asset: SceneAsset = {
831
816
  kind: 'scene',
832
- entities: [{ localId: localId(0), components: { RoundTripComp_Sup: { val: 99 } } }],
817
+ entities: { 'entity-0': { components: { RoundTripComp_Sup: { val: 99 } } } },
833
818
  };
834
819
  const handle = registerSceneAsset(world, asset);
835
820
  const r = SceneOwner.worldInstantiateScene(world, handle);
@@ -850,9 +835,7 @@ describe('m3 — runtime collector kernel parity supplemental', () => {
850
835
  const world = new World();
851
836
  const asset: SceneAsset = {
852
837
  kind: 'scene',
853
- entities: [
854
- { localId: localId(0), components: { TransientFieldCol_Sup: { keep: 5, derived: 99 } } },
855
- ],
838
+ entities: { 'entity-0': { components: { TransientFieldCol_Sup: { keep: 5, derived: 99 } } } },
856
839
  };
857
840
  const handle = registerSceneAsset(world, asset);
858
841
  const r = SceneOwner.worldInstantiateScene(world, handle);