@forgeax/engine-runtime 0.1.27 → 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 (114) 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 -30
  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-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -417,19 +417,16 @@ type __MergedKeep =
417
417
  return parsed.value;
418
418
  }
419
419
 
420
- function localId(n: number): LocalEntityId {
421
- return n as LocalEntityId;
422
- }
423
-
424
- /** Build a minimal SceneAsset suitable for _resolveSceneGuids testing. */
420
+ /** Build a minimal keyed SceneAsset suitable for _resolveSceneGuids testing. */
425
421
  function buildTestAsset(
426
422
  nodes: Array<{ localId: number; components: Record<string, Record<string, unknown>> }>,
427
423
  ): SceneAsset {
428
- const sceneNodes: SceneEntity[] = nodes.map((n) => ({
429
- localId: localId(n.localId),
430
- components: n.components,
431
- }));
432
- return { kind: 'scene', entities: sceneNodes };
424
+ return {
425
+ kind: 'scene',
426
+ entities: Object.fromEntries(
427
+ nodes.map((n) => [`entity-${n.localId}`, { components: n.components }]),
428
+ ),
429
+ };
433
430
  }
434
431
 
435
432
  describe('w4 - _resolveSceneGuids success path', () => {
@@ -503,9 +500,9 @@ type __MergedKeep =
503
500
  if (!result.ok) return;
504
501
  const resolvedAsset = result.value;
505
502
  expect(resolvedAsset.kind).toBe('scene');
506
- expect(resolvedAsset.entities.length).toBe(1);
503
+ expect(Object.keys(resolvedAsset.entities).length).toBe(1);
507
504
 
508
- const resolvedComp = resolvedAsset.entities[0]?.components as Record<
505
+ const resolvedComp = resolvedAsset.entities['entity-0']?.components as Record<
509
506
  string,
510
507
  Record<string, unknown>
511
508
  >;
@@ -555,7 +552,7 @@ type __MergedKeep =
555
552
  expect(result.ok).toBe(true);
556
553
  if (!result.ok) return;
557
554
  const resolvedAsset = result.value;
558
- const resolvedComp = resolvedAsset.entities[0]?.components as Record<
555
+ const resolvedComp = resolvedAsset.entities['entity-0']?.components as Record<
559
556
  string,
560
557
  Record<string, unknown>
561
558
  >;
@@ -618,10 +615,10 @@ type __MergedKeep =
618
615
  if (!result.ok) return;
619
616
  const resolvedAsset = result.value;
620
617
  const comp0 = (
621
- resolvedAsset.entities[0]?.components as Record<string, Record<string, unknown>>
618
+ resolvedAsset.entities['entity-0']?.components as Record<string, Record<string, unknown>>
622
619
  ).MeshFilter;
623
620
  const comp1 = (
624
- resolvedAsset.entities[1]?.components as Record<string, Record<string, unknown>>
621
+ resolvedAsset.entities['entity-1']?.components as Record<string, Record<string, unknown>>
625
622
  ).MeshFilter;
626
623
  expect(comp0?.assetHandle).toBe(comp1?.assetHandle);
627
624
  expect(comp0?.assetHandle).toBeTypeOf('number');
@@ -648,7 +645,7 @@ type __MergedKeep =
648
645
  if (!result.ok) return;
649
646
  const resolvedAsset = result.value;
650
647
  const comp = (
651
- resolvedAsset.entities[0]?.components as Record<string, Record<string, unknown>>
648
+ resolvedAsset.entities['entity-0']?.components as Record<string, Record<string, unknown>>
652
649
  ).Transform;
653
650
  expect(comp).toEqual({ pos: [1, 2, 3] });
654
651
  });
@@ -686,7 +683,7 @@ type __MergedKeep =
686
683
  expect(e.code).toBe('asset-not-found');
687
684
  // Hint must contain the GUID, localId, and field for AI-user debuggability (AC-04)
688
685
  expect(e.hint).toContain(UNREGISTERED_GUID_STR);
689
- expect(e.hint).toContain('0'); // localId
686
+ expect(e.hint).toContain('entity-0'); // keyed entity address
690
687
  expect(e.hint).toContain('assetHandle');
691
688
  });
692
689
 
@@ -749,7 +746,7 @@ type __MergedKeep =
749
746
  expect(e.code).toBe('asset-not-found');
750
747
  // Hint should reference the first failing node (localId=1), not the second
751
748
  expect(e.hint).toContain(UNREGISTERED_GUID_STR);
752
- expect(e.hint).toContain('1'); // localId of the first failing node
749
+ expect(e.hint).toContain('entity-1'); // keyed entity address of the first failing node
753
750
  // Must NOT contain the second GUID (stopped before reaching it)
