@forgeax/engine-scene 0.1.28 → 0.1.30

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 (38) hide show
  1. package/README.md +50 -0
  2. package/dist/__tests__/keyed-scene.integration.test.d.ts +2 -0
  3. package/dist/__tests__/keyed-scene.integration.test.d.ts.map +1 -0
  4. package/dist/assets/scene-decoder.d.ts.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +848 -229
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/instances/binding.d.ts +7 -5
  10. package/dist/instances/binding.d.ts.map +1 -1
  11. package/dist/instances/externalization.d.ts +1 -1
  12. package/dist/instances/externalization.d.ts.map +1 -1
  13. package/dist/instances/keyed.d.ts +38 -0
  14. package/dist/instances/keyed.d.ts.map +1 -0
  15. package/dist/instances/legacy.d.ts +20 -0
  16. package/dist/instances/legacy.d.ts.map +1 -0
  17. package/dist/instances/runtime-types.d.ts +20 -0
  18. package/dist/instances/runtime-types.d.ts.map +1 -0
  19. package/dist/instances/scene-instances.d.ts +27 -37
  20. package/dist/instances/scene-instances.d.ts.map +1 -1
  21. package/dist/instances/state.d.ts +6 -1
  22. package/dist/instances/state.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/__tests__/asset-owner.integration.test.ts +4 -7
  25. package/src/__tests__/flat-propagation.perf.test.ts +18 -17
  26. package/src/__tests__/keyed-scene.integration.test.ts +269 -0
  27. package/src/__tests__/scene-binding.integration.test.ts +141 -32
  28. package/src/__tests__/structural.test.ts +4 -1
  29. package/src/assets/scene-decoder.ts +123 -33
  30. package/src/collect-subtree.ts +2 -2
  31. package/src/index.ts +10 -1
  32. package/src/instances/binding.ts +26 -19
  33. package/src/instances/externalization.ts +115 -97
  34. package/src/instances/keyed.ts +505 -0
  35. package/src/instances/legacy.ts +108 -0
  36. package/src/instances/runtime-types.ts +21 -0
  37. package/src/instances/scene-instances.ts +220 -83
  38. package/src/instances/state.ts +6 -1
@@ -17,12 +17,11 @@ import { fillComponentDefaults, StaleEntityError } from '@forgeax/engine-ecs/pro
17
17
  import type {
18
18
  Handle,
19
19
  LocalEntityId,
20
- MountOverride,
21
20
  PackErrorCode,
22
21
  PackErrorDetail,
23
22
  SceneAsset,
23
+ SceneEntityAddress,
24
24
  SceneEntityRef,
25
- SceneInstanceMount,
26
25
  } from '@forgeax/engine-types';