754
751
  expect(e.hint).not.toContain(SECOND_UNREGISTERED.slice(0, 8)); // prefix match sufficient
755
752
  });
@@ -820,7 +817,7 @@ type __MergedKeep =
820
817
  {
821
818
  guid: MESH_GUID_STR,
822
819
  sourceField: { componentName: 'MeshFilter', fieldName: 'assetHandle' },
823
- sceneEntityId: 0,
820
+ sceneEntityKey: 'entity-0',
824
821
  },
825
822
  {
826
823
  guid: MATERIAL_GUID_STR,
@@ -829,7 +826,7 @@ type __MergedKeep =
829
826
  fieldName: 'materials',
830
827
  arrayIndex: 0,
831
828
  },
832
- sceneEntityId: 0,
829
+ sceneEntityKey: 'entity-0',
833
830
  },
834
831
  {
835
832
  guid: MATERIAL2_GUID_STR,
@@ -838,7 +835,7 @@ type __MergedKeep =
838
835
  fieldName: 'materials',
839
836
  arrayIndex: 1,
840
837
  },
841
- sceneEntityId: 0,
838
+ sceneEntityKey: 'entity-0',
842
839
  },
843
840
  {
844
841
  guid: MATERIAL3_GUID_STR,
@@ -847,14 +844,18 @@ type __MergedKeep =
847
844
  fieldName: 'materials',
848
845
  arrayIndex: 2,
849
846
  },
850
- sceneEntityId: 0,
847
+ sceneEntityKey: 'entity-0',
851
848
  },
852
849
  ];
853
850
  reg.catalog(sceneGuid, sceneAsset, refs);
854
851
 
855
852
  const world = new World();
856
- defineComponent('MeshFilter', { assetHandle: 'shared<MeshAsset>' });
857
- defineComponent('MeshRenderer', { materials: 'array<shared<MaterialAsset>>' });
853
+ world.components
854
+ .register(defineComponent('MeshFilter', { assetHandle: 'shared<MeshAsset>' }))
855
+ .unwrap();
856
+ world.components
857
+ .register(defineComponent('MeshRenderer', { materials: 'array<shared<MaterialAsset>>' }))
858
+ .unwrap();
858
859
 
859
860
  // biome-ignore lint/suspicious/noExplicitAny: private helper access
860
861
  const internal = reg as any as {
@@ -870,9 +871,9 @@ type __MergedKeep =
870
871
  if (!result.ok) return;
871
872
  const resolvedAsset = result.value;
872
873
  expect(resolvedAsset.kind).toBe('scene');
873
- expect(resolvedAsset.entities.length).toBe(1);
874
+ expect(Object.keys(resolvedAsset.entities).length).toBe(1);
874
875
 
875
- const resolvedComp = resolvedAsset.entities[0]?.components as Record<
876
+ const resolvedComp = resolvedAsset.entities['entity-0']?.components as Record<
876
877
  string,
877
878
  Record<string, unknown>
878
879
  >;
@@ -918,7 +919,7 @@ type __MergedKeep =
918
919
  fieldName: 'materials',
919
920
  arrayIndex: 0,
920
921
  },
921
- sceneEntityId: 0,
922
+ sceneEntityKey: 'entity-0',
922
923
  },
923
924
  {
924
925
  guid: MATERIAL2_GUID_STR,
@@ -927,7 +928,7 @@ type __MergedKeep =
927
928
  fieldName: 'materials',
928
929
  arrayIndex: 1,
929
930
  },
930
- sceneEntityId: 0,
931
+ sceneEntityKey: 'entity-0',
931
932
  },
932
933
  {
933
934
  guid: MATERIAL3_GUID_STR,
@@ -936,13 +937,15 @@ type __MergedKeep =
936
937
  fieldName: 'materials',
937
938
  arrayIndex: 2,
938
939
  },
939
- sceneEntityId: 0,
940
+ sceneEntityKey: 'entity-0',
940
941
  },
941
942
  ];
942
943
  reg.catalog(sceneGuid, sceneAsset, refs);
943
944
 
944
945
  const world = new World();
945
- defineComponent('MeshRenderer', { materials: 'array<shared<MaterialAsset>>' });
946
+ world.components
947
+ .register(defineComponent('MeshRenderer', { materials: 'array<shared<MaterialAsset>>' }))
948
+ .unwrap();
946
949
 
947
950
  // biome-ignore lint/suspicious/noExplicitAny: private helper access
948
951
  const internal = reg as any as {
@@ -956,7 +959,7 @@ type __MergedKeep =
956
959
 
957
960
  expect(result.ok).toBe(true);
958
961
  if (!result.ok) return;
959
- const resolvedComp = result.value.entities[0]?.components as Record<
962
+ const resolvedComp = result.value.entities['entity-0']?.components as Record<
960
963
  string,
961
964
  Record<string, unknown>
962
965
  >;
@@ -994,18 +997,20 @@ type __MergedKeep =
994
997
  {
995
998
  guid: MESH_GUID_STR,
996
999
  sourceField: { componentName: 'MeshFilter', fieldName: 'assetHandle' },
997
- sceneEntityId: 0,
1000
+ sceneEntityKey: 'entity-0',
998
1001
  },
999
1002
  {
1000
1003
  guid: MESH_GUID_STR,
1001
1004
  sourceField: { componentName: 'MeshFilter', fieldName: 'assetHandle' },
1002
- sceneEntityId: 1,
1005
+ sceneEntityKey: 'entity-1',
1003
1006
  },
1004
1007
  ];
1005
1008
  reg.catalog(sceneGuid, sceneAsset, refs);
1006
1009
 
1007
1010
  const world = new World();
1008
- defineComponent('MeshFilter', { assetHandle: 'shared<MeshAsset>' });
1011
+ world.components
1012
+ .register(defineComponent('MeshFilter', { assetHandle: 'shared<MeshAsset>' }))
1013
+ .unwrap();
1009
1014
 
1010
1015
  // biome-ignore lint/suspicious/noExplicitAny: private helper access
1011
1016
  const internal = reg as any as {
@@ -1020,17 +1025,17 @@ type __MergedKeep =
1020
1025
  expect(result.ok).toBe(true);
1021
1026
  if (!result.ok) return;
1022
1027
  const comp0 = (
1023
- result.value.entities[0]?.components as Record<string, Record<string, unknown>>
1028
+ result.value.entities['entity-0']?.components as Record<string, Record<string, unknown>>
1024
1029
  ).MeshFilter;
1025
1030
  const comp1 = (
1026
- result.value.entities[1]?.components as Record<string, Record<string, unknown>>
1031
+ result.value.entities['entity-1']?.components as Record<string, Record<string, unknown>>
1027
1032
  ).MeshFilter;
1028
1033
  // Same GUID → same handle (one allocSharedRef per unique payload)
1029
1034
  expect(comp0?.assetHandle).toBe(comp1?.assetHandle);
1030
1035
  expect(comp0?.assetHandle).toBeTypeOf('number');
1031
1036
  });
1032
1037
 
1033
- it('(d) breadcrumb from envelope.refs: buildSceneChildContext returns correct sceneEntityId + componentField', () => {
1038
+ it('(d) breadcrumb from envelope.refs: buildSceneChildContext returns correct sceneEntityKey + componentField', () => {
1034
1039
  const reg = new AssetRegistry(makeMockShaderRegistry());
1035
1040
 
1036
1041
  registerAsset(reg, MESH_GUID_STR, makeTestMesh());
@@ -1048,7 +1053,7 @@ type __MergedKeep =
1048
1053
  {
1049
1054
  guid: MESH_GUID_STR,
1050
1055
  sourceField: { componentName: 'MeshFilter', fieldName: 'assetHandle' },
1051
- sceneEntityId: 5,
1056
+ sceneEntityKey: 'entity-5',
1052
1057
  },
1053
1058
  ];
1054
1059
  reg.catalog(sceneGuid, sceneAsset, refs);
@@ -1056,7 +1061,7 @@ type __MergedKeep =
1056
1061
  const ctx = buildSceneChildContext(reg, sceneAsset, MESH_GUID_STR.toLowerCase());
1057
1062
 
1058
1063
  expect(ctx).toBeDefined();
1059
- expect(ctx?.sceneEntityId).toBe(5);
1064
+ expect(ctx?.sceneEntityKey).toBe('entity-5');
1060
1065
  expect(ctx?.componentField).toBe('MeshFilter.assetHandle');
1061
1066
  });
1062
1067
 
@@ -1093,17 +1098,17 @@ type __MergedKeep =
1093
1098
  fieldName: 'materials',
1094
1099
  arrayIndex: 0,
1095
1100
  },
1096
- sceneEntityId: 0,
1101
+ sceneEntityKey: 'entity-0',
1097
1102
  },
1098
1103
  {
1099
1104
  guid: TEXTURE_GUID_STR,
1100
1105
  // sourceField intentionally omitted — texture edge, D-2
1101
- sceneEntityId: undefined,
1106
+ sceneEntityKey: undefined,
1102
1107
  },