27
26
  import {
28
27
  err,
@@ -33,7 +32,13 @@ import {
33
32
  unwrapHandle,
34
33
  } from '@forgeax/engine-types';
35
34
  import { ComponentNotDefinedError } from '../errors';
36
- import { resolveSceneEntity, validateSceneBindings } from './binding.js';
35
+ import { resolveSceneEntity, sceneEntityAddressKey } from './binding.js';
36
+ import {
37
+ type CompiledSceneAsset,
38
+ type CompiledSceneEntity,
39
+ compileKeyedSceneAsset,
40
+ } from './keyed.js';
41
+ import type { MountOverride, SceneInstanceMount } from './runtime-types.js';
37
42
  import {
38
43
  isPrimitiveScalarFieldType,
39
44
  mountOverrideStateKey,
@@ -48,39 +53,32 @@ const entityIndex = (entity: EntityHandle): number => (entity as number) & 0x00f
48
53
  const entityGeneration = (entity: EntityHandle): number => ((entity as number) >>> 24) & 0xff;
49
54
 
50
55
  /**
51
- * C-R2 (feat-20260622-s5 / studio-issues): one structured, non-fatal record of
52
- * a SceneAsset payload field that did NOT match the target component's schema.
53
- *
54
- * Scene data is loader-fed and may carry a stale / deprecated / typo'd field
55
- * (an editor renames a field, an old `.pack.json` lags). `worldInstantiateScene`
56
- * does NOT blank the whole scene over one such field (#478 lesson: a
57
- * prod-silent strip re-introduced an invisible-entity class) and does NOT abort
58
- * fatally. Instead it skips the unknown key (no write, no input mutation) and
59
- * surfaces this record on the success value's `diagnostics[]` — observable in
60
- * production (NOT NODE_ENV-gated), consumed by property access (no string parse):
56
+ * Legacy diagnostic shape retained on the scene-instantiation result for
57
+ * non-blocking runtime observations. SceneAsset schema violations are
58
+ * rejected by the keyed compiler before this result is produced; no authored
59
+ * unknown-field record is emitted by the current path.
61
60
  *
62
61
  * const r = worldInstantiateScene(world, handle);
63
62
  * if (r.ok) for (const d of r.value.diagnostics)
64
- * console.warn(`unknown field ${d.component}.${d.field} on localId ${d.localId}`);
63
+ * console.warn('scene diagnostic', d);
65
64
  *
66
- * Direct `world.spawn` / `world.addComponent` / `Commands.spawn` stay fail-fast
67
- * with `SpawnDataUnknownFieldError` — those are explicit API calls where a typo
68
- * is a programming error, not loader-fed data.
65
+ * Direct `world.spawn` / `world.addComponent` / `Commands.spawn` remain
66
+ * fail-fast with `SpawnDataUnknownFieldError`.
69
67
  */
70
68
  export type SceneInstantiateDiagnostic = {
71
- /** Component name (schema key) the unknown field appeared under. */
69
+ /** Component name associated with the observation. */
72
70
  readonly component: string;
73
- /** The offending field name not declared in the component schema. */
71
+ /** Field associated with the observation. */
74
72
  readonly field: string;
75
- /** LocalEntityId (within its owning SceneAsset) of the carrying entity. */
73
+ /** LocalEntityId within the owning SceneAsset, when applicable. */
76
74
  readonly localId: number;
77
75
  };
78
76
 
79
77
  /**
80
78
  * Success value of `worldInstantiateScene`. `root` is the synthetic scene-root
81
- * EntityHandle (carries `SceneInstance`); `diagnostics` is the (possibly empty)
82
- * list of non-fatal unknown-field records aggregated across this scene and every
83
- * recursively mounted sub-scene (C-R2). Empty array = no diagnostics.
79
+ * EntityHandle (carries `SceneInstance`); `diagnostics` contains only
80
+ * non-blocking runtime observations. Schema-invalid authored fields fail before
81
+ * an entity is created.
84
82
  */
85
83
  export type SceneInstantiateOk = {
86
84
  readonly root: EntityHandle;
@@ -137,11 +135,50 @@ export interface SceneMembersSpawn {
137
135
  readonly mount: SceneInstanceMount;
138
136
  readonly root: EntityHandle;
139
137
  readonly mapping: Uint32Array;
138
+ readonly key?: string;
140
139
  }[];
141
140
  /** `entities.length + mounts + Σ memberCount`, captured at instantiate-time. */
142
141
  readonly totalSlots: number;
143
142
  }
144
143
 
144
+ /**
145
+ * Populate the instance binding projection from the private numeric mapping.
146
+ * The authored key remains the only lookup identity: nested instance paths are
147
+ * represented as the same string/tuple key accepted by `SceneEntityRef`, while
148
+ * the numeric mapping stays local to the Scene owner.
149
+ */
150
+ function collectSceneEntityBindings(
151
+ world: World,
152
+ root: EntityHandle,
153
+ prefix: readonly string[],
154
+ bindings: Map<string, EntityHandle>,
155
+ visited = new Set<number>(),
156
+ ): void {
157
+ const rootRaw = root as unknown as number;
158
+ if (visited.has(rootRaw)) return;
159
+ visited.add(rootRaw);
160
+ const state = worldResolveSceneInstanceStatePayload(world, root);
161
+ if (!state.ok) return;
162
+ const sceneInstance = world.components.resolve('SceneInstance');
163
+ if (sceneInstance === undefined) return;
164
+ const component = world.get(root, sceneInstance);
165
+ if (!component.ok) return;
166
+ const mapping = (component.value as unknown as { mapping: ArrayLike<number> }).mapping;
167
+ for (const [slot, key] of state.value.keyByLocalId) {
168
+ const raw = mapping[slot];
169
+ if (raw === undefined || raw === ENTITY_NULL_RAW) continue;
170
+ const address: SceneEntityAddress =
171
+ prefix.length === 0 ? key : ([...prefix, key] as unknown as [string, ...string[]]);
172
+ bindings.set(sceneEntityAddressKey(address), raw as unknown as EntityHandle);
173
+ }
174
+ for (const childRoot of state.value.mountRoots) {
175
+ const childState = worldResolveSceneInstanceStatePayload(world, childRoot);
176
+ const childKey = childState.ok ? childState.value.instanceKey : undefined;
177
+ if (childKey === undefined) continue;
178
+ collectSceneEntityBindings(world, childRoot, [...prefix, childKey], bindings, visited);
179
+ }
180
+ }
181
+
145
182
  export type SceneAssetResolver = (
146
183
  source: number | string,
147
184
  parentHandle: Handle<'SceneAsset', 'shared'>,
@@ -173,8 +210,8 @@ export function worldGetSceneAssetResolver(world: World): SceneAssetResolver | n
173
210
  * const r = worldInstantiateScene(world, handle);
174
211
  * if (!r.ok) return r;
175
212
  * const { root, diagnostics } = r.value;
176
- * for (const d of diagnostics) // C-R2: unknown-field records, non-fatal
177
- * console.warn(`unknown field ${d.component}.${d.field} on localId ${d.localId}`);
213
+ * for (const d of diagnostics) // non-blocking runtime observations
214
+ * console.warn('scene diagnostic', d);
178
215
  * const inst = world.get(root, SceneInstance).value;
179
216
  * const member = inst.mapping[0]; // first member entity
180
217
  */
@@ -182,13 +219,21 @@ export function worldInstantiateScene(
182
219
  world: World,
183
220
  handle: Handle<'SceneAsset', 'shared'>,
184
221
  parent?: EntityHandle,
222
+ sceneSourceKey?: string,
185
223
  ): Result<SceneInstantiateOk, EcsError> {
186
224
  const stack = new Set<number>();
187
- // C-R2: collect non-fatal unknown-field diagnostics across this scene and
188
- // every recursively mounted sub-scene. The internal recursion writes into
189
- // this accumulator; only the public entry packages it onto the success value.
225
+ // Keep the existing result shape for non-blocking runtime observations. The
226
+ // keyed compiler rejects schema-invalid authoring data before spawning.
190
227
  const diagnostics: SceneInstantiateDiagnostic[] = [];
191
- const r = worldInstantiateSceneRec(world, handle, parent, stack, diagnostics);
228
+ const r = worldInstantiateSceneRec(
229
+ world,
230
+ handle,
231
+ parent,
232
+ stack,
233
+ diagnostics,
234
+ undefined,
235
+ sceneSourceKey,
236
+ );
192
237
  if (!r.ok) return r;
193
238
  return ok({ root: r.value, diagnostics });
194
239
  }
@@ -264,6 +309,8 @@ export function worldInstantiateSceneRec(
264
309
  parent: EntityHandle | undefined,
265
310
  stack: Set<number>,
266
311
  diagnostics: SceneInstantiateDiagnostic[],
312
+ instanceKey?: string,
313
+ sceneSourceKey?: string,
267
314
  ): Result<EntityHandle, EcsError> {
268
315
  const handleKey = unwrapHandle(handle);
269
316
  if (stack.has(handleKey)) {
@@ -287,7 +334,16 @@ export function worldInstantiateSceneRec(
287
334
  const asset = resolved.value;
288
335
  stack.add(handleKey);
289
336
  try {
290
- return worldInstantiateSceneAsset(world, handle, asset, parent, stack, diagnostics);
337
+ return worldInstantiateSceneAsset(
338
+ world,
339
+ handle,
340
+ asset,
341
+ parent,
342
+ stack,
343
+ diagnostics,
344
+ instanceKey,
345
+ sceneSourceKey,
346
+ );
291
347
  } finally {
292
348
  stack.delete(handleKey);
293
349
  }
@@ -320,9 +376,10 @@ export function worldResolveSceneAsset(
320
376
  export function worldSpawnSceneMembers(
321
377
  world: World,
322
378
  handle: Handle<'SceneAsset', 'shared'>,
323
- asset: SceneAsset,
379
+ asset: CompiledSceneAsset,
324
380
  stack: Set<number>,
325
381
  diagnostics: SceneInstantiateDiagnostic[],
382
+ mountKeys?: ReadonlyMap<number, string>,
326
383
  ): Result<SceneMembersSpawn, EcsError> {
327
384
  const sceneInstanceToken = world.components.resolve('SceneInstance');
328
385
  if (sceneInstanceToken === undefined) {
@@ -335,15 +392,6 @@ export function worldSpawnSceneMembers(
335
392
 
336
393
  const ownEntities = asset.entities;
337
394
  const ownMounts = asset.mounts ?? [];
338
- const bindingKeys = ownEntities.flatMap((entity) =>
339
- entity.bindingKey === undefined ? [] : [entity.bindingKey],
340
- );
341
- if (bindingKeys.length > 0) {
342
- const bindingCheck = validateSceneBindings(asset.sourceKey ?? '', bindingKeys);
343
- if (!bindingCheck.ok) {
344
- return err(bindingCheck.error as unknown as EcsError);
345
- }
346
- }
347
395
  const memberSum = ownMounts.reduce((s, m) => s + m.memberCount, 0);
348
396
  const countBaseline = ownEntities.length + ownMounts.length + memberSum;
349
397
  // C-R1 (studio-issues #6): mapping table must be sized to maxLocalId+1,
@@ -469,10 +517,16 @@ export function worldSpawnSceneMembers(
469
517
  const childHandle = childHandleRes.value;
470
518
 
471
519
  // Recursively instantiate the child. Its synthetic root attaches as a
472
- // child of the mount entity. The child writes its own unknown-field
473
- // diagnostics into the SAME accumulator, so they bubble to the top-level
474
- // instantiateScene result (C-R2 recursive aggregation).
475
- const childRes = worldInstantiateSceneRec(world, childHandle, mountEntity, stack, diagnostics);
520
+ // child of the mount entity; runtime observations share the same result
521
+ // accumulator and bubble to the top-level instance.
522
+ const childRes = worldInstantiateSceneRec(
523
+ world,
524
+ childHandle,
525
+ mountEntity,
526
+ stack,
527
+ diagnostics,
528
+ mountKeys?.get(mountLid),
529
+ );
476
530
  if (!childRes.ok) return childRes;
477
531
 
478
532
  // R2/B-2: cross-check mount.memberCount === child.totalSlots BEFORE
@@ -482,7 +536,12 @@ export function worldSpawnSceneMembers(
482
536
  const childInstRes = world.get(childRes.value, sceneInstanceToken);
483
537
  if (!childInstRes.ok) return childInstRes;
484
538
  const childMapping = (childInstRes.value as unknown as { mapping: Uint32Array }).mapping;
485
- mountInstances.push({ mount, root: childRes.value, mapping: childMapping });
539
+ mountInstances.push({
540
+ mount,
541
+ root: childRes.value,
542
+ mapping: childMapping,
543
+ ...(mountKeys?.get(mountLid) === undefined ? {} : { key: mountKeys.get(mountLid) }),
544
+ });
486
545
  if (childMapping.length !== mount.memberCount) {
487
546
  return err({
488
547
  code: 'pack-mount-count-mismatch' as PackErrorCode,
@@ -614,6 +673,8 @@ export function worldInstantiateSceneAsset(
614
673
  parent: EntityHandle | undefined,
615
674
  stack: Set<number>,
616
675
  diagnostics: SceneInstantiateDiagnostic[],
676
+ instanceKey?: string,
677
+ sceneSourceKey?: string,
617
678
  ): Result<EntityHandle, EcsError> {
618
679
  const sceneInstanceToken = world.components.resolve('SceneInstance');
619
680
  if (sceneInstanceToken === undefined) {
@@ -621,12 +682,26 @@ export function worldInstantiateSceneAsset(
621
682
  }
622
683
  const childOfToken = world.components.resolve('ChildOf');
623
684
 
624
- const membersRes = worldSpawnSceneMembers(world, handle, asset, stack, diagnostics);
685
+ const compiled = compileKeyedSceneAsset(world, handle, asset, {
686
+ resolveSource: (source, parentHandle) => worldResolveMountSource(world, source, parentHandle),
687
+ resolveAsset: (childHandle) => worldResolveSceneAsset(world, childHandle),
688
+ stack,
689
+ });
690
+ if (!compiled.ok) return err(compiled.error as EcsError);
691
+ const compiledAsset = compiled.value.asset;
692
+ const membersRes = worldSpawnSceneMembers(
693
+ world,
694
+ handle,
695
+ compiledAsset,
696
+ stack,
697
+ diagnostics,
698
+ compiled.value.mountKeyByLocalId,
699
+ );
625
700
  if (!membersRes.ok) return membersRes;
626
701
  const { mapping, entityToLocalId, rootEntities, mountEntitiesNeedingRootParent, totalSlots } =
627
702
  membersRes.value;
628
703
  const { mountInstances } = membersRes.value;
629
- const ownMounts = asset.mounts ?? [];
704
+ const ownMounts = compiledAsset.mounts ?? [];
630
705
 
631
706
  // 3. Spawn the synthetic root entity carrying SceneInstance.
632
707
  // First alloc the state ref so the SceneInstance.state column has a
@@ -697,7 +772,11 @@ export function worldInstantiateSceneAsset(
697
772
  const memberEntityRaw = mapping[lid as unknown as number];
698
773
  if (memberEntityRaw !== undefined && memberEntityRaw !== ENTITY_NULL_RAW) {
699
774
  const memberEntity = memberEntityRaw as unknown as EntityHandle;
700
- const applyRes = worldApplyMountOverride(world, memberEntity, ov);
775
+ const applyRes = worldApplyMountOverride(
776
+ world,
777
+ memberEntity,
778
+ worldRemapMountOverride(world, ov, mapping),
779
+ );
701
780
  if (!applyRes.ok) {
702
781
  return applyRes as Result<EntityHandle, EcsError>;
703
782
  }
@@ -707,16 +786,11 @@ export function worldInstantiateSceneAsset(
707
786
 
708
787
  const detached = new Set<LocalEntityId>();
709
788
  const bindings = new Map<string, EntityHandle>();
710
- for (const entity of asset.entities) {
711
- if (entity.bindingKey === undefined) continue;
712
- const live = mapping[entity.localId as unknown as number];
713
- if (live !== undefined && live !== ENTITY_NULL_RAW) {
714
- bindings.set(entity.bindingKey, live as unknown as EntityHandle);
715
- }
716
- }
717
789
  const state: Record<string, unknown> = {
718
790
  source: handle,
719
- sceneSourceKey: asset.sourceKey,
791
+ ...(sceneSourceKey === undefined ? {} : { sceneSourceKey }),
792
+ keyByLocalId: new Map(compiled.value.keyByLocalId),
793
+ ...(instanceKey === undefined ? {} : { instanceKey }),
720
794
  bindings,
721
795
  entityToLocalId,
722
796
  detachedLocalIds: detached,
@@ -732,6 +806,10 @@ export function worldInstantiateSceneAsset(
732
806
  // re-use the slot we allocated above by writing directly into the
733
807
  // payloads map via a `_setUniqueRefPayload` shim.
734
808
  worldSetUniqueRefPayload(world, stateRef, state);
809
+ // Populate direct and nested keyed addresses only after this root state is
810
+ // visible. Child SceneInstance states were published by the recursive spawn
811
+ // above, so the same walk can project the complete address closure.
812
+ collectSceneEntityBindings(world, rootEntity, [], bindings);
735
813
 
736
814
  // 5. Wire ChildOf for every owned root entity (no ChildOf at layer-1)
737
815
  // to the synthetic root.
@@ -792,7 +870,20 @@ export function worldInstantiateSceneAssetFlat(
792
870
  stack: Set<number>,
793
871
  diagnostics: SceneInstantiateDiagnostic[],
794
872
  ): Result<{ roots: EntityHandle[]; mountEntities: EntityHandle[] }, EcsError> {
795
- const membersRes = worldSpawnSceneMembers(world, handle, asset, stack, diagnostics);
873
+ const compiled = compileKeyedSceneAsset(world, handle, asset, {
874
+ resolveSource: (source, parentHandle) => worldResolveMountSource(world, source, parentHandle),
875
+ resolveAsset: (childHandle) => worldResolveSceneAsset(world, childHandle),
876
+ stack,
877
+ });
878
+ if (!compiled.ok) return err(compiled.error as EcsError);
879
+ const membersRes = worldSpawnSceneMembers(
880
+ world,
881
+ handle,
882
+ compiled.value.asset,
883
+ stack,
884
+ diagnostics,
885
+ compiled.value.mountKeyByLocalId,
886
+ );
796
887
  if (!membersRes.ok) return membersRes;
797
888
  const { rootEntities, mountEntitiesNeedingRootParent, mountEntities, mountInstances } =
798
889
  membersRes.value;
@@ -811,7 +902,11 @@ export function worldInstantiateSceneAssetFlat(
811
902
  const memberEntityRaw = childMapping[childLocalId];
812
903
  if (memberEntityRaw === undefined || memberEntityRaw === ENTITY_NULL_RAW) continue;
813
904
  const memberEntity = memberEntityRaw as unknown as EntityHandle;
814
- const applyRes = worldApplyMountOverride(world, memberEntity, ov);
905
+ const applyRes = worldApplyMountOverride(
906
+ world,
907
+ memberEntity,
908
+ worldRemapMountOverride(world, ov, childMapping),
909
+ );
815
910
  if (!applyRes.ok) {
816
911
  return applyRes as Result<
817
912
  { roots: EntityHandle[]; mountEntities: EntityHandle[] },
@@ -849,21 +944,15 @@ export function worldInstantiateSceneAssetFlat(
849
944
  }
850
945
  /** @internal Build ComponentData[] for one SceneEntity, remapping localIds.
851
946
  *
852
- * C-R2 (feat-20260622-s5 M6): unknown fields on a SceneAsset payload are NOT
853
- * fatal. Unlike `world.spawn` (an explicit API call where a typo is a
854
- * programming error -> `SpawnDataUnknownFieldError`), scene data is loader-fed
855
- * and may carry a stale / deprecated / typo'd field. The remap below builds a
856
- * fresh `remappedRaw` and simply SKIPS keys absent from the schema (no input
857
- * mutation — the source `raw` is never deleted-from), recording each skipped
858
- * key as a non-fatal `SceneInstantiateDiagnostic` into the passed accumulator.
859
- * All known fields still write through, so one bad field cannot blank the
860
- * entity or the scene (C-AC-02/03/04).
947
+ * SceneAsset payloads use the same schema contract as explicit ECS writes.
948
+ * Unknown fields fail before the first entity is spawned, with the component
949
+ * schema's structured error. The source object is never mutated.
861
950
  */
862
951
  export function worldBuildSceneEntityComponentDatas(
863
952
  world: World,
864
- node: import('@forgeax/engine-types').SceneEntity,
953
+ node: CompiledSceneEntity,
865
954
  mapping: Uint32Array,
866
- diagnostics: SceneInstantiateDiagnostic[],
955
+ _diagnostics: SceneInstantiateDiagnostic[],
867
956
  ): Result<ComponentData[], EcsError> {
868
957
  const out: ComponentData[] = [];
869
958
  const nodeLocalId = node.localId as unknown as number;
@@ -877,13 +966,21 @@ export function worldBuildSceneEntityComponentDatas(
877
966
  const remappedRaw: Record<string, unknown> = {};
878
967
  for (const fieldName of Object.keys(raw)) {
879
968
  const fieldType = schema[fieldName];
880
- // C-R2: unknown key -> skip (do not copy into remappedRaw, do not
881
- // mutate the source `raw`) and record a structured diagnostic. The
882
- // downstream `spawn` only sees schema-valid keys, so its own
883
- // validateComponentDataKeys gate stays green.
969
+ // Do not mutate the source `raw`. SceneAsset compilation normally catches
970
+ // this earlier; this guard keeps the private numeric projection fail-fast
971
+ // for callers that provide a precompiled asset.
884
972
  if (fieldType === undefined) {
885
- diagnostics.push({ component: compName, field: fieldName, localId: nodeLocalId });
886
- continue;
973
+ return err({
974
+ code: 'spawn-data-unknown-field',
975
+ expected: `field name in {${Object.keys(schema).sort().join(', ')}}`,
976
+ hint: `unknown field '${fieldName}' on component '${compName}' at scene localId ${nodeLocalId}`,
977
+ detail: {
978
+ component: compName,
979
+ field: fieldName,
980
+ entity: nodeLocalId,
981
+ knownFields: Object.keys(schema).sort(),
982
+ },
983
+ } as unknown as EcsError);
887
984
  }
888
985
  const value = (raw as Record<string, unknown>)[fieldName];
889
986
  const kind = classifyEntityField(token, fieldName);
@@ -905,6 +1002,46 @@ export function worldBuildSceneEntityComponentDatas(
905
1002
  }
906
1003
  return ok(out);
907
1004
  }
1005
+
1006
+ /**
1007
+ * Resolve the private local-slot values produced by keyed SceneAsset
1008
+ * compilation before an instance override is written to a live ECS row.
1009
+ * Override references are authored in the declaring parent namespace, while
1010
+ * `worldApplyMountOverride` deliberately accepts ordinary live component data.
1011
+ */
1012
+ function worldRemapMountOverride(
1013
+ world: World,
1014
+ override: MountOverride,
1015
+ mapping: Uint32Array,
1016
+ ): MountOverride {
1017
+ const token = world.components.resolve(override.comp);
1018
+ if (token === undefined) return override;
1019
+ const remapField = (field: string, value: unknown): unknown => {
1020
+ const kind = classifyEntityField(token as Component, field);
1021
+ if (kind === null) return value;
1022
+ const toLive = (slot: number): number => {
1023
+ if (slot < 0 || slot >= mapping.length) return ENTITY_NULL_RAW;
1024
+ return mapping[slot] ?? ENTITY_NULL_RAW;
1025
+ };
1026
+ return remapEntityFieldValue(value, kind, toLive);
1027
+ };
1028
+ if (override.field !== undefined) {
1029
+ return { ...override, value: remapField(override.field, override.value) };
1030
+ }
1031
+ if (
1032
+ typeof override.value !== 'object' ||
1033
+ override.value === null ||
1034
+ Array.isArray(override.value)
1035
+ ) {
1036
+ return override;
1037
+ }
1038
+ const value: Record<string, unknown> = {};
1039
+ for (const [field, fieldValue] of Object.entries(override.value as Record<string, unknown>)) {
1040
+ value[field] = remapField(field, fieldValue);
1041
+ }
1042
+ return { ...override, value };
1043
+ }
1044
+
908
1045
  /**
909
1046
  * @internal feat-20260713 M2 / w8: apply one MountOverride to a live member
910
1047
  * entity column. The `field?` shape is the add-or-patch discriminant:
@@ -1058,7 +1195,7 @@ export function worldSpawnMountEntity(
1058
1195
  mapping: Uint32Array,
1059
1196
  diagnostics: SceneInstantiateDiagnostic[],
1060
1197
  ): Result<EntityHandle, EcsError> {
1061
- const fakeNode: import('@forgeax/engine-types').SceneEntity = {
1198
+ const fakeNode: CompiledSceneEntity = {
1062
1199
  localId: mount.localId,
1063
1200
  components: mount.components ?? {},
1064
1201
  };
@@ -1203,8 +1340,11 @@ export function worldResolveSceneEntity(
1203
1340
  ): Result<EntityHandle, EcsError> {
1204
1341
  const state = worldResolveSceneInstanceStatePayload(world, root);
1205
1342
  if (!state.ok) return state;
1343
+ // Anonymous POD scenes remain addressable with an explicit empty source key.
1344
+ // Never let the caller supply the identity used for the comparison: that
1345
+ // would make an anonymous instance accept a fabricated persistent ref.
1206
1346
  const resolved = resolveSceneEntity(ref, {
1207
- sceneSourceKey: state.value.sceneSourceKey ?? ref.sceneSourceKey,
1347
+ sceneSourceKey: state.value.sceneSourceKey ?? '',
1208
1348
  bindings: state.value.bindings,
1209
1349
  });
1210
1350
  if (!resolved.ok) return err(resolved.error as unknown as EcsError);
@@ -1428,9 +1568,8 @@ export function worldRemoveSceneOverride<S extends ComponentSchema>(
1428
1568
  // Look up the source SceneAsset layer-1 value.
1429
1569
  const assetRes = worldResolveSceneAsset(world, state.source);
1430
1570
  if (!assetRes.ok) return assetRes;
1431
- const node = assetRes.value.entities.find(
1432
- (n) => (n.localId as unknown as number) === (lid as unknown as number),
1433
- );
1571
+ const key = state.keyByLocalId.get(lid as unknown as number);
1572
+ const node = key === undefined ? undefined : assetRes.value.entities[key];
1434
1573
  const layer1 = node?.components[component.name] as Record<string, unknown> | undefined;
1435
1574
  if (layer1 !== undefined && field in layer1) {
1436
1575
  const r = world.set(member, component, { [field]: layer1[field] } as Partial<InputShapeOf<S>>);
@@ -1489,9 +1628,7 @@ export function worldGetSceneAssetForInstance(
1489
1628
  * reachable from indegree-0 (the fallback caller handles cycle reporting via
1490
1629
  * `pack-cyclic-reference` at the upstream scanner / runtime path).
1491
1630
  */
1492
- function sceneTopoSort(
1493
- nodes: readonly import('@forgeax/engine-types').SceneEntity[],
1494
- ): readonly number[] {
1631
+ function sceneTopoSort(nodes: readonly CompiledSceneEntity[]): readonly number[] {
1495
1632
  const n = nodes.length;
1496
1633
  const childrenOf: number[][] = Array.from({ length: n }, () => []);
1497
1634
  const indeg = new Uint32Array(n);
@@ -1,10 +1,15 @@
1
1
  import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
- import type { Handle, LocalEntityId, MountOverride } from '@forgeax/engine-types';
2
+ import type { Handle, LocalEntityId } from '@forgeax/engine-types';
3
+ import type { MountOverride } from './runtime-types.js';
3
4
 
4
5
  /** Internal state retained by a SceneInstance root. */
5
6
  export interface SceneInstanceStatePayload {
6
7
  readonly source: Handle<'SceneAsset', 'shared'>;
7
8
  readonly sceneSourceKey?: string;
9
+ /** Authored key for each private numeric slot, retained for collection. */
10
+ readonly keyByLocalId: Map<number, string>;
11
+ /** Authored key of this instance when it is nested in a parent scene. */
12
+ readonly instanceKey?: string;
8
13
  readonly bindings: Map<string, EntityHandle>;
9
14
  readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
10
15
  readonly detachedLocalIds: Set<LocalEntityId>;