1103
1108
  ];
1104
1109
  reg.catalog(sceneGuid, sceneAsset, refs);
1105
1110
 
1106
- // Look up the material GUID — should find sceneEntityId + componentField
1111
+ // Look up the material GUID — should find sceneEntityKey + componentField
1107
1112
  const materialCtx = buildSceneChildContext(
1108
1113
  reg,
1109
1114
  sceneAsset,
@@ -1115,7 +1120,7 @@ type __MergedKeep =
1115
1120
  // Look up the texture GUID — sourceField=undefined → componentField undefined
1116
1121
  const textureCtx = buildSceneChildContext(reg, sceneAsset, TEXTURE_GUID_STR.toLowerCase());
1117
1122
  expect(textureCtx).toBeDefined();
1118
- expect(textureCtx?.sceneEntityId).toBeUndefined();
1123
+ expect(textureCtx?.sceneEntityKey).toBeUndefined();
1119
1124
  expect(textureCtx?.componentField).toBeUndefined();
1120
1125
  });
1121
1126
  });
@@ -1,5 +1,5 @@
1
1
  import { World } from '@forgeax/engine-ecs';
2
- import type { Renderer } from '@forgeax/engine-render';
2
+ import type { Renderer, RendererEvent } from '@forgeax/engine-render';
3
3
  import { rhi } from '@forgeax/engine-rhi-null';
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import { requireRenderer } from './renderer-test-utils';
@@ -17,11 +17,7 @@ function frameRequest(lease: Parameters<Renderer['draw']>[0]['leases'][number])
17
17
  describe('RhiNull command flow', () => {
18
18
  it('returns the synchronous receipt after the host submits a frame', async () => {
19
19
  const renderer = await requireRenderer(canvas(), { rhi }, { shaderManifestUrl: manifest });
20
- const events: Array<{
21
- readonly kind: string;
22
- readonly frameId?: number;
23
- readonly deviceGeneration?: number;
24
- }> = [];
20
+ const events: RendererEvent[] = [];
25
21
  const unsubscribe = renderer.subscribe((event) => events.push(event));
26
22
  const world = new World();
27
23
  const attached = renderer.attach(world);
@@ -32,11 +28,17 @@ describe('RhiNull command flow', () => {
32
28
  const frame = renderer.draw(frameRequest(attached.value));
33
29
  expect(frame.ok).toBe(true);
34
30
  if (!frame.ok) return;
35
- expect(events.filter((event) => event.kind === 'frame-submitted')).toEqual([
31
+ expect(
32
+ events.filter(
33
+ (event): event is Extract<RendererEvent, { kind: 'frame-submitted' }> =>
34
+ event.kind === 'frame-submitted',
35
+ ),
36
+ ).toEqual([
36
37
  {
37
38
  kind: 'frame-submitted',
38
39
  frameId: frame.value.frameId,
39
40
  deviceGeneration: frame.value.deviceGeneration,
41
+ receipt: frame.value,
40
42
  },
41
43
  ]);
42
44
  const completed = await frame.value.completed;
@@ -106,7 +106,7 @@ describe('m2-t1: forest multi-root BFS closure + localId renumbering', () => {
106
106
  ]);
107
107
  expect(result.ok).toBe(true);
108
108
  if (!result.ok) return;
109
- expect(result.value.entities).toHaveLength(6);
109
+ expect(Object.keys(result.value.entities)).toHaveLength(6);
110
110
  });
111
111
 
112
112
  it('ancestor-descendant overlapping roots silently de-duplicate', () => {
@@ -123,7 +123,7 @@ describe('m2-t1: forest multi-root BFS closure + localId renumbering', () => {
123
123
  const result = rootsToSceneAsset(reg, world, [e0 as EntityHandle, e1 as EntityHandle]);
124
124
  expect(result.ok).toBe(true);
125
125
  if (!result.ok) return;
126
- expect(result.value.entities).toHaveLength(4);
126
+ expect(Object.keys(result.value.entities)).toHaveLength(4);
127
127
  });
128
128
 
129
129
  it('localIds are 0..N-1 continuous in BFS order', () => {
@@ -142,10 +142,10 @@ describe('m2-t1: forest multi-root BFS closure + localId renumbering', () => {
142
142
  expect(result.ok).toBe(true);
143
143
  if (!result.ok) return;
144
144
  const scene = result.value;
145
- expect(scene.entities).toHaveLength(5);
145
+ expect(Object.keys(scene.entities)).toHaveLength(5);
146
146
 
147
- const localIds = scene.entities.map((e) => e.localId as unknown as number);
148
- expect(localIds).toEqual([0, 1, 2, 3, 4]);
147
+ const keys = Object.keys(scene.entities);
148
+ expect(keys).toEqual(['entity-0', 'entity-1', 'entity-2', 'entity-3', 'entity-4']);
149
149
  });
150
150
 
151
151
  it('empty roots produces empty SceneAsset (no error)', () => {
@@ -154,7 +154,7 @@ describe('m2-t1: forest multi-root BFS closure + localId renumbering', () => {
154
154
  const result = rootsToSceneAsset(reg, world, []);
155
155
  expect(result.ok).toBe(true);
156
156
  if (!result.ok) return;
157
- expect(result.value.entities).toHaveLength(0);
157
+ expect(Object.keys(result.value.entities)).toHaveLength(0);
158
158
  });
159
159
  });
160
160
 
@@ -175,15 +175,14 @@ describe('m2-t2: entity / array<entity> -> localId', () => {
175
175
  expect(result.ok).toBe(true);
176
176
  if (!result.ok) return;
177
177
  const scene = result.value;
178
- expect(scene.entities).toHaveLength(2);
178
+ expect(Object.keys(scene.entities)).toHaveLength(2);
179
179
 
180
- const e1 = scene.entities[1];
180
+ const e1 = scene.entities['entity-1'];
181
181
  if (!e1) throw new Error('entity 1 missing');
182
182
  const refComps = (e1.components as Record<string, Record<string, unknown>>).Test_EntityRef;
183
183
  expect(refComps).toBeDefined();
184
184
  if (!refComps) throw new Error('Test_EntityRef missing');
185
- expect(refComps.target).toBeTypeOf('number');
186
- expect(refComps.target).toBe(0);
185
+ expect(refComps.target).toBe('entity-0');
187
186
  });
188
187
 
189
188
  it('array<entity> field round-trips each element as localId', () => {
@@ -205,9 +204,9 @@ describe('m2-t2: entity / array<entity> -> localId', () => {
205
204
  expect(result.ok).toBe(true);
206
205
  if (!result.ok) return;
207
206
  const scene = result.value;
208
- expect(scene.entities).toHaveLength(3);
207
+ expect(Object.keys(scene.entities)).toHaveLength(3);
209
208
 
210
- const rootEnt = scene.entities[0];
209
+ const rootEnt = scene.entities['entity-0'];
211
210
  if (!rootEnt) throw new Error('entity 0 missing');
212
211
  const elComps = (rootEnt.components as Record<string, Record<string, unknown>>)
213
212
  .Test_EntityArray;
@@ -215,7 +214,7 @@ describe('m2-t2: entity / array<entity> -> localId', () => {
215
214
  if (!elComps) throw new Error('Test_EntityArray missing');
216
215
  const targetsArr = elComps.targets as unknown[];
217
216
  expect(Array.isArray(targetsArr)).toBe(true);
218
- expect(targetsArr).toEqual([1, 2]);
217
+ expect(targetsArr).toEqual(['entity-1', 'entity-2']);
219
218
  });
220
219
  });
221
220
 
@@ -248,7 +247,7 @@ describe('m2-t3: shared<> / array<shared<>> -> GUID', () => {
248
247
  expect(result.ok).toBe(true);
249
248
  if (!result.ok) return;
250
249
  const scene = result.value;
251
- const ent0 = scene.entities[0];
250
+ const ent0 = scene.entities['entity-0'];
252
251
  if (!ent0) throw new Error('entity 0 missing');
253
252
  const comps = (ent0.components as Record<string, Record<string, unknown>>).Test_HasShared;
254
253
  expect(comps).toBeDefined();
@@ -286,7 +285,7 @@ describe('m2-t3: shared<> / array<shared<>> -> GUID', () => {
286
285
  expect(result.ok).toBe(true);
287
286
  if (!result.ok) return;
288
287
  const scene = result.value;
289
- const ent0 = scene.entities[0];
288
+ const ent0 = scene.entities['entity-0'];
290
289
  if (!ent0) throw new Error('entity 0 missing');
291
290
  const comps = (ent0.components as Record<string, Record<string, unknown>>).Test_HasSharedArray;
292
291
  expect(comps).toBeDefined();
@@ -323,7 +322,7 @@ describe('m2-t3: shared<> / array<shared<>> -> GUID', () => {
323
322
  const result = rootsToSceneAsset(reg, world, [r0 as EntityHandle]);
324
323
  expect(result.ok).toBe(true);
325
324
  if (!result.ok) return;
326
- const ent0 = result.value.entities[0];
325
+ const ent0 = result.value.entities['entity-0'];
327
326
  if (!ent0) throw new Error('entity 0 missing');
328
327
  const comps = (ent0.components as Record<string, Record<string, unknown>>).Test_EmptyMaterials;
329
328
  // An empty shared array serializes to [] (or the component is omitted when it
@@ -390,7 +389,7 @@ describe('m2-t3: shared<> / array<shared<>> -> GUID', () => {
390
389
  const result = rootsToSceneAsset(reg, world, [r0 as EntityHandle]);
391
390
  expect(result.ok).toBe(true);
392
391
  if (!result.ok) return;
393
- const ent0 = result.value.entities[0];
392
+ const ent0 = result.value.entities['entity-0'];
394
393
  if (!ent0) throw new Error('entity 0 missing');
395
394
  const comps = (ent0.components as Record<string, Record<string, unknown>>)
396
395
  .Test_UnsetScalarShared;
@@ -429,7 +428,7 @@ describe('m2-t3: shared<> / array<shared<>> -> GUID', () => {
429
428
  const result = rootsToSceneAsset(reg, world, [r0 as EntityHandle]);
430
429
  expect(result.ok).toBe(true);
431
430
  if (!result.ok) return;
432
- const ent0 = result.value.entities[0];
431
+ const ent0 = result.value.entities['entity-0'];
433
432
  if (!ent0) throw new Error('entity 0 missing');
434
433
  const comps = (ent0.components as Record<string, Record<string, unknown>>)
435
434
  .Test_SparseSharedArray;
@@ -460,9 +459,9 @@ describe('m2-t4: root ChildOf strip + cross-root closure refs', () => {
460
459
  expect(result.ok).toBe(true);
461
460
  if (!result.ok) return;
462
461
  const scene = result.value;
463
- expect(scene.entities).toHaveLength(1);
462
+ expect(Object.keys(scene.entities)).toHaveLength(1);
464
463
 
465
- const ent0 = scene.entities[0];
464
+ const ent0 = scene.entities['entity-0'];
466
465
  if (!ent0) throw new Error('entity missing');
467
466
  const comps = ent0.components as Record<string, Record<string, unknown>>;
468
467
  expect(comps.ChildOf).toBeUndefined();
@@ -481,9 +480,9 @@ describe('m2-t4: root ChildOf strip + cross-root closure refs', () => {
481
480
  expect(result.ok).toBe(true);
482
481
  if (!result.ok) return;
483
482
  const scene = result.value;
484
- expect(scene.entities).toHaveLength(3);
483
+ expect(Object.keys(scene.entities)).toHaveLength(3);
485
484
 
486
- const childEntity = scene.entities.find(
485
+ const childEntity = Object.values(scene.entities).find(
487
486
  (e) =>
488
487
  (e.components as Record<string, Record<string, unknown>>).Test_EntityRef?.target !==
489
488
  undefined,
@@ -493,9 +492,8 @@ describe('m2-t4: root ChildOf strip + cross-root closure refs', () => {
493
492
  const refVal = (childEntity.components as Record<string, Record<string, unknown>>)
494
493
  .Test_EntityRef;
495
494
  if (!refVal) throw new Error('Test_EntityRef missing');
496
- expect(typeof refVal.target).toBe('number');
497
- expect(refVal.target as number).toBeGreaterThanOrEqual(0);
498
- expect(refVal.target as number).toBeLessThan(3);
495
+ expect(typeof refVal.target).toBe('string');
496
+ expect(refVal.target).toBe('entity-2');
499
497
  });
500
498
  });
501
499
 
@@ -639,13 +637,14 @@ describe('w19 — end-to-end round-trip value equivalence (AC-07)', () => {
639
637
  // Child (GLB-like) scene + parent scene that mounts it.
640
638
  const child: SceneAsset = {
641
639
  kind: 'scene',
642
- entities: [{ localId: 0 as never, components: { Transform: { pos: [1, 0, 0] } } }],
640
+ entities: { 'entity-0': { components: { Transform: { pos: [1, 0, 0] } } } },
643
641
  };
644
642
  reg.catalog(pgc(W19_CHILD), child as Asset);
645
643
  const parent: SceneAsset = {
646
644
  kind: 'scene',
647
- entities: [{ localId: 0 as never, components: { Transform: { pos: [0, 0, 0] } } }],
648
- mounts: [{ localId: 1 as never, source: W19_CHILD, memberFirst: 2 as never, memberCount: 1 }],
645
+ entities: {
646
+ wrapper: { components: { Transform: { pos: [0, 0, 0] } }, instance: { source: W19_CHILD } },
647
+ },
649
648
  };
650
649
  reg.catalog(pgc(W19_PARENT), parent as Asset);
651
650
 
@@ -75,9 +75,9 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
75
75
  expect(collected.ok).toBe(true);
76
76
  if (!collected.ok) return;
77
77
  const scene = collected.value;
78
- expect(scene.entities).toHaveLength(1);
78
+ expect(Object.keys(scene.entities)).toHaveLength(1);
79
79
 
80
- const ent0 = scene.entities[0];
80
+ const ent0 = scene.entities['entity-0'];
81
81
  if (!ent0) throw new Error('entity 0 missing');
82
82
  const comps = ent0.components as Record<string, Record<string, unknown>>;
83
83
  const cf = comps.Test_RT_F32;
@@ -98,9 +98,9 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
98
98
  expect(collected.ok).toBe(true);
99
99
  if (!collected.ok) return;
100
100
  const scene = collected.value;
101
- expect(scene.entities).toHaveLength(1);
101
+ expect(Object.keys(scene.entities)).toHaveLength(1);
102
102
 
103
- const ent0 = scene.entities[0];
103
+ const ent0 = scene.entities['entity-0'];
104
104
  if (!ent0) throw new Error('entity 0 missing');
105
105
  const comps = ent0.components as Record<string, Record<string, unknown>>;
106
106
  const cf = comps.Test_RT_F64;
@@ -122,9 +122,9 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
122
122
  expect(collected.ok).toBe(true);
123
123
  if (!collected.ok) return;
124
124
  const scene = collected.value;
125
- expect(scene.entities).toHaveLength(1);
125
+ expect(Object.keys(scene.entities)).toHaveLength(1);
126
126
 
127
- const ent0 = scene.entities[0];
127
+ const ent0 = scene.entities['entity-0'];
128
128
  if (!ent0) throw new Error('entity 0 missing');
129
129
  const comps = ent0.components as Record<string, Record<string, unknown>>;
130
130
  const cf = comps.Test_RT_I32;
@@ -145,9 +145,9 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
145
145
  expect(collected.ok).toBe(true);
146
146
  if (!collected.ok) return;
147
147
  const scene = collected.value;
148
- expect(scene.entities).toHaveLength(1);
148
+ expect(Object.keys(scene.entities)).toHaveLength(1);
149
149
 
150
- const ent0 = scene.entities[0];
150
+ const ent0 = scene.entities['entity-0'];
151
151
  if (!ent0) throw new Error('entity 0 missing');
152
152
  const comps = ent0.components as Record<string, Record<string, unknown>>;
153
153
  const cf = comps.Test_RT_U32;
@@ -170,16 +170,18 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
170
170
  expect(collected.ok).toBe(true);
171
171
  if (!collected.ok) return;
172
172
  const scene = collected.value;
173
- expect(scene.entities).toHaveLength(2);
173
+ expect(Object.keys(scene.entities)).toHaveLength(2);
174
174
 
175
- const comps0 = (scene.entities[0]?.components as Record<string, Record<string, unknown>>)
176
- .Test_RT_BOOL;
175
+ const comps0 = (
176
+ scene.entities['entity-0']?.components as Record<string, Record<string, unknown>>
177
+ ).Test_RT_BOOL;
177
178
  expect(comps0).toBeDefined();
178
179
  if (!comps0) throw new Error('Test_RT_BOOL missing on e0');
179
180
  expect(comps0.flag).toBe(true);
180
181
 
181
- const comps1 = (scene.entities[1]?.components as Record<string, Record<string, unknown>>)
182
- .Test_RT_BOOL;
182
+ const comps1 = (
183
+ scene.entities['entity-1']?.components as Record<string, Record<string, unknown>>
184
+ ).Test_RT_BOOL;
183
185
  expect(comps1).toBeDefined();
184
186
  if (!comps1) throw new Error('Test_RT_BOOL missing on e1');
185
187
  expect(comps1.flag).toBe(false);
@@ -197,9 +199,9 @@ describe('m3-t3(a): individual primitive field round-trip', () => {
197
199
  expect(collected.ok).toBe(true);
198
200
  if (!collected.ok) return;
199
201
  const scene = collected.value;
200
- expect(scene.entities).toHaveLength(1);
202
+ expect(Object.keys(scene.entities)).toHaveLength(1);
201
203
 
202
- const ent0 = scene.entities[0];
204
+ const ent0 = scene.entities['entity-0'];
203
205
  if (!ent0) throw new Error('entity 0 missing');
204
206
  const comps = ent0.components as Record<string, Record<string, unknown>>;
205
207
  const cf = comps.Test_RT_STR;
@@ -270,10 +272,10 @@ describe('m3-t3(b): mixed entity+shared+primitive round-trip', () => {
270
272
  expect(collected.ok).toBe(true);
271
273
  if (!collected.ok) return;
272
274
  const scene = collected.value;
273
- expect(scene.entities).toHaveLength(2);
275
+ expect(Object.keys(scene.entities)).toHaveLength(2);
274
276
 
275
277
  // Entity 0 (r0): primitives
276
- const ent0 = scene.entities[0];
278
+ const ent0 = scene.entities['entity-0'];
277
279
  if (!ent0) throw new Error('e0 missing');
278
280
  const mp = (ent0.components as Record<string, Record<string, unknown>>).Test_RT_MixedPrim;
279
281
  expect(mp).toBeDefined();
@@ -286,14 +288,13 @@ describe('m3-t3(b): mixed entity+shared+primitive round-trip', () => {
286
288
  expect(mp.name).toBe('mixed-entity');
287
289
 
288
290
  // Entity 1 (r1): entity ref + shared ref
289
- const ent1 = scene.entities[1];
291
+ const ent1 = scene.entities['entity-1'];
290
292
  if (!ent1) throw new Error('e1 missing');
291
293
  const er = (ent1.components as Record<string, Record<string, unknown>>).Test_EntityRef;
292
294
  expect(er).toBeDefined();
293
295
  if (!er) throw new Error('Test_EntityRef missing');
294
- // After localId renumbering, the target should be a valid localId number.
295
- expect(typeof er.target).toBe('number');
296
- expect(er.target as number).toBeGreaterThanOrEqual(0);
296
+ // Entity references retain the keyed authoring identity.
297
+ expect(er.target).toBe('entity-0');
297
298
 
298
299
  // shared<> field must resolve to GUID string.
299
300
  const hs = (ent1.components as Record<string, Record<string, unknown>>).Test_HasShared;
@@ -326,9 +327,9 @@ describe('m3-t3(c): TypedArray->Array round-trip normalization', () => {
326
327
  expect(collected.ok).toBe(true);
327
328
  if (!collected.ok) return;
328
329
  const scene = collected.value;
329
- expect(scene.entities).toHaveLength(1);
330
+ expect(Object.keys(scene.entities)).toHaveLength(1);
330
331
 
331
- const ent0 = scene.entities[0];
332
+ const ent0 = scene.entities['entity-0'];
332
333
  if (!ent0) throw new Error('entity 0 missing');
333
334
  const comps = ent0.components as Record<string, Record<string, unknown>>;
334
335
  const cf = comps.Test_RT_ArrF32;
@@ -24,22 +24,12 @@ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
24
24
  import type { EntityHandle } from '@forgeax/engine-ecs';
25
25
  import { World } from '@forgeax/engine-ecs';
26
26
  import { Skylight } from '@forgeax/engine-render';
27
- import type {
28
- EquirectAsset,
29
- Handle,
30
- LocalEntityId,
31
- SceneAsset,
32
- SceneEntity,
33
- } from '@forgeax/engine-types';
27
+ import type { EquirectAsset, Handle, SceneAsset } from '@forgeax/engine-types';
34
28
  import { BUILTIN_BASE } from '@forgeax/engine-types';
35
29
  import { describe, expect, it } from 'vitest';
36
30
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
37
31
  import { registerSceneComponents } from './helpers/register-scene-components';
38
32
 
39
- function localId(n: number): LocalEntityId {
40
- return n as LocalEntityId;
41
- }
42
-
43
33
  // A minimal valid EquirectAsset POD (kind:'equirect', single 2D rgba16float
44
34
  // image). The roundtrip never decodes the bytes; a tiny tight-packed buffer is
45
35
  // enough to catalogue + resolve the handle.
@@ -72,14 +62,13 @@ describe('scene equirect roundtrip + instantiate handle resolution (M3 / w15)',
72
62
  // A scene whose single Skylight references the equirect source by GUID
73
63
  // string — the post-parseScenePayload intermediate state where handle
74
64
  // fields hold GUID strings awaiting resolution.
75
- const entities: SceneEntity[] = [
76
- {
77
- localId: localId(0),
65
+ const entities = {
66
+ 'entity-0': {
78
67
  components: {
79
68
  Skylight: { equirect: EQUIRECT_GUID, intensity: 1.0 },
80
69
  },
81
- } as unknown as SceneEntity,
82
- ];
70
+ },
71
+ };
83
72
  const scene: SceneAsset = { kind: 'scene', entities };
84
73
 
85
74
  const sceneHandle = world.allocSharedRef('SceneAsset', scene);