@forgeax/engine-ecs 0.1.26 → 0.1.28

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 (50) hide show
  1. package/README.md +94 -35
  2. package/dist/__tests__/world-read.unit.test.d.ts +2 -0
  3. package/dist/__tests__/world-read.unit.test.d.ts.map +1 -0
  4. package/dist/commands.d.ts +2 -0
  5. package/dist/commands.d.ts.map +1 -1
  6. package/dist/index.mjs +3471 -4263
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/internal.d.ts +2 -3
  9. package/dist/internal.d.ts.map +1 -1
  10. package/dist/internal.mjs +3 -333
  11. package/dist/internal.mjs.map +1 -1
  12. package/dist/projection/index.mjs.map +1 -1
  13. package/dist/shared.mjs.map +1 -1
  14. package/dist/world-entity-lifecycle.d.ts +3 -14
  15. package/dist/world-entity-lifecycle.d.ts.map +1 -1
  16. package/dist/world-internal.d.ts +65 -5
  17. package/dist/world-internal.d.ts.map +1 -1
  18. package/dist/world-read.d.ts +16 -0
  19. package/dist/world-read.d.ts.map +1 -0
  20. package/dist/world-read.mjs +8 -0
  21. package/dist/world-read.mjs.map +1 -0
  22. package/dist/world-scheduling.d.ts +0 -4
  23. package/dist/world-scheduling.d.ts.map +1 -1
  24. package/dist/world-storage-primitives.d.ts +26 -0
  25. package/dist/world-storage-primitives.d.ts.map +1 -0
  26. package/dist/world.d.ts +352 -157
  27. package/dist/world.d.ts.map +1 -1
  28. package/package.json +8 -4
  29. package/src/__tests__/command-buffer.test.ts +29 -3
  30. package/src/__tests__/hierarchy.unit.test.ts +3 -3
  31. package/src/__tests__/world-health.contract.test.ts +195 -2
  32. package/src/__tests__/world-read.unit.test.ts +30 -0
  33. package/src/commands.ts +22 -9
  34. package/src/internal.ts +5 -3
  35. package/src/world-entity-lifecycle.ts +23 -252
  36. package/src/world-internal-augmentation.d.ts +11 -0
  37. package/src/world-internal.ts +114 -63
  38. package/src/world-read.ts +38 -0
  39. package/src/world-scheduling.ts +0 -26
  40. package/src/world-storage-primitives.ts +179 -0
  41. package/src/world.ts +2590 -509
  42. package/dist/world-component-access.d.ts +0 -311
  43. package/dist/world-component-access.d.ts.map +0 -1
  44. package/dist/world-component-storage.d.ts +0 -298
  45. package/dist/world-component-storage.d.ts.map +0 -1
  46. package/dist/world-core.d.ts +0 -39
  47. package/dist/world-core.d.ts.map +0 -1
  48. package/src/world-component-access.ts +0 -1769
  49. package/src/world-component-storage.ts +0 -1264
  50. package/src/world-core.ts +0 -74
package/src/world.ts CHANGED
@@ -10,49 +10,84 @@
10
10
  // build-time / infrastructure failures.
11
11
 
12
12
  import type { Handle, Result } from '@forgeax/engine-types';
13
- import { err, ok } from '@forgeax/engine-types';
14
- import type { BufferPool } from './buffer-pool';
15
13
  import {
14
+ BUILTIN_BASE,
15
+ err,
16
+ isRetiredSlot,
17
+ ok,
18
+ toShared,
19
+ toUnique,
20
+ unwrapHandle,
21
+ } from '@forgeax/engine-types';
22
+ import { BufferPool } from './buffer-pool';
23
+ import {
24
+ type ArrayMeta,
25
+ bufferFieldByteLength,
16
26
  type Component,
17
27
  ComponentCatalog,
18
28
  type ComponentSchema,
19
29
  componentId,
30
+ componentSchema,
20
31
  type InputShapeOf,
32
+ isEntityField,
33
+ isManagedArrayField,
34
+ isManagedBufferField,
35
+ isManagedField,
21
36
  type ShapeOf,
37
+ TYPE_METADATA,
22
38
  } from './component';
23
- import { type EntityHandle, encodeEntity, entityGeneration, entityIndex } from './entity-handle';
39
+ import { fillComponentDefaults, validateComponentDataKeys } from './component-default-fallback';
40
+ import { componentDefinition, expandComponentRequirements } from './component-schema';
41
+ import { validateManagedArrayValues, validateSharedFieldValues } from './component-value-validate';
42
+ import { Entity as EntityComponent } from './entity';
43
+ import {
44
+ ENTITY_MAX_INDEX,
45
+ ENTITY_NULL_RAW,
46
+ type EntityHandle,
47
+ encodeEntity,
48
+ entityGeneration,
49
+ entityIndex,
50
+ } from './entity-handle';
24
51
  import type {
25
52
  CommandFailedError,
26
- ComponentAlreadyPresentError,
27
53
  ComponentFieldInvalidValueError,
28
54
  ComponentNotDefinedError,
29
- ComponentNotPresentError,
30
55
  ComponentNumericValueInvalidError,
31
56
  DerivedRangeOutOfBoundsError,
32
- FixedSizeMismatchError,
57
+ ManagedArrayErrorEnvelope,
33
58
  ManagedArrayInvalidValueError,
34
- ManagedBufferOutOfBoundsError,
35
59
  ManagedBufferShrinkNotSupportedError,
36
60
  RelationshipDetachMismatchError,
37
61
  RelationshipMirrorComponentNotRegisteredError,
38
62
  RelationshipMirrorFieldTypeMismatchError,
39
- RelationshipSelfCycleError,
40
- RemoveEssentialComponentError,
41
63
  ScheduleMutationError,
42
64
  ScheduleScopeMismatchError,
43
65
  SharedKernelEligibilityError,
44
66
  SharedKernelFailureError,
45
- StaleEntityError,
46
67
  SystemFailedError,
47
68
  SystemSetNotRegisteredError,
48
69
  TimeConfigInvalidError,
49
70
  TimeDeltaInvalidError,
50
71
  UniqueRefDoubleReleaseError,
51
72
  UniqueRefReleasedError,
73
+ } from './errors';
74
+ import {
75
+ ChangeEpochExhaustedError,
76
+ ComponentAlreadyPresentError,
77
+ ComponentNotPresentError,
78
+ EntityIndexOverflowError,
79
+ FixedSizeMismatchError,
80
+ ManagedBufferOutOfBoundsError,
81
+ RelationshipSelfCycleError,
82
+ RelationshipTargetReadonlyError,
83
+ RemoveEssentialComponentError,
84
+ StaleEntityError,
85
+ validateEnumFieldValues,
86
+ validateNumericFieldValues,
52
87
  WorldPoisonedError,
53
88
  } from './errors';
54
- import { ChangeEpochExhaustedError, RelationshipTargetReadonlyError } from './errors';
55
89
  import {
90
+ createWorldIdentity,
56
91
  healthyWorldExecutionState,
57
92
  poisonedWorldExecutionState,
58
93
  type WorldExecutionFault,
@@ -62,38 +97,52 @@ import type { QueryDescriptor } from './query/query';
62
97
  import { createQuery, type Query, type QueryCreationError } from './query/query';
63
98
  import {
64
99
  isRelationshipTarget,
100
+ RelationshipIndex,
65
101
  type RelationshipTargetComponent,
102
+ relationshipMirror,
66
103
  relationshipRole,
104
+ relationshipSource,
67
105
  } from './relationship-index';
68
106
  import { createResourceStore, type ResourceStore } from './resource';
69
- import { createSchedule, type Schedule, type SystemDescriptor, type SystemSet } from './schedule';
107
+ import { createSchedule, type SystemDescriptor, type SystemSet } from './schedule';
70
108
  import { FixedUpdate, Update } from './schedule-token';
71
109
  import type { SharedRefStore } from './shared-ref-store';
72
- import type { Archetype } from './storage/archetype';
73
- import type { ArchetypeGraph } from './storage/archetype-graph';
110
+ import { SharedRefStore as SharedRefStoreImpl } from './shared-ref-store';
111
+ import { type Archetype, appendArchetypeRow, removeArchetypeRow } from './storage/archetype';
112
+ import {
113
+ type ArchetypeGraph,
114
+ createArchetypeGraph,
115
+ getAddEdge,
116
+ getOrCreateArchetype,
117
+ getRemoveEdge,
118
+ getTable,
119
+ } from './storage/archetype-graph';
74
120
  import {
75
121
  type ChangeTicks,
122
+ copyComponentEpoch,
76
123
  markComponentChanged,
77
124
  markComponentsAdded,
78
125
  readComponentChange,
126
+ removeSparseTag,
79
127
  } from './storage/change-detection';
80
- import type {
81
- StructuralEvidenceInput,
82
- StructuralEvidenceRing,
83
- } from './storage/structural-evidence';
84
128
  import {
85
- type ClockWriter,
129
+ arrayCountColumnName,
130
+ type Column,
131
+ type FieldView,
132
+ normalizeBufferWrite,
133
+ } from './storage/column';
134
+ import type { StructuralEvidenceInput } from './storage/structural-evidence';
135
+ import { StructuralEvidenceRing as StructuralEvidenceRingImpl } from './storage/structural-evidence';
136
+ import { appendTableRow, removeTableRow, type Table } from './storage/table';
137
+ import {
86
138
  createWorldClock,
87
139
  DEFAULT_TIME_POLICY,
88
140
  FIXED_TIME_RESOURCE_KEY,
89
141
  TIME_RESOURCE_KEY,
90
142
  type WorldOptions,
91
143
  } from './time';
92
- import type { UniqueRefStore } from './unique-ref-store';
93
- import { WorldComponentAccess } from './world-component-access';
94
- import { WorldCore } from './world-core';
144
+ import { type UniqueRefStore, UniqueRefStore as UniqueRefStoreImpl } from './unique-ref-store';
95
145
  import {
96
- despawnCore,
97
146
  spawnCore,
98
147
  worldAddChild,
99
148
  worldIterAncestors,
@@ -101,17 +150,15 @@ import {
101
150
  worldRemoveChild,
102
151
  worldReparent,
103
152
  } from './world-entity-lifecycle';
104
- import { type WorldInternal, worldInternal } from './world-internal';
153
+ import { worldInternal } from './world-internal';
154
+ import { type WorldRead, worldRead } from './world-read';
105
155
  import {
106
156
  worldAddSystem,
107
157
  worldAddSystems,
108
- worldAllocSharedRef,
109
- worldAllocUniqueRef,
110
158
  worldGetResource,
111
159
  worldHasResource,
112
160
  worldInsertResource,
113
161
  worldInspect,
114
- worldInternSharedRef,
115
162
  worldRemoveResource,
116
163
  worldRemoveSystem,
117
164
  worldReplaceSystem,
@@ -119,6 +166,14 @@ import {
119
166
  worldScheduleUsesComponent,
120
167
  worldUpdate,
121
168
  } from './world-scheduling';
169
+ import {
170
+ detachWorldInspection,
171
+ elementByteSize,
172
+ readArrayElementAt,
173
+ reinterpretBufferRegion,
174
+ reinterpretSlotBytes,
175
+ writeArrayElementAt,
176
+ } from './world-storage-primitives';
122
177
 
123
178
  /**
124
179
  * Union of all EcsError types that World methods can return via Result.
@@ -174,6 +229,8 @@ export interface ComponentData<S extends ComponentSchema = ComponentSchema> {
174
229
 
175
230
  type WritableComponent<C extends Component> = C extends RelationshipTargetComponent ? never : C;
176
231
 
232
+ type ErrorContext = { readonly systemName: string };
233
+
177
234
  /**
178
235
  * Per-archetype summary returned by `world.inspect()`. Sorted ComponentId key
179
236
  * (always prefixed by the essential id=0 Entity column, e.g. "0+2+5+7"),
@@ -285,8 +342,10 @@ export interface WorldScheduleData {
285
342
  * permanently (it is never pushed back to `freeIndices`). The single liveness
286
343
  * predicate is therefore "handle gen matches AND archetypeId !== -1" -- see
287
344
  * `World.recordIsLive`. A deferred-spawn allocation is "pending" when
288
- * archetypeId === -1 (not yet materialized into an archetype row); no separate
289
- * boolean is needed.
345
+ * archetypeId === -1 (not yet materialized into an archetype row). During a
346
+ * failed append, `archetypeId` is reserved before the first storage write and
347
+ * `archetypeRow` remains `-1`; that marker prevents command abort from
348
+ * reclaiming a reservation that may already have touched table storage.
290
349
  */
291
350
  export interface EntityRecord {
292
351
  generation: number;
@@ -295,57 +354,36 @@ export interface EntityRecord {
295
354
  }
296
355
 
297
356
  /**
298
- * The World owns:
299
- * - the registry of known component schemas;
300
- * - all archetypes (via ArchetypeGraph);
301
- * - the entity index table (records by index slot);
302
- * - the free-list of recyclable entity slots.
357
+ * The World owns the component registry, archetype graph, entity index table,
358
+ * and recyclable free-list.
303
359
  */
304
360
  export class World {
305
- /** Package-internal implementation seam; not exported from any entry point. */
306
- readonly [worldInternal]: WorldInternal;
361
+ readonly [worldRead]: WorldRead;
307
362
  // ── Internal state ──
308
363
 
309
- /** The single package-private owner of storage, epochs, and change evidence. */
310
- private readonly core: WorldCore;
364
+ /** World-local state is kept together so one owner closes each mutation. */
365
+ private executionState: WorldExecutionState = healthyWorldExecutionState(createWorldIdentity());
366
+ private readonly graph: ArchetypeGraph;
367
+ private readonly records: EntityRecord[] = [];
368
+ private readonly freeIndices: number[] = [];
369
+ /** BufferPool for schema-declared variable buffers and arrays. */
370
+ private readonly bufferPool = new BufferPool();
371
+ /** Per-World managed unique-ref store used by lifecycle mutations. */
372
+ private readonly uniqueRefs: UniqueRefStore = new UniqueRefStoreImpl();
373
+ /** Per-World shared-ref store; public read-only for direct handle operations. */
374
+ readonly sharedRefs: SharedRefStore = new SharedRefStoreImpl();
375
+ private readonly componentMutationEpochs: number[] = [];
376
+ private readonly structuralEvidence = new StructuralEvidenceRingImpl();
377
+ /** One packed reverse index per relationship source component. */
378
+ private readonly relationshipIndexes = new Map<number, RelationshipIndex>();
379
+ private mutationEpoch = 0;
380
+ private structureEpoch = 0;
381
+ /** Keep identity as a prototype getter; it is a diagnostic capability, not enumerable state. */
311
382
  get identity() {
312
- return this.core.identity;
383
+ return this.executionState.identity;
313
384
  }
314
385
  /** Plugin-owned component discovery scoped to this World and removed through leases. */
315
386
  readonly components = new ComponentCatalog((component) => this.componentIsInUse(component));
316
- private executionState: WorldExecutionState;
317
- /** Monotonic clock advanced exactly once per successful mutation. */
318
- /** Monotonic revision for successful entity/component structure writes. */
319
- /** Last mutation epoch for each component id, used by component-owned projections. */
320
- /** Ordered, bounded evidence consumed by persistent engine-owned projections. */
321
- private get mutationEpoch() {
322
- return this.core.mutationEpoch;
323
- }
324
- private set mutationEpoch(value: number) {
325
- this.core.mutationEpoch = value;
326
- }
327
- private get structureEpoch() {
328
- return this.core.structureEpoch;
329
- }
330
- private set structureEpoch(value: number) {
331
- this.core.structureEpoch = value;
332
- }
333
- private get componentMutationEpochs() {
334
- return this.core.componentMutationEpochs;
335
- }
336
- /** Free index slots (LIFO stack). */
337
- private get records() {
338
- return this.core.records;
339
- }
340
- private get freeIndices() {
341
- return this.core.freeIndices;
342
- }
343
- /**
344
- * Relationship-sync reentry guard (feat-20260531 M2 / plan-strategy D-7).
345
- /** The archetype graph: manages all archetypes + edge caching. */
346
- private get graph(): ArchetypeGraph {
347
- return this.core.graph;
348
- }
349
387
  /** DAG schedules for the two built-in execution scopes. */
350
388
  private readonly schedules = new Map([
351
389
  [Update, createSchedule(Update)],
@@ -356,69 +394,8 @@ export class World {
356
394
  private readonly clock: ReturnType<typeof createWorldClock>;
357
395
  /** Remainder carried between fixed-step runs. */
358
396
  private fixedAccumulator = 0;
359
- /**
360
- * ECS-managed handle store (M1). Owned by the World - constructed eagerly
361
- * so every spawn / despawn / set path can dispatch managed-ref releases
362
- * without caller-side wiring. AI users obtain `Handle<T,'unique'>` values
363
- * by accessing the store through internal channels (the surface is
364
- * private; managed-ref-bearing fields read through `world.get`).
365
- */
366
- // UniqueRefStore is type-erased at the storage layer (alloc/resolve are
367
- // method-generic over `T`); World holds the single per-instance store and
368
- // routes payload-agnostic release calls. Typed access flows through
369
- // `UniqueRefStore.resolve<T>` at the consumer layer.
370
- private get uniqueRefs(): UniqueRefStore {
371
- return this.core.uniqueRefs;
372
- }
373
- /**
374
- * Per-World `SharedRefStore` (feat-20260614 M3). Backs every `shared<T>`
375
- * schema field + the `world.allocSharedRef` facade. Public read-only so AI
376
- * users can `retain` / `release` / `resolve` user-tier handles directly off
377
- * the world (the surface is small enough that hiding it behind another
378
- * facade would be a phantom indirection - charter F1 single-entry
379
- * indexability).
380
- *
381
- * Final release publishes structured evidence; there is no callback surface.
382
- * M6 D-15: the store manages only user-tier slots
383
- * (`>= BUILTIN_BASE`); builtin handles are process-static in their
384
- * authoring package and never reference-counted.
385
- */
386
- get sharedRefs(): SharedRefStore {
387
- return this.core.sharedRefs;
388
- }
389
- /**
390
- * BufferPool backing every `buffer:<N>` schema-vocab field (M2). Eagerly
391
- * constructed (per-World, D-2). `spawn` allocs slots for buffer fields and
392
- * stores the slot id in the u32 column; `despawn` / `removeComponent`
393
- * release the slots; `set(e, C, { field: Uint8Array })` copies bytes into
394
- * the live view without re-allocating (schema-declared byteLength is
395
- * fixed in v1; runtime grow is reserved for the M4 carry-over path).
396
- */
397
- private get bufferPool() {
398
- return this.core.bufferPool;
399
- }
400
- private readonly componentAccess: WorldComponentAccess;
401
-
402
397
  constructor(options: WorldOptions = {}) {
403
- this.core = new WorldCore(options.storage === 'shared');
404
- this.executionState = healthyWorldExecutionState(this.identity);
405
- this.componentAccess = new WorldComponentAccess({
406
- graph: this.graph,
407
- records: this.records,
408
- freeIndices: this.freeIndices,
409
- bufferPool: this.bufferPool,
410
- uniqueRefs: this.uniqueRefs,
411
- sharedRefs: this.sharedRefs,
412
- relationshipIndexes: this.core.relationshipIndexes,
413
- markComponentAdded: (entity, component) => this.internalmarkComponentAdded(entity, component),
414
- markComponentsAdded: (entity, components) =>
415
- this.internalmarkComponentsAdded(entity, components),
416
- markComponentChanged: (entity, component) =>
417
- this.internalmarkComponentChanged(entity, component),
418
- markStructureChanged: () => this.internalmarkStructureChanged(),
419
- recordStructuralEvidence: (evidence) => this.internalrecordStructuralEvidence(evidence),
420
- routeError: (error, context) => this.internalrouteError(error as EcsError, context),
421
- });
398
+ this.graph = createArchetypeGraph(options.storage === 'shared');
422
399
  this.clock = createWorldClock({ ...DEFAULT_TIME_POLICY, ...options.time });
423
400
  this.resources.entries.set(TIME_RESOURCE_KEY, {
424
401
  value: this.clock.time,
@@ -431,63 +408,51 @@ export class World {
431
408
  changed: 0,
432
409
  });
433
410
  this[worldInternal] = {
434
- addComponentCore: this.internaladdComponentCore.bind(this),
435
- allocateIndex: this.internalallocateIndex.bind(this),
436
- allocatePendingEntity: this.internalallocatePendingEntity.bind(this),
437
- cancelPendingEntity: this.internalcancelPendingEntity.bind(this),
438
- despawnCore: this.internaldespawnCore.bind(this),
439
- getArrayView: this.internalgetArrayView.bind(this),
440
- getArrayLength: this.internalgetArrayLength.bind(this),
441
- getArrayElement: this.internalgetArrayElement.bind(this),
442
- getFieldValue: this.internalgetFieldValue.bind(this),
443
- getBufferPool: this.internalgetBufferPool.bind(this),
444
- getClockWriter: this.internalgetClockWriter.bind(this),
411
+ allocatePendingEntity: this.allocatePendingEntity.bind(this),
412
+ cancelPendingEntity: this.cancelPendingEntity.bind(this),
413
+ getArrayView: this.getArrayView.bind(this),
414
+ getBufferPool: () => this.bufferPool,
415
+ getClockWriter: () => this.clock.writer,
445
416
  getComponentChange: this.internalgetComponentChange.bind(this),
446
- getComponentMutationEpoch: this.internalgetComponentMutationEpoch.bind(this),
447
- getComponentMutationEpochs: this.internalgetComponentMutationEpochs.bind(this),
417
+ getComponentMutationEpochs: () => this.componentMutationEpochs,
448
418
  getEntityArchetype: this.internalgetEntityArchetype.bind(this),
449
- getFixedAccumulator: this.internalgetFixedAccumulator.bind(this),
450
- getFreeIndices: this.internalgetFreeIndices.bind(this),
451
- getGraph: this.internalgetGraph.bind(this),
452
- getMutationEpoch: this.internalgetMutationEpoch.bind(this),
453
- getQueryRow: this.internalgetQueryRow.bind(this),
454
- getRecords: this.internalgetRecords.bind(this),
455
- getRelationshipEpoch: this.internalgetRelationshipEpoch.bind(this),
456
- getRelationshipTargetEntities: this.internalgetRelationshipTargetEntities.bind(this),
457
- getResources: this.internalgetResources.bind(this),
458
- getSchedule: this.internalgetSchedule.bind(this),
459
- getSchedules: this.internalgetSchedules.bind(this),
460
- getSharedRefs: this.internalgetSharedRefs.bind(this),
461
- getStructureEpoch: this.internalgetStructureEpoch.bind(this),
462
- getStructuralEvidence: this.internalgetStructuralEvidence.bind(this),
463
- getUniqueRefs: this.internalgetUniqueRefs.bind(this),
464
- lookupAlive: this.internallookupAlive.bind(this),
465
- markComponentAdded: this.internalmarkComponentAdded.bind(this),
419
+ getFixedAccumulator: () => this.fixedAccumulator,
420
+ getGraph: () => this.graph,
421
+ getMutationEpoch: () => this.mutationEpoch,
422
+ getQueryRow: (entity, component) =>
423
+ this.get(entity, component) as Result<Record<string, unknown>, EcsError>,
424
+ getRecords: () => this.records,
425
+ getRelationshipEpoch: (component) =>
426
+ this.relationshipIndexes.get(componentId(component))?.epoch ?? 0,
427
+ getRelationshipTargetEntities: this.relationshipTargetEntries.bind(this),
428
+ getResources: () => this.resources,
429
+ getSchedule: (token) => this.schedules.get(token),
430
+ getSchedules: () => this.schedules,
431
+ getSharedRefs: () => this.sharedRefs,
432
+ getStructureEpoch: this.getStructureEpoch.bind(this),
433
+ getStructuralEvidence: () => this.structuralEvidence,
434
+ lookupAlive: this.lookupAlive.bind(this),
466
435
  markComponentChanged: this.internalmarkComponentChanged.bind(this),
467
436
  markComponentRangeChanged: this.internalmarkComponentRangeChanged.bind(this),
468
- markComponentsAdded: this.internalmarkComponentsAdded.bind(this),
469
- markStructureChanged: this.internalmarkStructureChanged.bind(this),
470
- materializePendingEntity: this.internalmaterializePendingEntity.bind(this),
437
+ materializeEntity: this.materializeEntity.bind(this),
438
+ materializePendingEntity: this.materializePendingEntity.bind(this),
471
439
  nextMutationEpoch: this.internalnextMutationEpoch.bind(this),
472
440
  poisonExecution: this.internalpoisonExecution.bind(this),
473
- prepareRelationshipInsert: this.internalprepareRelationshipInsert.bind(this),
474
441
  publishDerivedRange: this.internalpublishDerivedRange.bind(this),
475
- preflightComponentData: this.internalpreflightComponentData.bind(this),
476
- readRow: this.internalreadRow.bind(this),
477
- recordStructuralEvidence: this.internalrecordStructuralEvidence.bind(this),
478
- recordIsLive: this.internalrecordIsLive.bind(this),
479
- relationshipOnInsert: this.internalrelationshipOnInsert.bind(this),
480
- relationshipOnRemove: this.internalrelationshipOnRemove.bind(this),
481
- releaseManagedRefsOnRow: this.internalreleaseManagedRefsOnRow.bind(this),
482
- releaseRelationshipPreparation: this.internalreleaseRelationshipPreparation.bind(this),
483
- removeComponentCore: this.internalremoveComponentCore.bind(this),
484
- routeError: this.internalrouteError.bind(this),
442
+ preflightComponentData: this.preflightComponentData.bind(this),
443
+ readRow: this.readRow.bind(this),
444
+ recordIsLive: this.recordIsLive.bind(this),
445
+ routeError: this.routeError.bind(this),
485
446
  restoreMutationEpoch: this.internalrestoreMutationEpoch.bind(this),
486
- setFixedAccumulator: this.internalsetFixedAccumulator.bind(this),
447
+ setFixedAccumulator: (value) => {
448
+ this.fixedAccumulator = value;
449
+ },
487
450
  setQueryRow: this.internalsetQueryRow.bind(this),
488
- spawnCore: this.internalspawnCore.bind(this),
489
- writeEntitySelf: this.internalwriteEntitySelf.bind(this),
490
- writeRow: this.internalwriteRow.bind(this),
451
+ };
452
+ this[worldRead] = {
453
+ getFieldValue: this.internalgetFieldValue.bind(this),
454
+ getArrayLength: this.internalgetArrayLength.bind(this),
455
+ getArrayElement: this.internalgetArrayElement.bind(this),
491
456
  };
492
457
  }
493
458
 
@@ -505,13 +470,24 @@ export class World {
505
470
  return FixedUpdate;
506
471
  }
507
472
 
508
- /** SharedKernel is the only writer; application code recovers by constructing a new World. */
473
+ /** Seal the first execution fault; application code recovers with a new World. */
509
474
  private internalpoisonExecution(fault: WorldExecutionFault): void {
510
475
  if (this.executionState.health === 'healthy') {
511
476
  this.executionState = poisonedWorldExecutionState(this.identity, fault);
512
477
  }
513
478
  }
514
479
 
480
+ /**
481
+ * A poisoned identity is diagnostic evidence, not a mutable recovery path.
482
+ * Public entity mutation therefore returns the same structured fence as
483
+ * `update()` instead of allocating a new reservation or touching a partial
484
+ * row. Recovery remains construction of a fresh World.
485
+ */
486
+ private poisonedResult<T>(): Result<T, EcsError> | undefined {
487
+ if (this.executionState.health !== 'poisoned') return undefined;
488
+ return err(new WorldPoisonedError(this.identity, this.executionState.fault));
489
+ }
490
+
515
491
  query<
516
492
  const R extends readonly Component[] = readonly [],
517
493
  const W extends readonly Component[] = readonly [],
@@ -524,11 +500,6 @@ export class World {
524
500
  // Internal access — query engine
525
501
  // ──────────────────────────────────────────────────────────────────────────
526
502
 
527
- /** Expose archetype graph for query engine. Not part of public API. */
528
- private internalgetGraph(): ArchetypeGraph {
529
- return this.graph;
530
- }
531
-
532
503
  private componentIsInUse(component: Component): boolean {
533
504
  if (
534
505
  this.graph.archetypes.some(
@@ -541,29 +512,14 @@ export class World {
541
512
  return worldScheduleUsesComponent(this, component);
542
513
  }
543
514
 
544
- /** Current upper bound for mutation observation. */
545
- private internalgetMutationEpoch(): number {
546
- return this.mutationEpoch;
547
- }
548
-
549
- /** Structure snapshot used to invalidate borrowed query facades. */
550
- private internalgetStructureEpoch(): number {
551
- return this.structureEpoch;
552
- }
553
-
554
- /** Typed structural facts are produced by this World and consumed by projections. */
555
- private internalgetStructuralEvidence(): StructuralEvidenceRing {
556
- return this.core.structuralEvidence;
557
- }
558
-
559
- private internalrecordStructuralEvidence(evidence: StructuralEvidenceInput): void {
560
- this.core.structuralEvidence.append(evidence);
515
+ private recordStructuralEvidence(evidence: StructuralEvidenceInput): void {
516
+ this.structuralEvidence.append(evidence);
561
517
  }
562
518
 
563
519
  /** Resolve current logical identity for a packed entity handle. */
564
520
  private internalgetEntityArchetype(entity: EntityHandle): Archetype | undefined {
565
521
  const record = this.records[entityIndex(entity)];
566
- if (!this.internalrecordIsLive(record, entityGeneration(entity))) return undefined;
522
+ if (!this.recordIsLive(record, entityGeneration(entity))) return undefined;
567
523
  return this.graph.archetypes[record.archetypeId];
568
524
  }
569
525
 
@@ -573,7 +529,7 @@ export class World {
573
529
  componentId: number,
574
530
  ): ChangeTicks | undefined {
575
531
  const record = this.records[entityIndex(entity)];
576
- if (!this.internalrecordIsLive(record, entityGeneration(entity))) return undefined;
532
+ if (!this.recordIsLive(record, entityGeneration(entity))) return undefined;
577
533
  return readComponentChange(this.graph, record, entity, componentId);
578
534
  }
579
535
 
@@ -606,7 +562,7 @@ export class World {
606
562
  }
607
563
 
608
564
  /** Record one successful structural mutation. */
609
- private internalmarkStructureChanged(): void {
565
+ private advanceStructureEpoch(): void {
610
566
  this.structureEpoch += 1;
611
567
  }
612
568
 
@@ -615,15 +571,10 @@ export class World {
615
571
  return this.structureEpoch;
616
572
  }
617
573
 
618
- /** Mark a component as both added and changed at the current tick. */
619
- private internalmarkComponentAdded(entity: EntityHandle, componentId: number): void {
620
- this.internalmarkComponentsAdded(entity, [componentId]);
621
- }
622
-
623
574
  /** Mark one mutation's component instances with a shared epoch. */
624
575
  private internalmarkComponentsAdded(entity: EntityHandle, componentIds: readonly number[]): void {
625
576
  const record = this.records[entityIndex(entity)];
626
- if (!this.internalrecordIsLive(record, entityGeneration(entity))) return;
577
+ if (!this.recordIsLive(record, entityGeneration(entity))) return;
627
578
  const epoch = this.internalnextMutationEpoch();
628
579
  markComponentsAdded(this.graph, record, entity, componentIds, epoch);
629
580
  for (const componentId of componentIds) {
@@ -634,7 +585,7 @@ export class World {
634
585
  /** Mark an existing component as changed at the current tick. */
635
586
  private internalmarkComponentChanged(entity: EntityHandle, componentId: number): void {
636
587
  const record = this.records[entityIndex(entity)];
637
- if (!this.internalrecordIsLive(record, entityGeneration(entity))) return;
588
+ if (!this.recordIsLive(record, entityGeneration(entity))) return;
638
589
  let epoch: number | undefined;
639
590
  markComponentChanged(this.graph, record, entity, componentId, () => {
640
591
  epoch = this.internalnextMutationEpoch();
@@ -659,49 +610,17 @@ export class World {
659
610
  this.componentMutationEpochs[componentId] = epoch;
660
611
  }
661
612
 
662
- /** Latest mutation token for one component-owned projection. */
663
- private internalgetComponentMutationEpoch(componentId: number): number {
664
- return this.componentMutationEpochs[componentId] ?? 0;
665
- }
666
-
667
- /** Borrow the component-version summary used to skip unrelated value columns. */
668
- private internalgetComponentMutationEpochs(): readonly number[] {
669
- return this.componentMutationEpochs;
670
- }
671
-
672
- /** Read a materialized relationship target in O(1 + k). */
673
- private internalgetRelationshipTargetEntities(
674
- source: Component,
675
- target: EntityHandle,
676
- ): readonly EntityHandle[] {
677
- return this.componentAccess.relationshipTargetEntries(source, target);
678
- }
679
-
680
- /** Monotonic epoch for the materialized relationship index. */
681
- private internalgetRelationshipEpoch(source: Component): number {
682
- return this.core.relationshipIndexes.get(componentId(source))?.epoch ?? 0;
683
- }
684
-
685
613
  /** Query facade write after the facade has already marked evidence. */
686
614
  private internalsetQueryRow(
687
615
  entity: EntityHandle,
688
616
  component: Component,
689
617
  value: Record<string, unknown>,
690
618
  ): Result<void, EcsError> {
691
- return this.componentAccess.set(
692
- entity,
693
- component,
694
- value as never,
695
- relationshipRole(component)?.kind === 'source',
696
- );
697
- }
698
-
699
- /** Query facade read that does not re-enter the public World API. */
700
- private internalgetQueryRow(
701
- entity: EntityHandle,
702
- component: Component,
703
- ): Result<Record<string, unknown>, EcsError> {
704
- return this.componentAccess.get(entity, component) as Result<Record<string, unknown>, EcsError>;
619
+ const result = this.set(entity, component, value as never, false);
620
+ if (result.ok && relationshipRole(component)?.kind === 'source') {
621
+ this.markComponentChanged(entity, component);
622
+ }
623
+ return result;
705
624
  }
706
625
 
707
626
  /** Return resource change ticks for diagnostics and resource-driven systems. */
@@ -710,67 +629,13 @@ export class World {
710
629
  return entry === undefined ? undefined : { added: entry.added, changed: entry.changed };
711
630
  }
712
631
 
713
- /**
714
- * Route a structured error from
715
- * an engine-internal subsystem (e.g. RenderSystem extract stage, w15).
716
- *
717
- * Mirrors the private `errorHandler(err, ctx)` call sites inside `World`
718
- * itself; the dedicated accessor avoids exposing `errorHandler` directly
719
- * and keeps the routing contract under the `_xxx` `@internal` umbrella so
720
- * AI users do not discover it through IDE autocomplete on `World`.
721
- *
722
- * Not part of the public API.
723
- */
724
- private internalrouteError(err: EcsError, ctx?: { readonly systemName: string }): void {
632
+ /** Route an expected internal failure through the host-owned error channel. */
633
+ private routeError(err: unknown, ctx?: ErrorContext): void {
725
634
  // Internal expected failures are reported without becoming a second
726
635
  // schedule or terminal hook. The host owns fatal frame policy.
727
636
  console.error(`[${ctx?.systemName ?? 'World'}]`, err);
728
637
  }
729
638
 
730
- // ──────────────────────────────────────────────────────────────────────────
731
- // accessors — M1 extraction seam
732
- // ──────────────────────────────────────────────────────────────────────────
733
-
734
- /** */ private internalgetRecords(): EntityRecord[] {
735
- return this.records;
736
- }
737
- /** */ private internalgetFreeIndices(): number[] {
738
- return this.freeIndices;
739
- }
740
- /** */ private internalgetResources(): ResourceStore {
741
- return this.resources;
742
- }
743
- /** */ private internalgetFixedAccumulator(): number {
744
- return this.fixedAccumulator;
745
- }
746
- /** */ private internalsetFixedAccumulator(value: number): void {
747
- this.fixedAccumulator = value;
748
- }
749
- /** */ private internalgetUniqueRefs(): UniqueRefStore {
750
- return this.uniqueRefs;
751
- }
752
- /** */ private internalgetBufferPool(): BufferPool {
753
- return this.bufferPool;
754
- }
755
- /** Scheduler-owned mutable clock capability. */
756
- private internalgetClockWriter(): ClockWriter {
757
- return this.clock.writer;
758
- }
759
- /** */ private internalgetSchedule(
760
- token: import('./schedule-token').ScheduleToken,
761
- ): Schedule | undefined {
762
- return this.schedules.get(token);
763
- }
764
- /** */ private internalgetSchedules(): ReadonlyMap<
765
- import('./schedule-token').ScheduleToken,
766
- Schedule
767
- > {
768
- return this.schedules;
769
- }
770
- /** */ private internalgetSharedRefs(): SharedRefStore {
771
- return this.sharedRefs;
772
- }
773
-
774
639
  // ──────────────────────────────────────────────────────────────────────────
775
640
  // System registration + update (M3)
776
641
  // ──────────────────────────────────────────────────────────────────────────
@@ -1017,7 +882,7 @@ export class World {
1017
882
  payload: T,
1018
883
  onRelease?: (payload: T) => void,
1019
884
  ): Handle<Target, 'unique'> {
1020
- return worldAllocUniqueRef(this, target, payload, onRelease);
885
+ return this.uniqueRefs.alloc(target, payload, onRelease);
1021
886
  }
1022
887
 
1023
888
  /**
@@ -1053,7 +918,7 @@ export class World {
1053
918
  * ```
1054
919
  */
1055
920
  allocSharedRef<Target extends string, T>(target: Target, payload: T): Handle<Target, 'shared'> {
1056
- return worldAllocSharedRef(this, target, payload);
921
+ return this.sharedRefs.alloc(target, payload);
1057
922
  }
1058
923
 
1059
924
  /**
@@ -1067,11 +932,14 @@ export class World {
1067
932
  target: Target,
1068
933
  payload: T,
1069
934
  ): Handle<Target, 'shared'> {
1070
- return worldInternSharedRef(this, target, payload);
935
+ return this.sharedRefs.intern(target, payload);
1071
936
  }
1072
937
 
1073
938
  // ──────────────────────────────────────────────────────────────────────────
1074
- // Component access facade — storage ownership is world-component-access.
939
+ // ──────────────────────────────────────────────────────────────────────────
940
+ // Component access and storage — World owns mutation state and coordination.
941
+ // Independent table/column algorithms remain private methods so all mutation
942
+ // paths share one state owner and publication boundary.
1075
943
  // ──────────────────────────────────────────────────────────────────────────
1076
944
 
1077
945
  private relationshipTargetWriteError(
@@ -1089,23 +957,7 @@ export class World {
1089
957
  });
1090
958
  }
1091
959
 
1092
- get<S extends ComponentSchema>(
1093
- entity: EntityHandle,
1094
- component: Component<string, S>,
1095
- ): Result<ShapeOf<S>, EcsError> {
1096
- return this.componentAccess.get(entity, component);
1097
- }
1098
-
1099
- /**
1100
- * Test live component presence without constructing a Result error.
1101
- *
1102
- * Read projections commonly need to branch on optional components for many
1103
- * entities. Calling `get` for that branch allocates a structured
1104
- * ComponentNotPresentError on every ordinary miss (and StaleEntityError for
1105
- * a dangling handle). This predicate is deliberately non-throwing and
1106
- * returns false for both cases; callers that need the detailed error should
1107
- * continue to use `get`.
1108
- */
960
+ /** Test live component presence without constructing a Result error. */
1109
961
  hasComponent(entity: EntityHandle, component: Component): boolean {
1110
962
  const archetype = this.internalgetEntityArchetype(entity);
1111
963
  return (
@@ -1115,174 +967,2434 @@ export class World {
1115
967
  );
1116
968
  }
1117
969
 
1118
- private internalgetArrayView(
1119
- entity: EntityHandle,
1120
- component: Component,
1121
- fieldName: string,
1122
- ): ArrayLike<number> | undefined {
1123
- return this.componentAccess._getArrayView(entity, component, fieldName);
970
+ private table(archetype: Archetype): Table {
971
+ return getTable(this.graph, archetype.tableId);
1124
972
  }
1125
973
 
1126
- private internalgetArrayLength(
1127
- entity: EntityHandle,
1128
- component: Component,
1129
- fieldName: string,
1130
- ): number | undefined {
1131
- return this.componentAccess._getArrayLength(entity, component, fieldName);
974
+ private tableRow(record: EntityRecord): number {
975
+ return this.graph.archetypes[record.archetypeId]?.rows[record.archetypeRow] ?? -1;
1132
976
  }
1133
977
 
1134
- private internalgetArrayElement(
1135
- entity: EntityHandle,
1136
- component: Component,
1137
- fieldName: string,
1138
- index: number,
1139
- ): number | undefined {
1140
- return this.componentAccess._getArrayElement(entity, component, fieldName, index);
978
+ private markComponentChanged(entity: EntityHandle, component: Component): void {
979
+ this.internalmarkComponentChanged(entity, componentId(component));
1141
980
  }
1142
981
 
1143
- private internalgetFieldValue(
1144
- entity: EntityHandle,
982
+ private relationshipIndex(component: Component): RelationshipIndex | undefined {
983
+ if (relationshipRole(component)?.kind !== 'source') return undefined;
984
+ let index = this.relationshipIndexes.get(componentId(component));
985
+ if (index === undefined) {
986
+ index = new RelationshipIndex();
987
+ this.relationshipIndexes.set(componentId(component), index);
988
+ }
989
+ return index;
990
+ }
991
+
992
+ /** Read the World-owned materialized target array; never consults a shadow list. */
993
+ private relationshipTargetEntries(
994
+ source: Component,
995
+ target: EntityHandle,
996
+ ): readonly EntityHandle[] {
997
+ const role = relationshipRole(source);
998
+ if (role?.kind !== 'source') return [];
999
+ const mirror = relationshipMirror(source);
1000
+ if (mirror === undefined) return [];
1001
+ // Internal relationship maintenance may read the live mirror view
1002
+ // directly; the public `get` path clones target arrays to keep them
1003
+ // read-only. Avoid allocating a component snapshot on every append or
1004
+ // removal while retaining the same World-owned storage authority.
1005
+ return (
1006
+ (this.getArrayView(target, mirror, role.targetField) as EntityHandle[] | undefined) ?? []
1007
+ );
1008
+ }
1009
+
1010
+ /** Read a relationship target length without materialising its array view. */
1011
+ private relationshipTargetLength(source: Component, target: EntityHandle): number {
1012
+ const role = relationshipRole(source);
1013
+ if (role?.kind !== 'source') return 0;
1014
+ const mirror = relationshipMirror(source);
1015
+ if (mirror === undefined) return 0;
1016
+ return this.internalgetArrayLength(target, mirror, role.targetField) ?? 0;
1017
+ }
1018
+
1019
+ private relationshipTargetEntity(
1145
1020
  component: Component,
1146
- fieldName: string,
1147
- ): number | undefined {
1148
- return this.componentAccess._getFieldValue(entity, component, fieldName);
1021
+ value: Record<string, unknown>,
1022
+ ): EntityHandle | null {
1023
+ for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
1024
+ if (isEntityField(fieldType)) {
1025
+ const raw = value[fieldName];
1026
+ if (raw === null || raw === undefined) return null;
1027
+ const asNum = raw as number;
1028
+ if (asNum === ENTITY_NULL_RAW) return null;
1029
+ return asNum as EntityHandle;
1030
+ }
1031
+ }
1032
+ return null;
1149
1033
  }
1150
1034
 
1151
- set<S extends ComponentSchema, C extends Component<string, S>>(
1152
- entity: EntityHandle,
1153
- component: C & WritableComponent<C>,
1154
- value: Partial<InputShapeOf<S>>,
1035
+ private preflightComponentFieldValues(
1036
+ holder: EntityHandle | null,
1037
+ componentData: ComponentData,
1155
1038
  ): Result<void, EcsError> {
1156
- if (isRelationshipTarget(component)) return this.relationshipTargetWriteError(component, 'set');
1157
- return this.componentAccess.set(entity, component, value);
1039
+ const data = componentData.data as Record<string, unknown>;
1040
+ const arrayError = validateManagedArrayValues(componentData.component, data);
1041
+ if (arrayError !== null) return err(arrayError as unknown as EcsError);
1042
+ const sharedError = validateSharedFieldValues(componentData.component, data);
1043
+ if (sharedError !== null) return err(sharedError as unknown as EcsError);
1044
+ const numericError = validateNumericFieldValues(
1045
+ componentData.component,
1046
+ data,
1047
+ holder === null ? undefined : (holder as number),
1048
+ );
1049
+ if (numericError !== null) return err(numericError as unknown as EcsError);
1050
+ return ok(undefined);
1158
1051
  }
1159
1052
 
1160
- addComponent<S extends ComponentSchema, C extends Component<string, S>>(
1161
- entity: EntityHandle,
1162
- componentData: ComponentData<S> & { component: C & WritableComponent<C> },
1053
+ /**
1054
+ * Validate one structural component payload without touching archetypes,
1055
+ * columns, relationship mirrors, epochs, or managed-reference stores.
1056
+ * CommandBuffer uses this same owner-level gate as the direct World facade;
1057
+ * the optional pending set lets a batch refer to an entity reserved earlier
1058
+ * in that batch without mistaking it for a stale live handle.
1059
+ */
1060
+ private preflightComponentData(
1061
+ holder: EntityHandle | null,
1062
+ componentData: ComponentData,
1063
+ pendingEntities?: ReadonlySet<number>,
1064
+ unavailableEntities?: ReadonlySet<number>,
1163
1065
  ): Result<void, EcsError> {
1066
+ const data = componentData.data as Record<string, unknown>;
1067
+ const keyError = validateComponentDataKeys(componentData.component, data);
1068
+ if (keyError !== null) return err(keyError as unknown as EcsError);
1069
+ const valuePreflight = this.preflightComponentFieldValues(holder, componentData);
1070
+ if (!valuePreflight.ok) return valuePreflight;
1164
1071
  if (
1165
1072
  isRelationshipTarget(componentData.component) &&
1166
- this.relationshipTargetPayloadWrites(componentData.data as Record<string, unknown>)
1167
- )
1168
- return this.relationshipTargetWriteError(componentData.component, 'addComponent');
1169
- return this.componentAccess.addComponent(entity, componentData);
1170
- }
1073
+ this.relationshipTargetPayloadWrites(data)
1074
+ ) {
1075
+ return err(new RelationshipTargetReadonlyError(componentData.component.name, 'command'));
1076
+ }
1171
1077
 
1172
- private internaladdComponentCore<S extends ComponentSchema>(
1173
- entity: EntityHandle,
1174
- componentData: ComponentData<S>,
1175
- internal: boolean,
1176
- ): Result<void, EcsError> {
1177
- return this.componentAccess._addComponentCore(entity, componentData, internal);
1078
+ const filled = fillComponentDefaults(componentData.component, data);
1079
+ const enumError = validateEnumFieldValues(
1080
+ componentData.component,
1081
+ filled,
1082
+ holder === null ? undefined : (holder as number),
1083
+ );
1084
+ if (enumError !== null) return err(enumError as unknown as EcsError);
1085
+
1086
+ const role = relationshipRole(componentData.component as Component);
1087
+ if (role?.kind !== 'source') return ok(undefined);
1088
+ const target = this.relationshipTargetEntity(componentData.component as Component, filled);
1089
+ if (target === null) return ok(undefined);
1090
+
1091
+ const targetRaw = target as unknown as number;
1092
+ if (unavailableEntities?.has(targetRaw) === true) {
1093
+ const targetRecord = this.records[entityIndex(target)];
1094
+ return err(
1095
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
1096
+ operation: 'relationship-insert',
1097
+ component: componentData.component.name,
1098
+ expectedGeneration: entityGeneration(target),
1099
+ actualGeneration: targetRecord?.generation ?? -1,
1100
+ }),
1101
+ );
1102
+ }
1103
+ const targetIsPending = pendingEntities?.has(targetRaw) === true;
1104
+ const targetRecord = this.records[entityIndex(target)];
1105
+ const actualGeneration = targetRecord?.generation ?? -1;
1106
+ const targetLive = this.recordIsLive(targetRecord, entityGeneration(target));
1107
+ const holderIsPending =
1108
+ holder === null || pendingEntities?.has(holder as unknown as number) === true;
1109
+ if (!targetIsPending && !targetLive && !holderIsPending) {
1110
+ return err(
1111
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
1112
+ operation: 'relationship-insert',
1113
+ component: componentData.component.name,
1114
+ expectedGeneration: entityGeneration(target),
1115
+ actualGeneration,
1116
+ }),
1117
+ );
1118
+ }
1119
+
1120
+ // A pending holder has no row to walk yet. Once materialized, its target
1121
+ // is still checked by the same source-side relationship callback.
1122
+ if (holder === null || pendingEntities?.has(holder as unknown as number) === true) {
1123
+ return ok(undefined);
1124
+ }
1125
+ const roleAllowsSelf = role?.kind === 'source' && role.allowSelf;
1126
+ if (holder === target && !roleAllowsSelf) {
1127
+ return err(
1128
+ new RelationshipSelfCycleError(
1129
+ componentData.component.name,
1130
+ holder as number,
1131
+ target as number,
1132
+ ),
1133
+ );
1134
+ }
1135
+
1136
+ const cycleHit =
1137
+ holder === target && roleAllowsSelf
1138
+ ? null
1139
+ : this.relationshipCycleHit(componentData.component as Component, target, holder);
1140
+ if (cycleHit !== null) {
1141
+ return err(
1142
+ new RelationshipSelfCycleError(
1143
+ componentData.component.name,
1144
+ holder as number,
1145
+ cycleHit as number,
1146
+ ),
1147
+ );
1148
+ }
1149
+ return ok(undefined);
1178
1150
  }
1179
1151
 
1180
- removeComponent<S extends ComponentSchema, C extends Component<string, S>>(
1181
- entity: EntityHandle,
1182
- component: C & WritableComponent<C>,
1183
- ): Result<void, EcsError> {
1184
- if (isRelationshipTarget(component))
1185
- return this.relationshipTargetWriteError(component, 'removeComponent');
1186
- return this.componentAccess.removeComponent(entity, component);
1152
+ private relationshipCycleHit(
1153
+ holderComponent: Component,
1154
+ start: EntityHandle,
1155
+ holder: EntityHandle,
1156
+ ): EntityHandle | null {
1157
+ const visited = new Set<number>();
1158
+ let current = start;
1159
+ while (true) {
1160
+ if (current === holder) return current;
1161
+ const raw = current as unknown as number;
1162
+ if (visited.has(raw)) return null;
1163
+ visited.add(raw);
1164
+ const record = this.records[entityIndex(current)];
1165
+ if (!this.recordIsLive(record, entityGeneration(current))) return null;
1166
+ const archetype = this.graph.archetypes[record.archetypeId];
1167
+ if (
1168
+ !archetype?.components.some(
1169
+ (candidate) => componentId(candidate) === componentId(holderComponent),
1170
+ )
1171
+ ) {
1172
+ return null;
1173
+ }
1174
+ const value = this.readRow(archetype, holderComponent, this.tableRow(record)) as Record<
1175
+ string,
1176
+ unknown
1177
+ >;
1178
+ const next = this.relationshipTargetEntity(holderComponent, value);
1179
+ if (next === null) return null;
1180
+ current = next;
1181
+ }
1187
1182
  }
1188
1183
 
1189
- private internalremoveComponentCore<S extends ComponentSchema>(
1184
+ /**
1185
+ * Commit a relationship source through its owner-specific write path.
1186
+ * Relationship sources have one entity field, so dispatching before the
1187
+ * generic field loop avoids paying the ordinary component-field traversal on
1188
+ * every hierarchy reparent while keeping mirror/index publication here.
1189
+ */
1190
+ private setRelationshipSource(
1190
1191
  entity: EntityHandle,
1191
- component: Component<string, S>,
1192
- internal: boolean,
1192
+ component: Component,
1193
+ value: Record<string, unknown>,
1194
+ record: EntityRecord,
1195
+ arch: Archetype,
1196
+ markChanged: boolean,
1193
1197
  ): Result<void, EcsError> {
1194
- return this.componentAccess._removeComponentCore(entity, component, internal);
1195
- }
1198
+ const role = relationshipRole(component);
1199
+ if (role?.kind !== 'source') return ok(undefined);
1196
1200
 
1197
- private internalallocatePendingEntity(): EntityHandle {
1198
- return this.componentAccess._allocatePendingEntity();
1199
- }
1200
- /** */ private internalcancelPendingEntity(entity: EntityHandle): void {
1201
- this.componentAccess._cancelPendingEntity(entity);
1201
+ const row = this.tableRow(record);
1202
+ const currentValue = this.readRow(arch, component, row) as Record<string, unknown>;
1203
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
1204
+ component,
1205
+ data: value as Partial<InputShapeOf<ComponentSchema>>,
1206
+ });
1207
+ if (!valuePreflight.ok) return valuePreflight;
1208
+
1209
+ const mergedValue = {
1210
+ ...currentValue,
1211
+ ...value,
1212
+ };
1213
+ const enumError = validateEnumFieldValues(component, mergedValue, entity as number);
1214
+ if (enumError !== null) return err(enumError as unknown as EcsError);
1215
+
1216
+ const oldRelationshipTarget = this.relationshipTargetEntity(component, currentValue);
1217
+ const nextRelationshipTarget = this.relationshipTargetEntity(component, mergedValue);
1218
+ const relationshipChanged = oldRelationshipTarget !== nextRelationshipTarget;
1219
+ let preparedMirrorAdded: boolean | undefined;
1220
+
1221
+ if (relationshipChanged && nextRelationshipTarget !== null) {
1222
+ // Keep the hot source-write path equivalent to the public relationship
1223
+ // preflight, without re-running the generic component-data validator
1224
+ // over the already-read/merged source row.
1225
+ const targetRecord = this.records[entityIndex(nextRelationshipTarget)];
1226
+ const actualGeneration = targetRecord?.generation ?? -1;
1227
+ if (!this.recordIsLive(targetRecord, entityGeneration(nextRelationshipTarget))) {
1228
+ return err(
1229
+ new StaleEntityError(
1230
+ nextRelationshipTarget as number,
1231
+ entityIndex(nextRelationshipTarget),
1232
+ entityGeneration(nextRelationshipTarget),
1233
+ {
1234
+ operation: 'relationship-insert',
1235
+ component: component.name,
1236
+ expectedGeneration: entityGeneration(nextRelationshipTarget),
1237
+ actualGeneration,
1238
+ },
1239
+ ),
1240
+ );
1241
+ }
1242
+ if (entity === nextRelationshipTarget && !role.allowSelf) {
1243
+ return err(
1244
+ new RelationshipSelfCycleError(
1245
+ component.name,
1246
+ entity as number,
1247
+ nextRelationshipTarget as number,
1248
+ ),
1249
+ );
1250
+ }
1251
+ const cycleHit =
1252
+ entity === nextRelationshipTarget
1253
+ ? null
1254
+ : this.relationshipCycleHit(component, nextRelationshipTarget, entity);
1255
+ if (cycleHit !== null) {
1256
+ return err(
1257
+ new RelationshipSelfCycleError(component.name, entity as number, cycleHit as number),
1258
+ );
1259
+ }
1260
+ const prepared = this.prepareRelationshipInsert(component, mergedValue);
1261
+ if (!prepared.ok) return prepared;
1262
+ preparedMirrorAdded = prepared.value;
1263
+ }
1264
+
1265
+ // Prepare first, then detach/attach the mirror, and only then publish the
1266
+ // source scalar. A later relationship error can therefore poison the same
1267
+ // World without exposing a source value that disagrees with its mirror.
1268
+ if (relationshipChanged && oldRelationshipTarget !== null) {
1269
+ const removed = this.relationshipOnRemove(entity, component, currentValue);
1270
+ if (!removed.ok) {
1271
+ this.poisonAfterEntityMutation('World.set', removed.error);
1272
+ return removed;
1273
+ }
1274
+ }
1275
+ if (relationshipChanged && nextRelationshipTarget !== null) {
1276
+ const inserted = this.relationshipOnInsert(
1277
+ entity,
1278
+ component,
1279
+ mergedValue,
1280
+ preparedMirrorAdded,
1281
+ );
1282
+ if (!inserted.ok) {
1283
+ this.poisonAfterEntityMutation('World.set', inserted.error);
1284
+ return inserted;
1285
+ }
1286
+ }
1287
+
1288
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
1289
+ const sourceColumn = fieldCols?.get(role.sourceField);
1290
+ if (sourceColumn === undefined) {
1291
+ return err(new ComponentNotPresentError(entity as number, component.name));
1292
+ }
1293
+ sourceColumn.view[row] =
1294
+ nextRelationshipTarget === null ? ENTITY_NULL_RAW : (nextRelationshipTarget as number);
1295
+ if (markChanged) this.markComponentChanged(entity, component);
1296
+ return ok(undefined);
1202
1297
  }
1203
1298
 
1204
- private internalmaterializePendingEntity(
1205
- entity: EntityHandle,
1206
- componentDatas: ComponentData[],
1207
- ): Result<void, EcsError> {
1208
- return this.componentAccess._materializePendingEntity(entity, componentDatas);
1299
+ /** Prepare the target side before a source archetype mutation commits. */
1300
+ private prepareRelationshipInsert(
1301
+ component: Component,
1302
+ value: Record<string, unknown>,
1303
+ ): Result<boolean, EcsError> {
1304
+ const role = relationshipRole(component);
1305
+ if (role?.kind !== 'source') return ok(false);
1306
+ const target = this.relationshipTargetEntity(component, value);
1307
+ if (target === null) return ok(false);
1308
+ const mirror = relationshipMirror(component);
1309
+ if (mirror === undefined) return ok(false);
1310
+ const targetRec = this.records[entityIndex(target)];
1311
+ const actualGeneration = targetRec?.generation ?? -1;
1312
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) {
1313
+ return err(
1314
+ new StaleEntityError(target as number, entityIndex(target), entityGeneration(target), {
1315
+ operation: 'relationship-insert',
1316
+ component: component.name,
1317
+ expectedGeneration: entityGeneration(target),
1318
+ actualGeneration,
1319
+ }),
1320
+ );
1321
+ }
1322
+ const targetArch = this.graph.archetypes[targetRec.archetypeId];
1323
+ const hasMirror =
1324
+ targetArch?.components.some((candidate) => componentId(candidate) === componentId(mirror)) ??
1325
+ false;
1326
+ let mirrorAdded = false;
1327
+ if (!hasMirror) {
1328
+ const added = this.addComponentCore(
1329
+ target,
1330
+ { component: mirror, data: {} as Partial<ShapeOf<ComponentSchema>> },
1331
+ true,
1332
+ false,
1333
+ );
1334
+ if (!added.ok) return added;
1335
+ mirrorAdded = true;
1336
+ }
1337
+ const length = this.relationshipTargetLength(component, target);
1338
+ const capacity = this.ensureArrayCapacity(target, mirror, role.targetField, length + 1);
1339
+ if (capacity.ok) return ok(mirrorAdded);
1340
+ if (mirrorAdded) {
1341
+ // Mirror creation is part of preparation, not publication. If capacity
1342
+ // reservation fails, remove the newly-created target component through
1343
+ // the silent internal path so the caller sees no target, epoch, or
1344
+ // structural evidence side effect.
1345
+ const rolledBack = this.removeComponentCore(target, mirror, true);
1346
+ if (!rolledBack.ok) {
1347
+ this.poisonAfterEntityMutation('World.prepareRelationshipInsert', rolledBack.error);
1348
+ return rolledBack;
1349
+ }
1350
+ }
1351
+ return capacity;
1209
1352
  }
1210
1353
 
1211
- /** Shared structural preflight for direct and deferred writes. */
1212
- private internalpreflightComponentData(
1213
- holder: EntityHandle | null,
1214
- componentData: ComponentData,
1215
- pendingEntities?: ReadonlySet<number>,
1216
- unavailableEntities?: ReadonlySet<number>,
1354
+ /** Append `holder` to the materialized target list. */
1355
+ private relationshipOnInsert(
1356
+ holder: EntityHandle,
1357
+ component: Component,
1358
+ value: Record<string, unknown>,
1359
+ // addComponentCore may have prepared the target before migrating the
1360
+ // source. Carry that one-shot fact so the final commit can publish a
1361
+ // newly-created mirror exactly once instead of preparing a second time.
1362
+ preparedMirrorAdded?: boolean,
1217
1363
  ): Result<void, EcsError> {
1218
- return this.componentAccess.preflightComponentData(
1219
- holder,
1220
- componentData,
1221
- pendingEntities,
1222
- unavailableEntities,
1223
- );
1224
- }
1364
+ const role = relationshipRole(component);
1365
+ if (role?.kind !== 'source') return ok(undefined);
1366
+ const target = this.relationshipTargetEntity(component, value);
1367
+ if (target === null) return ok(undefined);
1368
+ const mirror = relationshipMirror(component);
1369
+ /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
1370
+ if (mirror === undefined) return ok(undefined);
1371
+
1372
+ let mirrorAdded = preparedMirrorAdded ?? false;
1373
+ if (preparedMirrorAdded === undefined) {
1374
+ const prepared = this.prepareRelationshipInsert(component, value);
1375
+ if (!prepared.ok) {
1376
+ // A dangling source edge is still useful state: hierarchy/animation
1377
+ // projections report the missing target. The target mirror cannot be
1378
+ // updated, but insertion itself remains atomic and successful.
1379
+ if (prepared.error.code === 'stale-entity') return ok(undefined);
1380
+ return prepared;
1381
+ }
1382
+ mirrorAdded = prepared.value;
1383
+ }
1225
1384
 
1226
- /** */ private internalallocateIndex(): number {
1227
- return this.componentAccess.allocateIndex();
1228
- }
1229
- /** */ private internalrecordIsLive(r: EntityRecord | undefined, g: number): r is EntityRecord {
1230
- return this.componentAccess.recordIsLive(r, g);
1231
- }
1232
- /** */ private internallookupAlive(
1233
- e: EntityHandle,
1234
- op: string,
1235
- c?: string,
1236
- ): Result<EntityRecord, EcsError> {
1237
- return this.componentAccess.lookupAlive(e, op, c);
1238
- }
1239
- /** */ private internalreadRow<S extends ComponentSchema>(
1240
- a: Archetype,
1241
- c: Component<string, S>,
1242
- r: number,
1243
- ): ShapeOf<S> {
1244
- return this.componentAccess.readRow(a, c, r);
1245
- }
1246
- /** */ private internalwriteEntitySelf(a: Archetype, r: number, h: EntityHandle): void {
1247
- this.componentAccess.writeEntitySelf(a, r, h);
1248
- }
1249
- /** */ private internalwriteRow<S extends ComponentSchema>(
1250
- a: Archetype,
1251
- c: Component<string, S>,
1252
- r: number,
1253
- v: ShapeOf<S>,
1254
- ): void {
1255
- this.componentAccess.writeRow(a, c, r, v);
1256
- }
1257
- /** */ private internalreleaseManagedRefsOnRow(a: Archetype, c: Component, r: number): void {
1258
- this.componentAccess.releaseManagedRefsOnRow(a, c, r);
1385
+ // Lazy-create the mirror component on the target when absent (D-3c).
1386
+ const targetSlot = entityIndex(target);
1387
+ const targetRec = this.records[targetSlot];
1388
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
1389
+ const targetArch = this.graph.archetypes[targetRec.archetypeId];
1390
+ const mirrorLocalId = componentId(mirror);
1391
+ const hasMirror =
1392
+ targetArch?.components.some((component) => componentId(component) === mirrorLocalId) ?? false;
1393
+ if (!hasMirror) {
1394
+ const added = this.addComponentCore(
1395
+ target,
1396
+ {
1397
+ component: mirror,
1398
+ data: {} as Partial<ShapeOf<ComponentSchema>>,
1399
+ },
1400
+ true,
1401
+ false,
1402
+ );
1403
+ if (!added.ok) return added;
1404
+ mirrorAdded = true;
1405
+ }
1406
+ const slot = this.relationshipTargetLength(component, target);
1407
+ const mirrored = this.appendArrayElement(target, mirror, role.targetField, holder);
1408
+ if (!mirrored.ok) return mirrored;
1409
+ this.relationshipIndex(component)?.attach(holder, target, slot);
1410
+ if (mirrorAdded) {
1411
+ this.internalmarkComponentsAdded(target, [mirrorLocalId]);
1412
+ this.advanceStructureEpoch();
1413
+ this.recordStructuralEvidence({
1414
+ kind: 'component-added',
1415
+ entity: target,
1416
+ componentId: mirrorLocalId,
1417
+ });
1418
+ }
1419
+ return ok(undefined);
1259
1420
  }
1260
- /** */ private internalrelationshipOnInsert(
1261
- h: EntityHandle,
1262
- c: Component,
1263
- v: Record<string, unknown>,
1264
- preparation?: unknown,
1421
+
1422
+ /** Remove `holder` from the materialized target list. */
1423
+ private relationshipOnRemove(
1424
+ holder: EntityHandle,
1425
+ component: Component,
1426
+ oldValue: Record<string, unknown>,
1265
1427
  ): Result<void, EcsError> {
1266
- return this.componentAccess.relationshipOnInsert(h, c, v, preparation as never);
1267
- }
1268
- /** */ private internalprepareRelationshipInsert(
1269
- c: Component,
1270
- v: Record<string, unknown>,
1271
- ): Result<unknown, EcsError> {
1272
- return this.componentAccess.prepareRelationshipInsert(c, v);
1273
- }
1274
- /** */ private internalreleaseRelationshipPreparation(preparation: unknown): void {
1275
- this.componentAccess.releaseRelationshipPreparation(preparation as never);
1428
+ const role = relationshipRole(component);
1429
+ if (role?.kind !== 'source') return ok(undefined);
1430
+ const target = this.relationshipTargetEntity(component, oldValue);
1431
+ if (target === null) return ok(undefined);
1432
+ const mirror = relationshipMirror(component);
1433
+ /* istanbul ignore next -- defineComponent relationship validation guarantees mirror exists */
1434
+ if (mirror === undefined) return ok(undefined);
1435
+ const targetSlot = entityIndex(target);
1436
+ const targetRec = this.records[targetSlot];
1437
+ if (!this.recordIsLive(targetRec, entityGeneration(target))) return ok(undefined);
1438
+
1439
+ const index = this.relationshipIndex(component);
1440
+ if (index === undefined) return ok(undefined);
1441
+ const slot = index.slotOf(holder);
1442
+ if (slot === undefined || index.targetOf(holder) !== target) return ok(undefined);
1443
+ const mirrored = this.removeArrayElementAt(target, mirror, role.targetField, slot);
1444
+ if (!mirrored.ok) return mirrored;
1445
+ index.detach(holder);
1446
+ if (mirrored.value !== undefined) index.updateSlot(mirrored.value, target, slot);
1447
+ return ok(undefined);
1276
1448
  }
1277
- /** */ private internalrelationshipOnRemove(
1278
- h: EntityHandle,
1279
- c: Component,
1280
- v: Record<string, unknown>,
1281
- ): Result<void, EcsError> {
1282
- return this.componentAccess.relationshipOnRemove(h, c, v);
1449
+
1450
+ private linkedSpawnMirrorField(mirror: Component): string | undefined {
1451
+ const source = relationshipSource(mirror);
1452
+ const role = source === undefined ? undefined : relationshipRole(source);
1453
+ return role?.kind === 'source' && role.linkedSpawn ? role.targetField : undefined;
1283
1454
  }
1284
1455
 
1285
- // ──────────────────────────────────────────────────────────────────────────
1456
+ private relationshipLinkedSpawnChildren(
1457
+ entity: EntityHandle,
1458
+ archetype: Archetype,
1459
+ ): EntityHandle[] {
1460
+ const record = this.records[entityIndex(entity)];
1461
+ const row = record === undefined ? -1 : this.tableRow(record);
1462
+ const collected: EntityHandle[] = [];
1463
+ for (const component of archetype.components) {
1464
+ const mirrorField = this.linkedSpawnMirrorField(component);
1465
+ if (mirrorField === undefined) continue;
1466
+ const snapshot = this.readRow(archetype, component, row) as Record<string, unknown>;
1467
+ const list = snapshot[mirrorField];
1468
+ if (!(list instanceof Uint32Array)) continue;
1469
+ for (const raw of list) {
1470
+ if (raw !== ENTITY_NULL_RAW) collected.push(raw as EntityHandle);
1471
+ }
1472
+ }
1473
+ return collected;
1474
+ }
1475
+
1476
+ /**
1477
+ * Read component data from an entity.
1478
+ *
1479
+ * **Transient view contract (feat-20260602):** for fixed-capacity
1480
+ * `array<T,N>` and `buffer<N>` fields, the returned `TypedArray` (and any
1481
+ * subarray of it) aliases the archetype column buffer directly. The view is
1482
+ * valid only until the next structural change (`spawn` / `despawn` /
1483
+ * `addComponent` / `removeComponent`). Holding a view across a structural
1484
+ * change is undefined behaviour -- the backing `ArrayBuffer` is detached on
1485
+ * column growth, and swap-remove at the same row index points to the wrong
1486
+ * entity. **Re-fetch `world.get(e, C)` on every access.** See
1487
+ * `packages/ecs/README.md` Transient view contract section.
1488
+ *
1489
+ * @returns `Result<ShapeOf<S>, EcsError>` —
1490
+ * `ok(ShapeOf<S>)` on success;
1491
+ * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
1492
+ * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
1493
+ * the entity does not have the component (a never-present component on
1494
+ * this entity degrades to the same `component-not-present` path — there is
1495
+ * no separate "not registered" failure; components are global at
1496
+ * `defineComponent` time).
1497
+ *
1498
+ * @example
1499
+ * ```ts
1500
+ * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
1501
+ * const world = new World();
1502
+ * const e = world.spawn({ component: Position, data: { x: 1, y: 2 } }).unwrap();
1503
+ * const r = world.get(e, Position);
1504
+ * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
1505
+ * const pos = r.value;
1506
+ * ```
1507
+ */
1508
+ get<S extends ComponentSchema>(
1509
+ entity: EntityHandle,
1510
+ component: Component<string, S>,
1511
+ ): Result<ShapeOf<S>, EcsError> {
1512
+ const record = this.lookupAlive(entity, 'get', component.name);
1513
+ if (!record.ok) return record;
1514
+
1515
+ const rec = record.value;
1516
+ const arch = this.graph.archetypes[rec.archetypeId];
1517
+ /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
1518
+ if (!arch) {
1519
+ return err(
1520
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1521
+ operation: 'get',
1522
+ component: component.name,
1523
+ expectedGeneration: entityGeneration(entity),
1524
+ actualGeneration: rec.generation,
1525
+ }),
1526
+ );
1527
+ }
1528
+
1529
+ // Check if this archetype has the component (using World-local ID).
1530
+ const localId = componentId(component);
1531
+ if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
1532
+ return err(new ComponentNotPresentError(entity as number, component.name));
1533
+ }
1534
+
1535
+ return ok(this.readRow(arch, component, this.tableRow(rec)));
1536
+ }
1537
+
1538
+ /**
1539
+ * Column-level zero-copy view of an `array<T, N>` / `array<T>` field.
1540
+ *
1541
+ * Resolves the live byte region for `(entity, component, fieldName)`
1542
+ * directly at the column level and returns the element-typed TypedArray
1543
+ * aliasing it (`view.buffer` is the SSOT byte region; mutations route
1544
+ * through `world.set`). Unlike `get`, this does NOT build the
1545
+ * `{}` whole-component object nor walk every schema field. Per-frame
1546
+ * consumers that need one column (the resolved world mat4) take this path to
1547
+ * avoid the `get` overhead (1 `{}` alloc + N-field readRow walk).
1548
+ *
1549
+ * Fixed `array<T,N>` columns (feat-20260602) store their elements inline, so
1550
+ * the view aliases the archetype column buffer directly (no BufferPool
1551
+ * indirection); variable `array<T>` columns still alias the BufferPool slot.
1552
+ * The returned view's element type follows the schema element type
1553
+ * (`array<entity,N>` -> `Uint32Array`, `array<f32,N>` -> `Float32Array`,
1554
+ * etc.) -- the prior f32-only early-return gate is removed.
1555
+ *
1556
+ * **Transient view contract:** the returned `TypedArray` aliases the column
1557
+ * buffer and is valid only until the next structural change (`spawn` /
1558
+ * `despawn` / `addComponent` / `removeComponent`). Column growth
1559
+ * (`growColumn`) detaches the old `ArrayBuffer` via `transfer()`; a
1560
+ * swap-remove at the same row index leaves the view pointing to the wrong
1561
+ * entity. **Callers must re-fetch `getArrayView` on every access** and must
1562
+ * not hold the view across any operation that may cause archetype migration.
1563
+ * All existing per-frame consumers (`propagateTransforms` / `render-extract`
1564
+ * / `pick`) already conform -- they fetch the view inside a single pass with
1565
+ * no intervening structural changes.
1566
+ *
1567
+ * Returns `undefined` when the entity is dead, the component is absent, the
1568
+ * field does not exist, or the field is not an `array<...>` column.
1569
+ *
1570
+ * Engine-internal fast path; AI users read the typed view through
1571
+ * `world.get(e, GlobalTransform).world`. The accessor is the zero-materialization
1572
+ * route the propagate kernel and render walk use.
1573
+ */
1574
+ private getArrayView(
1575
+ entity: EntityHandle,
1576
+ component: Component,
1577
+ fieldName: string,
1578
+ ): FieldView | undefined {
1579
+ const record = this.lookupAlive(entity, 'getArrayView', component.name);
1580
+ if (!record.ok) return undefined;
1581
+
1582
+ const rec = record.value;
1583
+ const arch = this.graph.archetypes[rec.archetypeId];
1584
+ if (!arch) return undefined;
1585
+ return this.readArrayView(arch, component, this.tableRow(rec), fieldName);
1586
+ }
1587
+
1588
+ /**
1589
+ * Write (partial) component data to an entity.
1590
+ *
1591
+ * @returns `Result<void, EcsError>` —
1592
+ * `ok(void)` on success;
1593
+ * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
1594
+ * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`) if
1595
+ * entity does not have the component (F-02: no longer silently ignores).
1596
+ *
1597
+ * @example
1598
+ * ```ts
1599
+ * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
1600
+ * const world = new World();
1601
+ * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
1602
+ * const r = world.set(e, Position, { x: 10 });
1603
+ * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
1604
+ * r.unwrap();
1605
+ * ```
1606
+ */
1607
+ set<S extends ComponentSchema, C extends Component<string, S>>(
1608
+ entity: EntityHandle,
1609
+ component: C & WritableComponent<C>,
1610
+ value: Partial<InputShapeOf<S>>,
1611
+ markChanged = true,
1612
+ ): Result<void, EcsError> {
1613
+ const poisoned = this.poisonedResult<void>();
1614
+ if (poisoned !== undefined) return poisoned;
1615
+ if (isRelationshipTarget(component)) return this.relationshipTargetWriteError(component, 'set');
1616
+ const record = this.lookupAlive(entity, 'set', component.name);
1617
+ if (!record.ok) return record;
1618
+
1619
+ const rec = record.value;
1620
+ const arch = this.graph.archetypes[rec.archetypeId];
1621
+ /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
1622
+ if (!arch) {
1623
+ return err(
1624
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1625
+ operation: 'set',
1626
+ component: component.name,
1627
+ expectedGeneration: entityGeneration(entity),
1628
+ actualGeneration: rec.generation,
1629
+ }),
1630
+ );
1631
+ }
1632
+ const row = this.tableRow(rec);
1633
+ const localId = componentId(component);
1634
+ if (!arch.components.some((candidate) => componentId(candidate) === localId)) {
1635
+ // F-02: set on missing component returns err instead of silent ignore
1636
+ return err(new ComponentNotPresentError(entity as number, component.name));
1637
+ }
1638
+ const relationship = relationshipRole(component);
1639
+ if (relationship?.kind === 'source') {
1640
+ return this.setRelationshipSource(
1641
+ entity,
1642
+ component,
1643
+ value as Record<string, unknown>,
1644
+ rec,
1645
+ arch,
1646
+ markChanged,
1647
+ );
1648
+ }
1649
+ const valuePreflight = this.preflightComponentFieldValues(entity, {
1650
+ component,
1651
+ data: value,
1652
+ });
1653
+ if (!valuePreflight.ok) return valuePreflight;
1654
+ const currentValue = this.readRow(arch, component, row) as Record<string, unknown>;
1655
+ const mergedValue = {
1656
+ ...currentValue,
1657
+ ...(value as Record<string, unknown>),
1658
+ };
1659
+ const enumError = validateEnumFieldValues(component, mergedValue, entity as number);
1660
+ if (enumError !== null) return err(enumError as unknown as EcsError);
1661
+
1662
+ if (component.storage === 'sparse') {
1663
+ if (markChanged) this.markComponentChanged(entity, component);
1664
+ return ok(undefined);
1665
+ }
1666
+ const fieldCols = this.table(arch).storage.get(localId)?.fields;
1667
+ if (fieldCols === undefined) {
1668
+ throw new Error(`Table storage for ${component.name} does not exist.`);
1669
+ }
1670
+ for (const fieldName of Object.keys(value)) {
1671
+ const col = fieldCols.get(fieldName);
1672
+ if (!col) {
1673
+ continue;
1674
+ }
1675
+ const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
1676
+ // M1/M2 release loop (set path): release the prior managed value
1677
+ // BEFORE writing the new one. Single SSOT helper `releaseManagedFieldOnRow`
1678
+ // (feat-20260614 D-2) covers every managed-field family (`ref<T>` /
1679
+ // `string` / `buffer` / variable `array<T>`); it self-skips fields that
1680
+ // do not match `isManagedField` here, but for set-ref/string we already
1681
+ // gated on it so the call is hot. Zeroes the column when applicable.
1682
+ if (isManagedField(fieldType)) {
1683
+ this.releaseManagedFieldOnRow(arch, component, row, fieldName);
1684
+ }
1685
+ const raw = (value as Record<string, unknown>)[fieldName];
1686
+ if (fieldType === 'bool') {
1687
+ col.view[row] = raw ? 1 : 0;
1688
+ } else if (isEntityField(fieldType)) {
1689
+ // M3 entity field overwrite: encode null as ENTITY_NULL_RAW;
1690
+ // otherwise store the Entity bit pattern (slot+gen).
1691
+ col.view[row] = raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
1692
+ } else if (isManagedBufferField(fieldType)) {
1693
+ // M2 set path: collapsed-vocab keyword family `'buffer'` (variable) +
1694
+ // `'buffer<N>'` (fixed). The two shapes diverge here:
1695
+ // - `buffer<N>` — schema-declared byteLength is fixed; raw must be a
1696
+ // `Uint8Array` whose `byteLength === N`. Mismatched payloads route
1697
+ // `FixedSizeMismatchError` via Result.err so AI users observe an
1698
+ // explicit failure instead of silent truncation (verify round 1
1699
+ // B1 fix; charter P3 — explicit failure > silent acceptance).
1700
+ // - `'buffer'` — variable capacity; release the prior slot then
1701
+ // alloc a fresh one sized to the new payload's byteLength (mirrors
1702
+ // the `array<T>` set path's release-then-alloc D-5 ordering).
1703
+ // raw is normalized from any AllowSharedBufferSource view to a
1704
+ // Uint8Array over its bytes (feat-20260621 V2 / AC-A4). Non-buffer
1705
+ // raw (a forced cast feeding e.g. a number) normalizes to null and
1706
+ // is treated as a no-op (column slot stays unchanged).
1707
+ const isFixedBuffer = fieldType !== 'buffer';
1708
+ const bytes = normalizeBufferWrite(raw);
1709
+ if (bytes !== null) {
1710
+ if (isFixedBuffer) {
1711
+ // feat-20260602: fixed `buffer<N>` lives inline as a stride-N u8
1712
+ // column (arity = N bytes). Write the payload straight into the
1713
+ // row window -- no BufferPool slot.
1714
+ const expected = bufferFieldByteLength(fieldType);
1715
+ if (bytes.byteLength !== expected) {
1716
+ return err(new FixedSizeMismatchError(fieldName, expected, bytes.byteLength));
1717
+ }
1718
+ const arity = col.arity;
1719
+ (col.view as Uint8Array).set(bytes.subarray(0, arity), row * arity);
1720
+ } else {
1721
+ // Variable `'buffer'` set: release prior slot via SSOT helper
1722
+ // (feat-20260614 D-2) then alloc fresh sized to the new payload
1723
+ // (verify round 1 B2 fix path). The helper zeroes the column on
1724
+ // release; sentinel slot id 0 is a no-op.
1725
+ this.releaseManagedFieldOnRow(arch, component, row, fieldName);
1726
+ const allocR = this.bufferPool.alloc(bytes.byteLength);
1727
+ if (!allocR.ok) {
1728
+ const ctx: ErrorContext = {
1729
+ systemName: `World.set (${component.name}.${fieldName})`,
1730
+ };
1731
+ this.routeError(allocR.error as EcsError, ctx);
1732
+ col.view[row] = 0;
1733
+ continue;
1734
+ }
1735
+ const slot = allocR.value;
1736
+ slot.view.set(bytes);
1737
+ col.view[row] = slot.id;
1738
+ }
1739
+ }
1740
+ } else if (fieldType === 'string') {
1741
+ // M1 string-field set path (AC-05 path 3): the prior handle was
1742
+ // already released by the unified `isManagedField` pre-write block
1743
+ // above (D-R3) -- here we just alloc the new handle and store the
1744
+ // u32. Mirrors the array<T> release-then-alloc pattern (D-5) so AI
1745
+ // users observe the UniqueRefStore _liveCount net-zero invariant
1746
+ // on field overwrite. Missing / non-string raw -> '' fallback
1747
+ // (AC-06).
1748
+ const text = typeof raw === 'string' ? raw : '';
1749
+ const handle = this.uniqueRefs.alloc<'String'>('String', text);
1750
+ col.view[row] = unwrapHandle(handle);
1751
+ } else {
1752
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1753
+ if (arrayMeta !== undefined) {
1754
+ // M1 set path for array<T> / array<T,N> fields (feat-20260614 D-3
1755
+ // calling convention). The set semantics mirror spawn: release the
1756
+ // prior slot via the SSOT helper, then alloc a fresh one sized to
1757
+ // the new value, copy bytes verbatim, store slot id (+ count for
1758
+ // variable). Fixed `array<T,N>` is inline — the helper short-
1759
+ // circuits and writeArrayField writes directly into the row's
1760
+ // stride window with no pool traffic.
1761
+ this.releaseManagedFieldOnRow(arch, component, row, fieldName);
1762
+ this.writeArrayField(arch, component, row, fieldName, fieldType, arrayMeta, raw);
1763
+ } else {
1764
+ // The pre-write `releaseManagedFieldOnRow` block above already
1765
+ // released the prior `'shared<T>'` rc via SharedRefStore.release;
1766
+ // here we retain the new value so net rc delta is +1 / 0 / -1 per
1767
+ // M4 invariant (set: -1+1=0; spawn: 0+1=+1; despawn: -1).
1768
+ col.view[row] = raw as number;
1769
+ if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
1770
+ this.retainSharedScalarHandle(raw as number, component.name, fieldName);
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+ if (markChanged) this.markComponentChanged(entity, component);
1776
+ return ok(undefined);
1777
+ }
1778
+
1779
+ // ──────────────────────────────────────────────────────────────────────────
1780
+ // Internal relationship array maintenance. Public array mutation is always
1781
+ // expressed as one `world.set` payload; these helpers only implement the
1782
+ // engine-owned target projection and backpointer swap-remove path.
1783
+ //
1784
+ // Append/remove are engine-owned relationship maintenance only.
1785
+ //
1786
+ // ──────────────────────────────────────────────────────────────────────────
1787
+
1788
+ /**
1789
+ * Append `value` to the variable `array<T>` field `fieldName` on `entity`.
1790
+ *
1791
+ * BufferPool grow is amortized O(1) via the size-class freelist (research
1792
+ * Finding 5). Relationship target arrays grow byte-wise.
1793
+ *
1794
+ * @returns `Result<void, EcsError>` with the normal stale/component errors.
1795
+ *
1796
+ * The helper is called only by relationship synchronization.
1797
+ */
1798
+ private appendArrayElement(
1799
+ entity: EntityHandle,
1800
+ component: Component,
1801
+ fieldName: string,
1802
+ value: EntityHandle,
1803
+ ): Result<void, EcsError> {
1804
+ const record = this.lookupAlive(entity, 'relationship-append', component.name);
1805
+ if (!record.ok) return record;
1806
+ const rec = record.value;
1807
+ const arch = this.graph.archetypes[rec.archetypeId];
1808
+ /* istanbul ignore next -- alive record always has a valid archetype */
1809
+ if (!arch) {
1810
+ return err(
1811
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1812
+ operation: 'relationship-append',
1813
+ component: component.name,
1814
+ expectedGeneration: entityGeneration(entity),
1815
+ actualGeneration: rec.generation,
1816
+ }),
1817
+ );
1818
+ }
1819
+ const row = this.tableRow(rec);
1820
+ const localId = componentId(component);
1821
+ const fieldCols = this.table(arch).storage.get(localId)?.fields;
1822
+ if (!fieldCols) {
1823
+ return err(new ComponentNotPresentError(entity as number, component.name));
1824
+ }
1825
+ const col = fieldCols.get(fieldName);
1826
+ if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
1827
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1828
+ if (arrayMeta === undefined) {
1829
+ return err(new ComponentNotPresentError(entity as number, component.name));
1830
+ }
1831
+ const meta = TYPE_METADATA[arrayMeta.elementType];
1832
+ /* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
1833
+ if (!meta) return err(new ComponentNotPresentError(entity as number, component.name));
1834
+ // biome-ignore lint/style/noNonNullAssertion: every array element type has a byte size
1835
+ const elementBytes = meta.byteSize!;
1836
+ const slotId = col.view[row] as number;
1837
+
1838
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
1839
+ /* istanbul ignore next -- variable arrays always allocate the count column */
1840
+ if (countCol === undefined) {
1841
+ return err(new ComponentNotPresentError(entity as number, component.name));
1842
+ }
1843
+ const count = countCol.view[row] as number;
1844
+ const newCount = count + 1;
1845
+ const newByteLength = newCount * elementBytes;
1846
+
1847
+ let liveSlotId = slotId;
1848
+ if (liveSlotId === 0) {
1849
+ // Empty/unallocated slot — alloc fresh.
1850
+ const allocR = this.bufferPool.alloc(newByteLength);
1851
+ if (!allocR.ok) return err(allocR.error);
1852
+ liveSlotId = allocR.value.id;
1853
+ col.view[row] = liveSlotId;
1854
+ } else {
1855
+ // A previously-allocated slot may have drained below its high-water
1856
+ // mark: swap-remove (`_removeArrayElementByValue`) and `pop` only lower
1857
+ // the count column, never shrink the managed buffer. When the refilled
1858
+ // length still fits inside the slot's current logical length, reuse the
1859
+ // buffer in place -- routing through `grow` would hit the (correct, but
1860
+ // here irrelevant) shrink-not-supported guard and strand the field
1861
+ // (e.g. `Children.entities` never repopulating after a full drain).
1862
+ if (newByteLength > this.bufferPool.view(liveSlotId).byteLength) {
1863
+ const growR = this.bufferPool.grow(liveSlotId, newByteLength);
1864
+ if (!growR.ok) return err(growR.error);
1865
+ }
1866
+ }
1867
+ const liveBytes = this.bufferPool.view(liveSlotId);
1868
+ // Reinterpret the slot bytes as the element-typed view and write at the
1869
+ // tail index. Entity values are stored as their u32 bit pattern.
1870
+ writeArrayElementAt(liveBytes, count, arrayMeta.elementType, value as number);
1871
+ countCol.view[row] = newCount;
1872
+ this.markComponentChanged(entity, component);
1873
+ return ok(undefined);
1874
+ }
1875
+
1876
+ private ensureArrayCapacity(
1877
+ entity: EntityHandle,
1878
+ component: Component,
1879
+ fieldName: string,
1880
+ minimum: number,
1881
+ ): Result<void, EcsError> {
1882
+ const record = this.lookupAlive(entity, 'relationship-capacity', component.name);
1883
+ if (!record.ok) return record;
1884
+ const rec = record.value;
1885
+ const arch = this.graph.archetypes[rec.archetypeId];
1886
+ if (!arch) {
1887
+ return err(
1888
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
1889
+ operation: 'relationship-capacity',
1890
+ component: component.name,
1891
+ expectedGeneration: entityGeneration(entity),
1892
+ actualGeneration: rec.generation,
1893
+ }),
1894
+ );
1895
+ }
1896
+ const row = this.tableRow(rec);
1897
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
1898
+ if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
1899
+ const col = fieldCols.get(fieldName);
1900
+ if (!col) return err(new ComponentNotPresentError(entity as number, component.name));
1901
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1902
+ if (arrayMeta === undefined) {
1903
+ return err(new ComponentNotPresentError(entity as number, component.name));
1904
+ }
1905
+ const meta = TYPE_METADATA[arrayMeta.elementType];
1906
+ if (!meta?.byteSize) {
1907
+ return err(new ComponentNotPresentError(entity as number, component.name));
1908
+ }
1909
+ const maximum = Math.floor(262_144 / meta.byteSize);
1910
+ if (!Number.isSafeInteger(minimum) || minimum < 0 || minimum > maximum) {
1911
+ return err(new ManagedBufferOutOfBoundsError(minimum, maximum));
1912
+ }
1913
+
1914
+ const byteLength = minimum * meta.byteSize;
1915
+ const slotId = col.view[row] as number;
1916
+ if (slotId === 0) {
1917
+ if (minimum === 0) return ok(undefined);
1918
+ const allocated = this.bufferPool.alloc(byteLength);
1919
+ if (!allocated.ok) return allocated;
1920
+ col.view[row] = allocated.value.id;
1921
+ return ok(undefined);
1922
+ }
1923
+ if (this.bufferPool.view(slotId).byteLength >= byteLength) return ok(undefined);
1924
+ const grown = this.bufferPool.grow(slotId, byteLength);
1925
+ return grown.ok ? ok(undefined) : grown;
1926
+ }
1927
+
1928
+ /**
1929
+ * Remove one variable-array element at a known slot. Relationship holders
1930
+ * supply the slot from their backpointer, so this is O(1) and never scans
1931
+ * the materialized target array.
1932
+ */
1933
+ private removeArrayElementAt(
1934
+ entity: EntityHandle,
1935
+ component: Component<string, ComponentSchema>,
1936
+ fieldName: string,
1937
+ slot: number,
1938
+ ): Result<EntityHandle | undefined, EcsError> {
1939
+ const record = this.lookupAlive(entity, 'removeArrayElementAt', component.name);
1940
+ if (!record.ok) return record;
1941
+ const rec = record.value;
1942
+ const arch = this.graph.archetypes[rec.archetypeId];
1943
+ if (!arch) return err(new ComponentNotPresentError(entity as number, component.name));
1944
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
1945
+ if (!fieldCols) return err(new ComponentNotPresentError(entity as number, component.name));
1946
+ const col = fieldCols.get(fieldName);
1947
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
1948
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
1949
+ if (!col || !arrayMeta || arrayMeta.length !== undefined || !countCol) {
1950
+ return err(new ComponentNotPresentError(entity as number, component.name));
1951
+ }
1952
+ const row = this.tableRow(rec);
1953
+ const count = countCol.view[row] as number;
1954
+ if (slot < 0 || slot >= count) return ok(undefined);
1955
+ const slotId = col.view[row] as number;
1956
+ if (slotId === 0) return ok(undefined);
1957
+ const liveBytes = this.bufferPool.view(slotId);
1958
+ const last = count - 1;
1959
+ const moved =
1960
+ slot === last
1961
+ ? undefined
1962
+ : (readArrayElementAt(liveBytes, last, arrayMeta.elementType) as EntityHandle);
1963
+ if (slot !== last) {
1964
+ writeArrayElementAt(liveBytes, slot, arrayMeta.elementType, moved as number);
1965
+ }
1966
+ countCol.view[row] = last;
1967
+ this.markComponentChanged(entity, component);
1968
+ return ok(moved);
1969
+ }
1970
+
1971
+ // ──────────────────────────────────────────────────────────────────────────
1972
+ // addComponent / removeComponent (archetype migration via edges, AC-07)
1973
+ // ──────────────────────────────────────────────────────────────────────────
1974
+
1975
+ /**
1976
+ * Add a component to an existing entity, triggering archetype migration.
1977
+ *
1978
+ * @returns `Result<void, EcsError>` —
1979
+ * `ok(void)` on success;
1980
+ * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
1981
+ * `err(ComponentAlreadyPresentError)` (`.code = 'component-already-present'`)
1982
+ * if entity already has the component (E-03).
1983
+ *
1984
+ * @example
1985
+ * ```ts
1986
+ * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
1987
+ * const Velocity = defineComponent('Velocity', { dx: 'f32', dy: 'f32' });
1988
+ * const world = new World();
1989
+ * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
1990
+ * const r = world.addComponent(e, { component: Velocity, data: { dx: 1, dy: 0 } });
1991
+ * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
1992
+ * r.unwrap();
1993
+ * ```
1994
+ */
1995
+ addComponent<S extends ComponentSchema, C extends Component<string, S>>(
1996
+ entity: EntityHandle,
1997
+ componentData: ComponentData<S> & { component: C & WritableComponent<C> },
1998
+ ): Result<void, EcsError> {
1999
+ const poisoned = this.poisonedResult<void>();
2000
+ if (poisoned !== undefined) return poisoned;
2001
+ if (
2002
+ isRelationshipTarget(componentData.component) &&
2003
+ this.relationshipTargetPayloadWrites(componentData.data as Record<string, unknown>)
2004
+ )
2005
+ return this.relationshipTargetWriteError(componentData.component, 'addComponent');
2006
+ return this.addComponentCore(entity, componentData, false);
2007
+ }
2008
+
2009
+ /**
2010
+ * Core implementation of `addComponent` with reentry guard.
2011
+ *
2012
+ * @param internal — `true` when called from relationship maintenance
2013
+ * (lazy mirror create or exclusive reparent).
2014
+ */
2015
+ private addComponentCore<S extends ComponentSchema>(
2016
+ entity: EntityHandle,
2017
+ componentData: ComponentData<S>,
2018
+ internal: boolean,
2019
+ resolveRequirements = true,
2020
+ // A relationship preflight can create a target mirror before the source
2021
+ // migration. Preserve whether that preflight created it through the
2022
+ // exclusive-reparent recursion.
2023
+ preparedMirrorAdded?: boolean,
2024
+ ): Result<void, EcsError> {
2025
+ const record = this.lookupAlive(entity, 'addComponent', componentData.component.name);
2026
+ if (!record.ok) return record;
2027
+
2028
+ const rec = record.value;
2029
+ let srcArch = this.graph.archetypes[rec.archetypeId];
2030
+ /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
2031
+ if (!srcArch) {
2032
+ return err(
2033
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
2034
+ operation: 'addComponent',
2035
+ component: componentData.component.name,
2036
+ expectedGeneration: entityGeneration(entity),
2037
+ actualGeneration: rec.generation,
2038
+ }),
2039
+ );
2040
+ }
2041
+
2042
+ const preflight = this.preflightComponentData(entity, componentData);
2043
+ if (!preflight.ok) return preflight;
2044
+
2045
+ const filled = fillComponentDefaults(
2046
+ componentData.component,
2047
+ componentData.data as Record<string, unknown>,
2048
+ );
2049
+
2050
+ // Reserve a source relationship's target before adding any required
2051
+ // components. A capacity failure must not leave a required component on
2052
+ // the holder, nor a lazily-created mirror on the target. Existing
2053
+ // exclusive sources keep their dedicated reparent path below.
2054
+ let relationshipMirrorAdded = preparedMirrorAdded;
2055
+ const componentAlreadyPresent = srcArch.components.some(
2056
+ (candidate) => componentId(candidate) === componentId(componentData.component),
2057
+ );
2058
+ if (
2059
+ !componentAlreadyPresent &&
2060
+ !internal &&
2061
+ relationshipRole(componentData.component as Component)?.kind === 'source' &&
2062
+ relationshipMirrorAdded === undefined
2063
+ ) {
2064
+ const prepared = this.prepareRelationshipInsert(
2065
+ componentData.component as Component,
2066
+ filled as Record<string, unknown>,
2067
+ );
2068
+ if (!prepared.ok) return prepared;
2069
+ relationshipMirrorAdded = prepared.value;
2070
+ // Preparing a relationship may materialize the mirror on the same
2071
+ // entity (self-targeting relationships are valid when `allowSelf` is
2072
+ // enabled). That nested add migrates `rec`, so the source archetype
2073
+ // captured above is no longer authoritative. Refresh it before the
2074
+ // requested component migration instead of removing a row from the
2075
+ // stale, already-empty table.
2076
+ srcArch = this.graph.archetypes[rec.archetypeId];
2077
+ if (srcArch === undefined) {
2078
+ return err(
2079
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
2080
+ operation: 'addComponent',
2081
+ component: componentData.component.name,
2082
+ expectedGeneration: rec.generation,
2083
+ actualGeneration: rec.generation,
2084
+ }),
2085
+ );
2086
+ }
2087
+ }
2088
+
2089
+ // Generic component requirements are resolved once at the structural
2090
+ // boundary. Explicit data remains authoritative; only missing required
2091
+ // identities are added before the requested component is migrated.
2092
+ if (resolveRequirements) {
2093
+ const required = expandComponentRequirements([componentData]).slice(1);
2094
+ for (const requirement of required) {
2095
+ if (
2096
+ srcArch.components.some(
2097
+ (candidate) => componentId(candidate) === componentId(requirement.component),
2098
+ )
2099
+ ) {
2100
+ continue;
2101
+ }
2102
+ // The closure is expanded once above. Bypass requirement expansion for
2103
+ // each member so malformed dependency cycles remain finite and the
2104
+ // structural work still happens in one deterministic sequence.
2105
+ const added = this.addComponentCore(entity, requirement as ComponentData, internal, false);
2106
+ if (!added.ok) return added;
2107
+ srcArch = this.graph.archetypes[rec.archetypeId];
2108
+ if (!srcArch) {
2109
+ return err(
2110
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
2111
+ operation: 'addComponent',
2112
+ component: componentData.component.name,
2113
+ expectedGeneration: rec.generation,
2114
+ actualGeneration: rec.generation,
2115
+ }),
2116
+ );
2117
+ }
2118
+ }
2119
+ }
2120
+
2121
+ // Check if entity already has this component (using World-local ID).
2122
+ const localId = componentId(componentData.component);
2123
+ if (srcArch.components.some((candidate) => componentId(candidate) === localId)) {
2124
+ // M2 exclusive relationship: re-adding the holder with a (possibly new)
2125
+ // target auto-reparents instead of failing (AC-12). Prune the old side
2126
+ // first (removeComponent prunes the old target), then fall through to
2127
+ // the normal add (which appends the new target). The two steps keep the
2128
+ // materialized target list consistent (AC-13);
2129
+ // removeComponent + addComponent each touch the mirror exactly once and
2130
+ // the mirror component carries no relationship of its own, so there is
2131
+ // no recursion. Reparent only fires for top-level user calls
2132
+ // (!internal); engine-internal lazy create / append
2133
+ // never re-add an existing relationship component.
2134
+ const role = relationshipRole(componentData.component as Component);
2135
+ if (role?.kind === 'source' && role.exclusive && !internal) {
2136
+ const prepared = this.prepareRelationshipInsert(
2137
+ componentData.component as Component,
2138
+ filled as Record<string, unknown>,
2139
+ );
2140
+ if (!prepared.ok) return prepared;
2141
+ const removeR = this.removeComponentCore(
2142
+ entity,
2143
+ componentData.component as Component,
2144
+ false,
2145
+ );
2146
+ if (!removeR.ok) return removeR;
2147
+ return this.addComponentCore(entity, componentData, false, true, prepared.value);
2148
+ }
2149
+ return err(new ComponentAlreadyPresentError(entity as number, componentData.component.name));
2150
+ }
2151
+
2152
+ // Get target archetype via edge cache.
2153
+ const targetArch = getAddEdge(
2154
+ this.graph,
2155
+ srcArch,
2156
+ localId,
2157
+ componentData.component as Component,
2158
+ );
2159
+
2160
+ if (componentData.component.storage === 'sparse') {
2161
+ this.moveEntityArchetype(rec, srcArch, targetArch);
2162
+ } else {
2163
+ this.migrateEntity(rec, srcArch, targetArch);
2164
+ }
2165
+
2166
+ // Write the new component's data. Apply layer-2 + layer-3 silent
2167
+ // fallback so addComponent shares the SAME default-resolution path
2168
+ // as spawn / SceneAsset.instantiate (feat-20260517 / M2 / AC-04
2169
+ // research §F4 auto-symmetry; ComponentData<S>['data'] is the
2170
+ // physical bridge).
2171
+ if (componentData.component.storage === 'table') {
2172
+ this.writeRow(targetArch, componentData.component, this.tableRow(rec), filled as ShapeOf<S>);
2173
+ }
2174
+ // Relationship sync: append to the materialized target list.
2175
+ if (!internal && relationshipRole(componentData.component as Component)?.kind === 'source') {
2176
+ const relationshipResult = this.relationshipOnInsert(
2177
+ entity,
2178
+ componentData.component as Component,
2179
+ filled as Record<string, unknown>,
2180
+ relationshipMirrorAdded,
2181
+ );
2182
+ if (!relationshipResult.ok) {
2183
+ this.poisonAfterEntityMutation('World.addComponent', relationshipResult.error);
2184
+ return relationshipResult;
2185
+ }
2186
+ }
2187
+
2188
+ if (!internal) {
2189
+ // Relationship insertion is part of the same publication boundary. A
2190
+ // target-side storage failure poisons the World; it must not leave a
2191
+ // components-added epoch/evidence record that claims the edge exists.
2192
+ this.internalmarkComponentsAdded(entity, [componentId(componentData.component)]);
2193
+ this.advanceStructureEpoch();
2194
+ this.recordStructuralEvidence({
2195
+ kind: 'component-added',
2196
+ entity,
2197
+ componentId: localId,
2198
+ });
2199
+ }
2200
+ return ok(undefined);
2201
+ }
2202
+
2203
+ /**
2204
+ * Remove a component from an existing entity, triggering archetype migration.
2205
+ *
2206
+ * @returns `Result<void, EcsError>` —
2207
+ * `ok(void)` on success;
2208
+ * `err(StaleEntityError)` (`.code = 'stale-entity'`) if entity is dead;
2209
+ * `err(ComponentNotPresentError)` (`.code = 'component-not-present'`)
2210
+ * if entity doesn't have the component (E-04).
2211
+ *
2212
+ * @example
2213
+ * ```ts
2214
+ * const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
2215
+ * const world = new World();
2216
+ * const e = world.spawn({ component: Position, data: { x: 0, y: 0 } }).unwrap();
2217
+ * const r = world.removeComponent(e, Position);
2218
+ * if (!r.ok) { return; } // r.error.code === 'stale-entity' on dead handle
2219
+ * r.unwrap();
2220
+ * ```
2221
+ */
2222
+ removeComponent<S extends ComponentSchema, C extends Component<string, S>>(
2223
+ entity: EntityHandle,
2224
+ component: C & WritableComponent<C>,
2225
+ ): Result<void, EcsError> {
2226
+ const poisoned = this.poisonedResult<void>();
2227
+ if (poisoned !== undefined) return poisoned;
2228
+ if (isRelationshipTarget(component))
2229
+ return this.relationshipTargetWriteError(component, 'removeComponent');
2230
+ return this.removeComponentCore(entity, component, false);
2231
+ }
2232
+
2233
+ /**
2234
+ * Core implementation of `removeComponent` with reentry guard.
2235
+ *
2236
+ * @param internal — `true` when called from relationship maintenance
2237
+ * (exclusive reparent).
2238
+ */
2239
+ private removeComponentCore<S extends ComponentSchema>(
2240
+ entity: EntityHandle,
2241
+ component: Component<string, S>,
2242
+ internal: boolean,
2243
+ ): Result<void, EcsError> {
2244
+ // Essential-component hard reject (feat-20260602 / plan-strategy D-3): the
2245
+ // id=0 `Entity` component is carried by every archetype unconditionally (it
2246
+ // is the row's own packed handle) and cannot be removed. Reject before any
2247
+ // liveness lookup so the rejection is structural, not entity-state-dependent.
2248
+ if (componentId(component) === componentId(EntityComponent)) {
2249
+ return err(new RemoveEssentialComponentError(component.name));
2250
+ }
2251
+
2252
+ const record = this.lookupAlive(entity, 'removeComponent', component.name);
2253
+ if (!record.ok) return record;
2254
+
2255
+ const rec = record.value;
2256
+ const srcArch = this.graph.archetypes[rec.archetypeId];
2257
+ /* istanbul ignore next -- defensive: alive record always has valid archetypeId */
2258
+ if (!srcArch) {
2259
+ return err(
2260
+ new StaleEntityError(entity as number, entityIndex(entity), entityGeneration(entity), {
2261
+ operation: 'removeComponent',
2262
+ component: component.name,
2263
+ expectedGeneration: entityGeneration(entity),
2264
+ actualGeneration: rec.generation,
2265
+ }),
2266
+ );
2267
+ }
2268
+
2269
+ // Check if entity has this component (using World-local ID).
2270
+ const localId = componentId(component);
2271
+ if (!srcArch.components.some((candidate) => componentId(candidate) === localId)) {
2272
+ return err(new ComponentNotPresentError(entity as number, component.name));
2273
+ }
2274
+
2275
+ // Capture the old relationship value before column removal so the
2276
+ // materialized target list can be pruned.
2277
+ const role = relationshipRole(component as Component);
2278
+ const needsOldValue = role?.kind === 'source' && !internal;
2279
+ if (needsOldValue) {
2280
+ const oldValue = this.readRow(srcArch, component as Component, this.tableRow(rec)) as Record<
2281
+ string,
2282
+ unknown
2283
+ >;
2284
+ // Relationship sync: prune the holder from the target's materialized list.
2285
+ if (role?.kind === 'source' && !internal) {
2286
+ const relation = this.relationshipOnRemove(entity, component as Component, oldValue);
2287
+ if (!relation.ok) {
2288
+ this.poisonAfterEntityMutation('World.removeComponent', relation.error);
2289
+ return relation;
2290
+ }
2291
+ }
2292
+ }
2293
+
2294
+ // M1 release loop (removeComponent path): release every `ref<T>` field
2295
+ // on the component being removed before migration drops the row.
2296
+ if (component.storage === 'table') {
2297
+ this.releaseManagedRefsOnRow(srcArch, component as Component, this.tableRow(rec));
2298
+ }
2299
+
2300
+ // Get target archetype via edge cache.
2301
+ const targetArch = getRemoveEdge(this.graph, srcArch, localId);
2302
+
2303
+ if (component.storage === 'sparse') {
2304
+ this.moveEntityArchetype(rec, srcArch, targetArch);
2305
+ const set = this.graph.sparseTags.get(componentId(component));
2306
+ if (set !== undefined) removeSparseTag(set, entity);
2307
+ } else {
2308
+ this.migrateEntity(rec, srcArch, targetArch);
2309
+ }
2310
+ if (!internal) {
2311
+ this.advanceStructureEpoch();
2312
+ this.recordStructuralEvidence({
2313
+ kind: 'component-removed',
2314
+ entity,
2315
+ componentId: localId,
2316
+ });
2317
+ }
2318
+ return ok(undefined);
2319
+ }
2320
+
2321
+ // ──────────────────────────────────────────────────────────────────────────
2322
+ // Internal — deferred command support (CommandBuffer interface)
2323
+ // ──────────────────────────────────────────────────────────────────────────
2324
+
2325
+ /**
2326
+ * Allocate a pending entity for deferred spawn.
2327
+ * Returns an Entity handle. The entity is "pending" because
2328
+ * archetypeId === -1 (set by allocateIndex); no separate flag needed.
2329
+ */
2330
+ private allocatePendingEntity(): EntityHandle {
2331
+ const indexSlot = this.allocateIndex();
2332
+ // biome-ignore lint/style/noNonNullAssertion: allocateIndex guarantees a valid slot with an initialized record
2333
+ return encodeEntity(indexSlot, this.records[indexSlot]!.generation);
2334
+ }
2335
+
2336
+ /**
2337
+ * Return a deferred-spawn reservation to the free-list without publishing a
2338
+ * row or advancing an epoch. CommandBuffer.abort is the sole caller; a
2339
+ * materialized entity is intentionally left untouched so an unexpected
2340
+ * post-write failure poisons the World instead of attempting an unsafe undo.
2341
+ */
2342
+ private cancelPendingEntity(entity: EntityHandle): void {
2343
+ const slot = entityIndex(entity);
2344
+ const record = this.records[slot];
2345
+ if (record === undefined || record.generation !== entityGeneration(entity)) return;
2346
+ if (record.archetypeId !== -1 || record.archetypeRow !== -1) return;
2347
+ record.generation += 1;
2348
+ if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
2349
+ }
2350
+
2351
+ /**
2352
+ * Materialize one already-reserved entity. Synchronous spawn and deferred
2353
+ * command flush share this exact insertion/publication path; only the
2354
+ * caller's validation and reservation boundary differs.
2355
+ *
2356
+ */
2357
+ private materializeEntity(
2358
+ entity: EntityHandle,
2359
+ componentDatas: ComponentData[],
2360
+ ): Result<void, EcsError> {
2361
+ const poisoned = this.poisonedResult<void>();
2362
+ if (poisoned !== undefined) return poisoned;
2363
+ const slot = entityIndex(entity);
2364
+ const record = this.records[slot];
2365
+ if (!record || record.archetypeId !== -1) return ok(undefined);
2366
+
2367
+ let storageTouched = false;
2368
+ try {
2369
+ // Find or create target archetype (using World-local IDs).
2370
+ const componentIds = componentDatas.map((cd) => componentId(cd.component));
2371
+ const components = componentDatas.map((cd) => cd.component);
2372
+ const arch = getOrCreateArchetype(this.graph, componentIds, components);
2373
+
2374
+ // Mark the reservation as mutation-owned before either append can write.
2375
+ // If appendTableRow succeeds and appendArchetypeRow fails, command abort
2376
+ // must not reclaim an entity whose table storage may already be touched.
2377
+ // A poisoned World is the recovery boundary for that partial state.
2378
+ record.archetypeId = arch.id;
2379
+ storageTouched = true;
2380
+ const table = this.table(arch);
2381
+ const tableRow = appendTableRow(table, entity);
2382
+ const archetypeRow = appendArchetypeRow(arch, tableRow);
2383
+ record.archetypeRow = archetypeRow;
2384
+
2385
+ // Write initial data. Apply the same default-resolution path as the
2386
+ // synchronous World.spawn / addComponent operations.
2387
+ for (const cd of componentDatas) {
2388
+ const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
2389
+ this.writeRow(arch, cd.component, tableRow, filled as ShapeOf<ComponentSchema>);
2390
+ }
2391
+
2392
+ // The reserved handle is the source of truth for the essential Entity
2393
+ // self column. Relationship maintenance is part of the write boundary;
2394
+ // component/epoch publication waits until it has completed.
2395
+ this.writeEntitySelf(arch, tableRow, entity);
2396
+
2397
+ // Publish relationship targets after all rows and managed writes are
2398
+ // ready. A non-stale error here is a possible partial write. No
2399
+ // components-added epoch or structural evidence has been published yet.
2400
+ for (const cd of componentDatas) {
2401
+ if (relationshipRole(cd.component as Component)?.kind !== 'source') continue;
2402
+ const filled = fillComponentDefaults(cd.component, cd.data as Record<string, unknown>);
2403
+ const relationshipResult = this.relationshipOnInsert(
2404
+ entity,
2405
+ cd.component as Component,
2406
+ filled as Record<string, unknown>,
2407
+ );
2408
+ if (!relationshipResult.ok) {
2409
+ this.poisonAfterEntityMutation('World.materializeEntity', relationshipResult.error);
2410
+ return relationshipResult;
2411
+ }
2412
+ }
2413
+ this.internalmarkComponentsAdded(entity, [
2414
+ componentId(EntityComponent),
2415
+ ...componentDatas.map((cd) => componentId(cd.component)),
2416
+ ]);
2417
+ this.advanceStructureEpoch();
2418
+ this.recordStructuralEvidence({ kind: 'spawn', entity });
2419
+ return ok(undefined);
2420
+ } catch (error) {
2421
+ if (storageTouched) this.poisonAfterEntityMutation('World.materializeEntity', error);
2422
+ throw error;
2423
+ }
2424
+ }
2425
+
2426
+ private poisonAfterEntityMutation(kernelName: string, cause: unknown): void {
2427
+ this.internalpoisonExecution({
2428
+ code: 'shared-kernel-failed',
2429
+ kernelName,
2430
+ cause,
2431
+ partialWrite: true,
2432
+ retryable: false,
2433
+ });
2434
+ }
2435
+
2436
+ /** Deferred commands use the common materialization owner. */
2437
+ private materializePendingEntity(
2438
+ entity: EntityHandle,
2439
+ componentDatas: ComponentData[],
2440
+ ): Result<void, EcsError> {
2441
+ return this.materializeEntity(entity, expandComponentRequirements(componentDatas));
2442
+ }
2443
+
2444
+ // ──────────────────────────────────────────────────────────────────────────
2445
+ // Internal — entity index allocation
2446
+ // ──────────────────────────────────────────────────────────────────────────
2447
+
2448
+ private allocateIndex(): number {
2449
+ if (this.executionState.health === 'poisoned') {
2450
+ throw new WorldPoisonedError(this.identity, this.executionState.fault);
2451
+ }
2452
+ const recycled = this.freeIndices.pop();
2453
+ if (recycled !== undefined) {
2454
+ return recycled;
2455
+ }
2456
+ const slot = this.records.length;
2457
+ if (slot > ENTITY_MAX_INDEX) {
2458
+ throw new EntityIndexOverflowError(slot);
2459
+ }
2460
+ this.records.push({ generation: 0, archetypeId: -1, archetypeRow: -1 });
2461
+ return slot;
2462
+ }
2463
+
2464
+ /**
2465
+ * Single liveness predicate (feat-20260602 / plan-strategy D-4): a slot is
2466
+ * live for a given handle generation iff the record exists, its generation
2467
+ * still matches the handle (despawn bumps generation, so a stale or recycled
2468
+ * handle fails here), and the slot is materialized into an archetype
2469
+ * (archetypeId !== -1). Replaces the former `record.alive && record.generation
2470
+ * === gen` conjunction and the intermediate `!record.pending` clause. An
2471
+ * append in progress keeps `archetypeRow === -1` until both storage indexes
2472
+ * exist.
2473
+ */
2474
+ private recordIsLive(record: EntityRecord | undefined, gen: number): record is EntityRecord {
2475
+ return (
2476
+ record !== undefined &&
2477
+ record.generation === gen &&
2478
+ record.archetypeId !== -1 &&
2479
+ record.archetypeRow !== -1
2480
+ );
2481
+ }
2482
+
2483
+ private lookupAlive(
2484
+ entity: EntityHandle,
2485
+ operation: string,
2486
+ component?: string,
2487
+ ): Result<EntityRecord, EcsError> {
2488
+ const slot = entityIndex(entity);
2489
+ const gen = entityGeneration(entity);
2490
+ const record = this.records[slot];
2491
+ if (!this.recordIsLive(record, gen)) {
2492
+ return err(
2493
+ new StaleEntityError(entity as number, slot, gen, {
2494
+ operation,
2495
+ ...(component !== undefined ? { component } : {}),
2496
+ expectedGeneration: gen,
2497
+ actualGeneration: this.records[slot]?.generation ?? -1,
2498
+ }),
2499
+ );
2500
+ }
2501
+ return ok(record);
2502
+ }
2503
+
2504
+ private readArrayView(
2505
+ arch: Archetype,
2506
+ component: Component,
2507
+ row: number,
2508
+ fieldName: string,
2509
+ ): FieldView | undefined {
2510
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2511
+ if (!fieldCols) return undefined;
2512
+
2513
+ const fieldType = componentSchema(component)[fieldName];
2514
+ if (fieldType === undefined) return undefined;
2515
+ // Component reflection already parses and freezes array metadata at
2516
+ // registration time. Reusing it here keeps the per-entity zero-copy path
2517
+ // parse-free; this accessor is called once for every renderable every
2518
+ // frame. The field lookup also preserves the existing undefined result
2519
+ // for non-array fields without reparsing arbitrary schema strings.
2520
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2521
+ if (arrayMeta === undefined) return undefined;
2522
+
2523
+ const col = fieldCols.get(fieldName);
2524
+ if (!col) return undefined;
2525
+
2526
+ const elementCount =
2527
+ arrayMeta.length ??
2528
+ (fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined) ??
2529
+ 0;
2530
+ return this.materializeArrayView(col, row, arrayMeta, elementCount);
2531
+ }
2532
+
2533
+ /** Read one scalar column without constructing a component snapshot. */
2534
+ private internalgetFieldValue(
2535
+ entity: EntityHandle,
2536
+ component: Component,
2537
+ fieldName: string,
2538
+ ): number | undefined {
2539
+ const record = this.lookupAlive(entity, 'world-read', component.name);
2540
+ if (!record.ok) return undefined;
2541
+ const arch = this.graph.archetypes[record.value.archetypeId];
2542
+ if (arch === undefined) return undefined;
2543
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2544
+ return fieldCols?.get(fieldName)?.view[this.tableRow(record.value)] as number | undefined;
2545
+ }
2546
+
2547
+ /** Read an array's live logical length without allocating a TypedArray. */
2548
+ private internalgetArrayLength(
2549
+ entity: EntityHandle,
2550
+ component: Component,
2551
+ fieldName: string,
2552
+ ): number | undefined {
2553
+ const record = this.lookupAlive(entity, 'world-read', component.name);
2554
+ if (!record.ok) return undefined;
2555
+ const arch = this.graph.archetypes[record.value.archetypeId];
2556
+ if (arch === undefined) return undefined;
2557
+ const row = this.tableRow(record.value);
2558
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2559
+ if (fieldCols === undefined) return undefined;
2560
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2561
+ if (arrayMeta === undefined) return undefined;
2562
+ if (arrayMeta.length !== undefined) return arrayMeta.length;
2563
+ const count = fieldCols.get(arrayCountColumnName(fieldName))?.view[row];
2564
+ return typeof count === 'number' ? count : 0;
2565
+ }
2566
+
2567
+ /** Read one array element directly from its column or BufferPool slot. */
2568
+ private internalgetArrayElement(
2569
+ entity: EntityHandle,
2570
+ component: Component,
2571
+ fieldName: string,
2572
+ index: number,
2573
+ ): number | undefined {
2574
+ if (!Number.isSafeInteger(index) || index < 0) return undefined;
2575
+ const record = this.lookupAlive(entity, 'world-read', component.name);
2576
+ if (!record.ok) return undefined;
2577
+ const arch = this.graph.archetypes[record.value.archetypeId];
2578
+ if (arch === undefined) return undefined;
2579
+ const row = this.tableRow(record.value);
2580
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2581
+ if (fieldCols === undefined) return undefined;
2582
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2583
+ if (arrayMeta === undefined) return undefined;
2584
+ const length =
2585
+ arrayMeta.length ??
2586
+ (fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined);
2587
+ if (length === undefined || index >= length) return undefined;
2588
+ const col = fieldCols.get(fieldName);
2589
+ if (col === undefined) return undefined;
2590
+ if (arrayMeta.length !== undefined) return col.view[row * col.arity + index] as number;
2591
+
2592
+ const slotId = col.view[row] as number;
2593
+ const bytes = this.bufferPool.view(slotId);
2594
+ if (arrayMeta.elementType === 'entity') {
2595
+ const byteOffset = index * 4;
2596
+ if (byteOffset + 4 > bytes.byteLength) return undefined;
2597
+ return (
2598
+ ((bytes[byteOffset] ?? 0) |
2599
+ ((bytes[byteOffset + 1] ?? 0) << 8) |
2600
+ ((bytes[byteOffset + 2] ?? 0) << 16) |
2601
+ ((bytes[byteOffset + 3] ?? 0) << 24)) >>>
2602
+ 0
2603
+ );
2604
+ }
2605
+ return readArrayElementAt(bytes, index, arrayMeta.elementType);
2606
+ }
2607
+
2608
+ // ──────────────────────────────────────────────────────────────────────────
2609
+ // Internal — archetype data read/write
2610
+ // ──────────────────────────────────────────────────────────────────────────
2611
+
2612
+ private readRow<S extends ComponentSchema>(
2613
+ arch: Archetype,
2614
+ component: Component<string, S>,
2615
+ row: number,
2616
+ ): ShapeOf<S> {
2617
+ const localId = componentId(component);
2618
+ const fieldCols = this.table(arch).storage.get(localId)?.fields;
2619
+ const out = {} as ShapeOf<S>;
2620
+ /* istanbul ignore next -- defensive: component is registered and arch has it */
2621
+ if (!fieldCols) {
2622
+ return out;
2623
+ }
2624
+ for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
2625
+ const col = fieldCols.get(fieldName);
2626
+ if (!col) {
2627
+ continue;
2628
+ }
2629
+ const raw = col.view[row];
2630
+ if (fieldType === 'bool') {
2631
+ (out as Record<string, unknown>)[fieldName] = raw === 1;
2632
+ } else if (isEntityField(fieldType)) {
2633
+ // Entity field: decode the stored raw u32 verbatim back to Entity
2634
+ // (or null when the slot carries the ENTITY_NULL_RAW sentinel). No
2635
+ // liveness validation happens here -- a slot referencing a despawned
2636
+ // target returns its original raw encoding unchanged; the consumer is
2637
+ // responsible for checking liveness (e.g. `world.get(ref, Entity)`).
2638
+ (out as Record<string, unknown>)[fieldName] = raw === ENTITY_NULL_RAW ? null : raw;
2639
+ } else if (isManagedBufferField(fieldType)) {
2640
+ if (fieldType !== 'buffer') {
2641
+ // feat-20260602: fixed `buffer<N>` lives inline (stride-N u8 column,
2642
+ // arity = N). Return the row's byte window directly -- no pool slot.
2643
+ const arity = col.arity;
2644
+ (out as Record<string, unknown>)[fieldName] = (col.view as Uint8Array).subarray(
2645
+ row * arity,
2646
+ row * arity + arity,
2647
+ );
2648
+ } else {
2649
+ // Variable `'buffer'`: column stores slot id; the live view is
2650
+ // resolved on demand so post-grow callers always see the refreshed
2651
+ // Uint8Array.
2652
+ (out as Record<string, unknown>)[fieldName] = this.bufferPool.view(raw as number);
2653
+ }
2654
+ } else if (fieldType === 'string') {
2655
+ // M1 string-field read path (AC-03 / AC-09): resolve the column
2656
+ // u32 handle through UniqueRefStore -- same dispatch arm as the
2657
+ // 'unique<T>' read (D-R3). Returns the native JS string payload by
2658
+ // strong reference; identity is stable across reads until the
2659
+ // next set or release (AC-03 read-side identity contract).
2660
+ // Released / sentinel handles surface as unique-ref-released via
2661
+ // resolve; we fall back to '' rather than propagate the error so
2662
+ // the read shape (`out.value: string`) stays total -- AI users
2663
+ // never see undefined or wrapper objects.
2664
+ const resolveR = this.uniqueRefs.resolve<'String'>(toUnique<'String'>(raw as number));
2665
+ (out as Record<string, unknown>)[fieldName] = resolveR.ok ? resolveR.value : '';
2666
+ } else {
2667
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2668
+ if (arrayMeta !== undefined) {
2669
+ // M1 read path: materialise a fresh TypedArray snapshot each call
2670
+ // (D-4 no cache; plan-strategy §2.2 read-only contract). The
2671
+ // snapshot aliases the BufferPool slot bytes; mutations route
2672
+ // through the World mutation API.
2673
+ const elementCount =
2674
+ arrayMeta.length ??
2675
+ (fieldCols.get(arrayCountColumnName(fieldName))?.view[row] as number | undefined) ??
2676
+ 0;
2677
+ const materialized = this.materializeArrayView(col, row, arrayMeta, elementCount);
2678
+ // Relationship target arrays are a read-only projection. Keep the
2679
+ // public component snapshot detached so mutating the returned view
2680
+ // cannot bypass the World-owned mirror/index path.
2681
+ (out as Record<string, unknown>)[fieldName] = isRelationshipTarget(component)
2682
+ ? materialized.slice()
2683
+ : materialized;
2684
+ } else {
2685
+ (out as Record<string, unknown>)[fieldName] = raw;
2686
+ }
2687
+ }
2688
+ }
2689
+ return out;
2690
+ }
2691
+
2692
+ /**
2693
+ * Write the full packed entity handle into the row's essential id=0 `Entity`
2694
+ * column (`self` field). Called by `spawn` / `materializePendingEntity`
2695
+ * after the row is appended (feat-20260602 / plan-strategy D-3). The column
2696
+ * always exists -- `createArchetype` folds the Entity column into every
2697
+ * archetype -- so this is a direct u32 store, no readRow/writeRow walk.
2698
+ */
2699
+ private writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
2700
+ const col = this.table(arch).storage.get(componentId(EntityComponent))?.fields.get('self');
2701
+ /* istanbul ignore next -- defensive: Entity column is folded into every archetype */
2702
+ if (!col) return;
2703
+ col.view[row] = handle as unknown as number;
2704
+ }
2705
+
2706
+ private writeRow<S extends ComponentSchema>(
2707
+ arch: Archetype,
2708
+ component: Component<string, S>,
2709
+ row: number,
2710
+ value: ShapeOf<S>,
2711
+ ): void {
2712
+ const localId = componentId(component);
2713
+ const fieldCols = this.table(arch).storage.get(localId)?.fields;
2714
+ /* istanbul ignore next -- defensive: component is registered and arch has it */
2715
+ if (!fieldCols) {
2716
+ return;
2717
+ }
2718
+ for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
2719
+ const col = fieldCols.get(fieldName);
2720
+ /* istanbul ignore next -- defensive: schema fields always have columns */
2721
+ if (!col) {
2722
+ continue;
2723
+ }
2724
+ const raw = (value as Record<string, unknown>)[fieldName];
2725
+ if (fieldType === 'bool') {
2726
+ col.view[row] = raw ? 1 : 0;
2727
+ } else if (isEntityField(fieldType)) {
2728
+ // M3 entity field: encode (slot, gen) into u32 column. `null`
2729
+ // / undefined map to ENTITY_NULL_RAW sentinel.
2730
+ col.view[row] = raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
2731
+ } else if (isManagedBufferField(fieldType)) {
2732
+ // M2 spawn path: collapsed-vocab keyword family `'buffer'` (variable)
2733
+ // + `'buffer<N>'` (fixed):
2734
+ // - `buffer<N>` (feat-20260602) — lives inline as a stride-N u8
2735
+ // column (arity = N). Copy any provided payload straight into the
2736
+ // row window (truncate to N); no BufferPool slot.
2737
+ // - `'buffer'` — variable capacity; alloc one BufferPool slot sized
2738
+ // to the provided payload's byteLength. Missing / non-buffer raw
2739
+ // -> alloc(0) zero-length live view (verify round 1 B2 fix path;
2740
+ // pre-fix the bare keyword routed `bufferFieldByteLength('buffer')`
2741
+ // -> NaN -> alloc(NaN) -> managed-buffer-out-of-bounds, dropping
2742
+ // the payload bytes silently). Failures route to Layer 3
2743
+ // error channel; column slot stays at 0 (sentinel) so subsequent
2744
+ // release short-circuits.
2745
+ // raw is normalized from any AllowSharedBufferSource view to a
2746
+ // Uint8Array over its bytes (feat-20260621 V2 / AC-A4).
2747
+ const bytes = normalizeBufferWrite(raw);
2748
+ if (fieldType !== 'buffer') {
2749
+ const arity = col.arity;
2750
+ if (bytes !== null) {
2751
+ const copyLen = Math.min(bytes.byteLength, arity);
2752
+ (col.view as Uint8Array).set(bytes.subarray(0, copyLen), row * arity);
2753
+ }
2754
+ } else {
2755
+ const allocBytes = bytes !== null ? bytes.byteLength : 0;
2756
+ const allocR = this.bufferPool.alloc(allocBytes);
2757
+ if (!allocR.ok) {
2758
+ const ctx: ErrorContext = {
2759
+ systemName: `World.spawn (${component.name}.${fieldName})`,
2760
+ };
2761
+ this.routeError(allocR.error, ctx);
2762
+ col.view[row] = 0;
2763
+ continue;
2764
+ }
2765
+ const slot = allocR.value;
2766
+ if (bytes !== null) {
2767
+ // allocBytes is the payload's exact byteLength so no truncation.
2768
+ const copyLen = Math.min(bytes.byteLength, slot.view.byteLength);
2769
+ slot.view.set(bytes.subarray(0, copyLen));
2770
+ }
2771
+ col.view[row] = slot.id;
2772
+ }
2773
+ } else if (fieldType === 'string') {
2774
+ // M1 string-field spawn path (AC-04 / AC-06): route the JS string
2775
+ // payload through `uniqueRefs.alloc('String', text)` -- the same
2776
+ // UniqueRefStore the `ref<T>` arm uses (D-R3 single-arm dispatch).
2777
+ // The store holds the immutable string by strong reference so
2778
+ // identity is stable across reads (AC-03). Missing / non-string raw
2779
+ // falls back to '' so AI users always see a readable string on
2780
+ // get (no nullable handling).
2781
+ const text = typeof raw === 'string' ? raw : '';
2782
+ const handle = this.uniqueRefs.alloc<'String'>('String', text);
2783
+ col.view[row] = unwrapHandle(handle);
2784
+ } else {
2785
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2786
+ if (arrayMeta !== undefined) {
2787
+ // M1 spawn path for array<T> / array<T,N> fields (D-3 double-
2788
+ // column for variable; single column for fixed).
2789
+ // Spawn path: no prior-slot release (fresh rows carry stale debris
2790
+ // owned by the migrated entity in the new archetype) — feat-20260614
2791
+ // D-3 calling convention.
2792
+ this.writeArrayField(arch, component, row, fieldName, fieldType, arrayMeta, raw);
2793
+ } else {
2794
+ col.view[row] = raw as number;
2795
+ // feat-20260614 M5 / D-5: scalar 'shared<T>' spawn retain. The
2796
+ // alloc-grant rc=1 stays held by the producer (e.g. AssetRegistry);
2797
+ // each ECS holder bumps rc via this retain so despawn / overwrite
2798
+ // releases bring rc back symmetrically. Sentinel slot 0 is a no-op.
2799
+ if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
2800
+ this.retainSharedScalarHandle(raw as number, component.name, fieldName);
2801
+ }
2802
+ }
2803
+ }
2804
+ }
2805
+ }
2806
+
2807
+ // ──────────────────────────────────────────────────────────────────────────
2808
+ // Internal — managed-ref + managed-buffer release loop (M1 / M2)
2809
+ // ──────────────────────────────────────────────────────────────────────────
2810
+
2811
+ /** Release all ECS-owned field handles before a row is removed or overwritten. */
2812
+ private releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
2813
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2814
+ if (!fieldCols) return;
2815
+ for (const fieldName of Object.keys(componentSchema(component))) {
2816
+ this.releaseManagedFieldOnRow(arch, component, row, fieldName);
2817
+ }
2818
+ }
2819
+
2820
+ /**
2821
+ * Release one ECS-owned field according to its schema. Inline buffers have
2822
+ * no pool slot; shared-array elements still release their handles. Store
2823
+ * failures use the host error channel so row cleanup remains total.
2824
+ */
2825
+ private releaseManagedFieldOnRow(
2826
+ arch: Archetype,
2827
+ component: Component,
2828
+ row: number,
2829
+ fieldName: string,
2830
+ ): void {
2831
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
2832
+ if (!fieldCols) return;
2833
+ const col = fieldCols.get(fieldName);
2834
+ if (!col) return;
2835
+ const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
2836
+ if (isManagedField(fieldType)) {
2837
+ // Sub-dispatch by the schema-vocab keyword (feat-20260614 M4 / AC-08):
2838
+ // - 'shared<T>' scalar -> SharedRefStore.release (rc--; drop on rc=0)
2839
+ // - 'unique<T>' / 'string' -> UniqueRefStore.release (direct slot drop)
2840
+ // Both column shapes are u32 handles; the lookup store differs.
2841
+ // Keeping both arms inside the unified `isManagedField` block is
2842
+ // intentional: meta key (TYPE_METADATA `'shared'` vs `'ref'`) decides
2843
+ // the store, not a separate top-level branch (architecture-principles
2844
+ // §1 SSOT — meta key = release semantics).
2845
+ const handleU32 = col.view[row] as number;
2846
+ if (fieldType.startsWith('shared<')) {
2847
+ this.releaseSharedRefHandle(handleU32, component.name, fieldName);
2848
+ return;
2849
+ }
2850
+ this.releaseManagedRefHandle(handleU32, component.name, fieldName);
2851
+ return;
2852
+ }
2853
+ if (isManagedBufferField(fieldType)) {
2854
+ if (fieldType === 'buffer') {
2855
+ const slotId = col.view[row] as number;
2856
+ this.releaseManagedBufferSlot(slotId);
2857
+ col.view[row] = 0;
2858
+ }
2859
+ return;
2860
+ }
2861
+ if (isManagedArrayField(fieldType)) {
2862
+ // Use the pre-parsed arrayMeta cached on the component descriptor at
2863
+ // registration (AC-03c parse-free hot path); reaching for
2864
+ // `parseManagedArraySchema` here would violate the parse-free
2865
+ // invariant exercised by hierarchy.unit.test.ts §w5 AC-03(a).
2866
+ const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
2867
+ if (arrayMeta === undefined) return;
2868
+ const isSharedElement = arrayMeta.elementType.startsWith('shared<');
2869
+ if (arrayMeta.length === undefined) {
2870
+ // Variable `array<T>`: BufferPool slot id in primary column + live
2871
+ // count in `<fieldName>:count` sidecar. For `array<shared<T>>`,
2872
+ // walk live elements and release each shared handle BEFORE
2873
+ // releasing the slot bytes (feat-20260614 M4 / D-3 — slot bytes
2874
+ // are only valid until the slot is recycled).
2875
+ const slotId = col.view[row] as number;
2876
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
2877
+ if (isSharedElement && slotId !== 0) {
2878
+ const liveCount = countCol !== undefined ? (countCol.view[row] as number) : 0;
2879
+ const slotView = liveCount > 0 ? this.bufferPool.view(slotId) : null;
2880
+ if (slotView !== null && slotView.byteLength > 0) {
2881
+ this.releaseSharedArrayElements(slotView, liveCount);
2882
+ }
2883
+ }
2884
+ this.releaseManagedBufferSlot(slotId);
2885
+ col.view[row] = 0;
2886
+ if (countCol !== undefined) countCol.view[row] = 0;
2887
+ return;
2888
+ }
2889
+ // Fixed `array<T,N>` (feat-20260602): inline stride-N column, no
2890
+ // BufferPool slot to release. For `array<shared<T>,N>`, walk the N
2891
+ // inline elements and release each shared handle. Zero the row
2892
+ // window so subsequent writes do not double-release.
2893
+ if (isSharedElement) {
2894
+ const arity = col.arity;
2895
+ const elementBytes = (TYPE_METADATA.shared?.byteSize ?? 4) as number;
2896
+ const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
2897
+ const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
2898
+ this.releaseSharedArrayElements(rowBytes, arity);
2899
+ rowBytes.fill(0);
2900
+ }
2901
+ }
2902
+ }
2903
+
2904
+ /** Release one unique-ref handle; sentinel 0 is ignored. */
2905
+ private releaseManagedRefHandle(
2906
+ handleU32: number,
2907
+ componentName: string,
2908
+ fieldName: string,
2909
+ ): void {
2910
+ if (handleU32 === 0) return; // sentinel: skip silently.
2911
+ const r = this.uniqueRefs.release(handleU32 as Handle<string, 'unique'>);
2912
+ if (r.ok) return;
2913
+ // Layer 3 routing: surface double-release as a structured error so AI
2914
+ // users see {code, hint, expected, detail} on their handler. Severity
2915
+ // defaults to Error so the chain continues; matchSeverity prints to
2916
+ // console.error rather than throw.
2917
+ const ctx: ErrorContext = {
2918
+ systemName: `World.release (${componentName}.${fieldName})`,
2919
+ };
2920
+ this.routeError(r.error, ctx);
2921
+ }
2922
+
2923
+ /** Release one shared-ref handle, preserving builtin slots and refcounts. */
2924
+ private releaseSharedRefHandle(
2925
+ handleU32: number,
2926
+ componentName: string,
2927
+ fieldName: string,
2928
+ ): void {
2929
+ // feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
2930
+ // sentinel 0) are process-static and never reference-counted -> short-circuit
2931
+ // before touching SharedRefStore. This single guard is the SSOT for both the
2932
+ // scalar arm (here) and the array-element arm (releaseSharedArrayElements).
2933
+ if (handleU32 < BUILTIN_BASE) return;
2934
+ const r = this.sharedRefs.release(toShared<string>(handleU32));
2935
+ if (r.ok) return;
2936
+ const ctx: ErrorContext = {
2937
+ systemName: `World.release (${componentName}.${fieldName})`,
2938
+ };
2939
+ this.routeError(r.error, ctx);
2940
+ }
2941
+
2942
+ /** Retain one shared-ref scalar handle for a World-owned field. */
2943
+ private retainSharedScalarHandle(
2944
+ handleU32: number,
2945
+ componentName: string,
2946
+ fieldName: string,
2947
+ ): void {
2948
+ // feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
2949
+ // sentinel 0) short-circuit — process-static, never reference-counted. SSOT
2950
+ // guard shared with the array-element arm (retainSharedArrayElements).
2951
+ if (handleU32 < BUILTIN_BASE) return;
2952
+ const r = this.sharedRefs.retain(toShared<string>(handleU32));
2953
+ if (r.ok) return;
2954
+ const ctx: ErrorContext = {
2955
+ systemName: `World.write (${componentName}.${fieldName} shared scalar retain)`,
2956
+ };
2957
+ this.routeError(r.error, ctx);
2958
+ }
2959
+
2960
+ /** Release one variable-buffer slot; id 0 is the unallocated sentinel. */
2961
+ private releaseManagedBufferSlot(slotId: number): void {
2962
+ if (slotId === 0) return; // sentinel: skip silently.
2963
+ this.bufferPool.release(slotId);
2964
+ }
2965
+
2966
+ // ──────────────────────────────────────────────────────────────────────────
2967
+ // Internal — array<T> / array<T,N> spawn / set helpers (M1 / w7)
2968
+ // ──────────────────────────────────────────────────────────────────────────
2969
+
2970
+ /**
2971
+ * Attach field context to a `ManagedArrayErrorEnvelope`.
2972
+ * The envelope shape (`code / hint / expected / detail`) already mirrors
2973
+ * the EcsError contract; this helper only attaches the systemName context
2974
+ * so AI users can correlate the error with the holder component / field.
2975
+ */
2976
+ private routeArrayError(
2977
+ err: ManagedArrayErrorEnvelope,
2978
+ componentName: string,
2979
+ fieldName: string,
2980
+ ): void {
2981
+ const ctx: ErrorContext = {
2982
+ systemName: `World.write (${componentName}.${fieldName})`,
2983
+ };
2984
+ this.routeError(err, ctx);
2985
+ }
2986
+
2987
+ /**
2988
+ * Write an array field for spawn or set. Fixed arrays stay inline; variable
2989
+ * arrays use one BufferPool slot plus a live-count sidecar.
2990
+ */
2991
+ private writeArrayField(
2992
+ arch: Archetype,
2993
+ component: Component,
2994
+ row: number,
2995
+ fieldName: string,
2996
+ _fieldType: string,
2997
+ arrayMeta: ArrayMeta,
2998
+ raw: unknown,
2999
+ ): void {
3000
+ const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
3001
+ /* istanbul ignore next -- writeArrayField caller validated the column map */
3002
+ if (!fieldCols) return;
3003
+ const col = fieldCols.get(fieldName);
3004
+ /* istanbul ignore next -- writeArrayField caller validated the column */
3005
+ if (!col) return;
3006
+
3007
+ const elementType = arrayMeta.elementType;
3008
+ // Normalize parametrised element-type template literals to the family
3009
+ // key for TYPE_METADATA lookup; the column stores plain u32 handles
3010
+ // either way:
3011
+ // - `shared<X>` -> 'shared' (feat-20260614 M4 / D-3 -- element-level
3012
+ // retain/release semantics route via the dedicated `'shared'` arm
3013
+ // below)
3014
+ const metaKey = elementType.startsWith('shared<') ? 'shared' : (elementType as string);
3015
+ const meta = TYPE_METADATA[metaKey];
3016
+ /* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
3017
+ if (!meta) return;
3018
+ // biome-ignore lint/style/noNonNullAssertion: every array element type has a byte size
3019
+ const elementBytes = meta.byteSize!;
3020
+
3021
+ const isVariable = arrayMeta.length === undefined;
3022
+ const fixedLength = arrayMeta.length ?? 0;
3023
+
3024
+ // Determine the payload's logical element count. Accept any TypedArray
3025
+ // (Float32Array / Uint32Array / etc.) plus plain numeric arrays; an
3026
+ // undefined / missing payload is treated as a length-0 init. Bytes are
3027
+ // copied from the source's underlying ArrayBuffer when present.
3028
+ let payloadCount = 0;
3029
+ let payloadBytes: Uint8Array | null = null;
3030
+ if (raw !== null && raw !== undefined) {
3031
+ if (
3032
+ raw instanceof Float32Array ||
3033
+ raw instanceof Float64Array ||
3034
+ raw instanceof Int32Array ||
3035
+ raw instanceof Uint32Array ||
3036
+ raw instanceof Int16Array ||
3037
+ raw instanceof Uint16Array ||
3038
+ raw instanceof Int8Array ||
3039
+ raw instanceof Uint8Array
3040
+ ) {
3041
+ payloadCount = raw.length;
3042
+ payloadBytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
3043
+ } else if (Array.isArray(raw)) {
3044
+ payloadCount = raw.length;
3045
+ // Plain JS array: pack each element through the declared element
3046
+ // type's TypedArray constructor (`meta.viewCtor`) so the numeric
3047
+ // VALUE is encoded, not its integer bit pattern. Dispatching on
3048
+ // `viewCtor` (the type SSOT) rather than byte size is what keeps
3049
+ // `array<f32,N>` distinct from `array<u32,N>` -- both are 4 bytes,
3050
+ // so a size-keyed setter would store an f32 `1.0` as the u32 bits
3051
+ // `0x00000001` (reads back ~1.4e-45). The TypedArray then exposes
3052
+ // its little-endian bytes for the shared copy path below.
3053
+ if (payloadCount > 0 && meta.viewCtor !== undefined) {
3054
+ const typed = new meta.viewCtor(payloadCount);
3055
+ for (let i = 0; i < payloadCount; i++) {
3056
+ const val = raw[i];
3057
+ typed[i] = typeof val === 'number' ? val : 0;
3058
+ }
3059
+ payloadBytes = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
3060
+ }
3061
+ }
3062
+ }
3063
+
3064
+ // Effective count for variable arrays = payload count; for fixed
3065
+ // arrays = schema-declared N (the payload's length is advisory — we
3066
+ // copy up to N elements and pad the rest with zero).
3067
+ const effectiveCount = isVariable ? payloadCount : fixedLength;
3068
+
3069
+ // Fixed `array<T,N>` (feat-20260602): the column is an inline stride-N
3070
+ // view (`col.arity === N`), so write the payload bytes directly into the
3071
+ // row's stride window — no BufferPool slot, no slot-id store, no
3072
+ // prior-slot release. The byte window starts at `row * arity * elementBytes`
3073
+ // and spans N elements; payloads shorter than N copy a prefix and leave
3074
+ // the tail at its current value (spawn rows are zero-initialised by the
3075
+ // fresh column buffer; the swap-pop migration copies the whole block).
3076
+ if (!isVariable) {
3077
+ const arity = col.arity;
3078
+ const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
3079
+ const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
3080
+ const copyLen =
3081
+ payloadBytes === null ? 0 : Math.min(payloadBytes.byteLength, rowBytes.byteLength);
3082
+ if (copyLen > 0 && payloadBytes !== null) {
3083
+ rowBytes.set(payloadBytes.subarray(0, copyLen));
3084
+ }
3085
+ // Zero the tail past the copied prefix so a short / missing payload
3086
+ // matches the prior fresh-slot semantics (the old pool path always
3087
+ // alloc'd a zeroed slot, so unwritten elements read back as 0).
3088
+ if (copyLen < rowBytes.byteLength) {
3089
+ rowBytes.fill(0, copyLen);
3090
+ }
3091
+ // feat-20260614 M4 / D-3: `array<shared<T>,N>` element-level retain.
3092
+ // Walk the copied prefix as u32 handles and retain each non-sentinel
3093
+ // element. Caller releases priors via `releaseManagedFieldOnRow` on
3094
+ // the set path (D-3 calling convention); the spawn path's fresh row
3095
+ // is zero-initialised so no priors exist.
3096
+ if (metaKey === 'shared' && copyLen > 0) {
3097
+ this.retainSharedArrayElements(rowBytes, copyLen >>> 2);
3098
+ }
3099
+ return;
3100
+ }
3101
+
3102
+ // Variable `array<T>`: prior-slot release lives at the caller (set path)
3103
+ // — D-3 calling convention. Spawn path's fresh rows carry stale swap-pop
3104
+ // debris which MUST NOT be released here.
3105
+ const byteLength = effectiveCount * elementBytes;
3106
+ const allocR = this.bufferPool.alloc(byteLength);
3107
+ if (!allocR.ok) {
3108
+ this.routeArrayError(
3109
+ {
3110
+ code: allocR.error.code,
3111
+ hint: allocR.error.hint,
3112
+ expected: allocR.error.expected,
3113
+ detail: allocR.error.detail,
3114
+ } as ManagedArrayErrorEnvelope,
3115
+ component.name,
3116
+ fieldName,
3117
+ );
3118
+ col.view[row] = 0;
3119
+ if (isVariable) {
3120
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
3121
+ if (countCol !== undefined) countCol.view[row] = 0;
3122
+ }
3123
+ return;
3124
+ }
3125
+ const slot = allocR.value;
3126
+ if (payloadBytes !== null) {
3127
+ const copyLen = Math.min(payloadBytes.byteLength, slot.view.byteLength);
3128
+ slot.view.set(payloadBytes.subarray(0, copyLen));
3129
+ }
3130
+ col.view[row] = slot.id;
3131
+ if (isVariable) {
3132
+ const countCol = fieldCols.get(arrayCountColumnName(fieldName));
3133
+ /* istanbul ignore else -- count column allocated by createArchetype */
3134
+ if (countCol !== undefined) countCol.view[row] = effectiveCount;
3135
+ }
3136
+ // feat-20260614 M4 / D-3: variable `array<shared<T>>` element-level
3137
+ // retain. Walk the live element prefix (effectiveCount u32 handles) and
3138
+ // retain each non-sentinel handle. Prior elements were released by the
3139
+ // caller via `releaseManagedFieldOnRow` on the set path (D-3 calling
3140
+ // convention); the spawn path has no priors.
3141
+ if (metaKey === 'shared' && effectiveCount > 0) {
3142
+ this.retainSharedArrayElements(slot.view, effectiveCount);
3143
+ }
3144
+ }
3145
+
3146
+ /**
3147
+ * Walk the first `count` u32 handles in `bytes` and call
3148
+ * `SharedRefStore.retain` on each non-sentinel slot id (feat-20260614 M4 /
3149
+ * D-3). Failures route via the error channel so the write chain stays
3150
+ * total; charter explicit-failure boundary lets AI users see structured
3151
+ * `shared-ref-released` payloads when retaining a stale handle.
3152
+ *
3153
+ * Helper-internal -- only called from `writeArrayField`'s `'shared'` arm.
3154
+ */
3155
+ private retainSharedArrayElements(bytes: Uint8Array, count: number): void {
3156
+ const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
3157
+ for (let i = 0; i < count; i++) {
3158
+ const raw = view[i];
3159
+ if (raw === undefined) continue;
3160
+ // R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
3161
+ // short-circuit (builtin slots + sentinel 0) lives in exactly one place.
3162
+ this.retainSharedScalarHandle(raw, 'array<shared<T>>', 'element');
3163
+ }
3164
+ }
3165
+
3166
+ /**
3167
+ * Walk the first `count` u32 handles in `bytes` and call
3168
+ * `SharedRefStore.release` on each non-sentinel slot id (feat-20260614 M4 /
3169
+ * D-3). Mirrors `retainSharedArrayElements`; called from
3170
+ * `releaseManagedFieldOnRow`'s array arm BEFORE the BufferPool slot is
3171
+ * released so the underlying bytes are still valid.
3172
+ */
3173
+ private releaseSharedArrayElements(bytes: Uint8Array, count: number): void {
3174
+ const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
3175
+ for (let i = 0; i < count; i++) {
3176
+ const raw = view[i];
3177
+ if (raw === undefined) continue;
3178
+ // R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
3179
+ // short-circuit (builtin slots + sentinel 0) lives in exactly one place.
3180
+ this.releaseSharedRefHandle(raw, 'array<shared<T>>', 'element');
3181
+ }
3182
+ }
3183
+
3184
+ /**
3185
+ * Materialize an array snapshot. Fixed arrays alias their inline column;
3186
+ * variable arrays alias the live BufferPool slot and use the count sidecar.
3187
+ * Both views are transient and must not be held across structural changes.
3188
+ */
3189
+ private materializeArrayView(
3190
+ col: Column,
3191
+ row: number,
3192
+ arrayMeta: ArrayMeta,
3193
+ elementCount: number,
3194
+ ): FieldView {
3195
+ if (arrayMeta.length !== undefined) {
3196
+ // Fixed `array<T,N>` (feat-20260602): the elements live INLINE in the
3197
+ // stride-N column. Reinterpret the row's byte window directly — no
3198
+ // BufferPool indirection.
3199
+ const elementBytes = elementByteSize(arrayMeta.elementType);
3200
+ const arity = col.arity;
3201
+ const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
3202
+ return reinterpretBufferRegion(
3203
+ col.view.buffer,
3204
+ rowByteOffset,
3205
+ arrayMeta.elementType,
3206
+ arrayMeta.length,
3207
+ );
3208
+ }
3209
+ return reinterpretSlotBytes(
3210
+ this.bufferPool.view(col.view[row] as number),
3211
+ arrayMeta.elementType,
3212
+ elementCount,
3213
+ );
3214
+ }
3215
+
3216
+ // ──────────────────────────────────────────────────────────────────────────
3217
+ // Internal — archetype migration
3218
+ // ──────────────────────────────────────────────────────────────────────────
3219
+
3220
+ /**
3221
+ * Copy surviving component columns into the target row, then swap-remove
3222
+ * the source row. Managed handles and variable-array sidecars are copied
3223
+ * verbatim; release remains the responsibility of remove/despawn paths.
3224
+ */
3225
+ private migrateEntity(record: EntityRecord, srcArch: Archetype, targetArch: Archetype): void {
3226
+ const oldArchetypeRow = record.archetypeRow;
3227
+ const oldTableRow = srcArch.rows[oldArchetypeRow] ?? 0;
3228
+ const srcTable = this.table(srcArch);
3229
+ const targetTable = this.table(targetArch);
3230
+ const entity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')?.view[
3231
+ oldTableRow
3232
+ ] ?? 0) as EntityHandle;
3233
+ const newTableRow = appendTableRow(targetTable, entity);
3234
+ const newArchetypeRow = appendArchetypeRow(targetArch, newTableRow);
3235
+
3236
+ // Copy shared component data.
3237
+ for (const [compId, srcComponentStorage] of srcTable.storage) {
3238
+ const srcFieldCols = srcComponentStorage.fields;
3239
+ const targetComponentStorage = targetTable.storage.get(compId);
3240
+ const targetFieldCols = targetComponentStorage?.fields;
3241
+ if (!targetFieldCols) {
3242
+ continue; // Component was removed — skip.
3243
+ }
3244
+ for (const [fieldName, srcCol] of srcFieldCols) {
3245
+ const targetCol = targetFieldCols.get(fieldName);
3246
+ if (!targetCol) {
3247
+ continue;
3248
+ }
3249
+ // Copy the whole stride-N block per row. Scalar / variable / `:count`
3250
+ // columns have arity 1 (single-element copy, byte-identical to the
3251
+ // prior `view[newRow] = view[oldRow]` form); fixed inline
3252
+ // `array<T,N>` / `buffer<N>` columns carry their N elements inline and
3253
+ // must migrate the entire block (feat-20260602).
3254
+ const arity = srcCol.arity;
3255
+ targetCol.view.set(
3256
+ srcCol.view.subarray(oldTableRow * arity, oldTableRow * arity + arity),
3257
+ newTableRow * arity,
3258
+ );
3259
+ }
3260
+ if (targetComponentStorage !== undefined) {
3261
+ copyComponentEpoch(
3262
+ srcComponentStorage.epochs,
3263
+ oldTableRow,
3264
+ targetComponentStorage.epochs,
3265
+ newTableRow,
3266
+ );
3267
+ }
3268
+ }
3269
+
3270
+ const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
3271
+ if (archetypeSwap !== null) {
3272
+ const movedEntity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')
3273
+ ?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
3274
+ const movedRecord = this.records[entityIndex(movedEntity)];
3275
+ if (movedRecord?.generation === entityGeneration(movedEntity)) {
3276
+ movedRecord.archetypeRow = archetypeSwap.newRow;
3277
+ }
3278
+ }
3279
+ const tableSwap = removeTableRow(srcTable, oldTableRow);
3280
+ if (tableSwap !== null) {
3281
+ const movedRecord = this.records[entityIndex(tableSwap.movedEntity)];
3282
+ if (movedRecord?.generation === entityGeneration(tableSwap.movedEntity)) {
3283
+ const movedArchetype = this.graph.archetypes[movedRecord.archetypeId];
3284
+ if (movedArchetype !== undefined) {
3285
+ movedArchetype.rows[movedRecord.archetypeRow] = tableSwap.newRow;
3286
+ }
3287
+ }
3288
+ }
3289
+
3290
+ record.archetypeId = targetArch.id;
3291
+ record.archetypeRow = newArchetypeRow;
3292
+ }
3293
+
3294
+ private moveEntityArchetype(
3295
+ record: EntityRecord,
3296
+ srcArch: Archetype,
3297
+ targetArch: Archetype,
3298
+ ): void {
3299
+ const table = this.table(srcArch);
3300
+ if (srcArch.tableId !== targetArch.tableId) {
3301
+ throw new Error('Logical archetype move requires a shared Table.');
3302
+ }
3303
+ const oldArchetypeRow = record.archetypeRow;
3304
+ const tableRow = srcArch.rows[oldArchetypeRow] ?? 0;
3305
+ const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
3306
+ if (archetypeSwap !== null) {
3307
+ const movedEntity = (table.storage.get(componentId(EntityComponent))?.fields.get('self')
3308
+ ?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
3309
+ const movedRecord = this.records[entityIndex(movedEntity)];
3310
+ if (movedRecord?.generation === entityGeneration(movedEntity)) {
3311
+ movedRecord.archetypeRow = archetypeSwap.newRow;
3312
+ }
3313
+ }
3314
+ record.archetypeId = targetArch.id;
3315
+ record.archetypeRow = appendArchetypeRow(targetArch, tableRow);
3316
+ }
3317
+
3318
+ /**
3319
+ * Retire one live entity and any linked-spawn descendants. The complete
3320
+ * row/relationship/managed-data mutation stays on World so a failure after
3321
+ * the first write can poison this identity instead of crossing an extraction
3322
+ * owner boundary.
3323
+ */
3324
+ private despawnEntity(entity: EntityHandle, internal: boolean): Result<void, EcsError> {
3325
+ const slot = entityIndex(entity);
3326
+ const generation = entityGeneration(entity);
3327
+ const record = this.records[slot];
3328
+ if (!this.recordIsLive(record, generation)) return ok(undefined);
3329
+
3330
+ const archetype = this.graph.archetypes[record.archetypeId];
3331
+ const linkedChildren =
3332
+ archetype === undefined ? [] : this.relationshipLinkedSpawnChildren(entity, archetype);
3333
+ let mutationStarted = false;
3334
+ try {
3335
+ if (archetype !== undefined) {
3336
+ const table = this.table(archetype);
3337
+ const archetypeRow = record.archetypeRow;
3338
+ const tableRow = archetype.rows[archetypeRow] ?? 0;
3339
+ mutationStarted = true;
3340
+ for (const component of archetype.components) {
3341
+ const role = relationshipRole(component);
3342
+ if (role?.kind === 'source' && !internal) {
3343
+ const oldValue = this.readRow(archetype, component, tableRow) as Record<
3344
+ string,
3345
+ unknown
3346
+ >;
3347
+ const relation = this.relationshipOnRemove(entity, component, oldValue);
3348
+ if (!relation.ok) {
3349
+ this.poisonAfterEntityMutation('World.despawn', relation.error);
3350
+ return relation;
3351
+ }
3352
+ }
3353
+ this.releaseManagedRefsOnRow(archetype, component, tableRow);
3354
+ }
3355
+ for (const component of archetype.components) {
3356
+ if (component.storage !== 'sparse') continue;
3357
+ const sparse = this.graph.sparseTags.get(componentId(component));
3358
+ if (sparse !== undefined) removeSparseTag(sparse, entity);
3359
+ }
3360
+ const archetypeSwap = removeArchetypeRow(archetype, archetypeRow);
3361
+ if (archetypeSwap !== null) {
3362
+ const movedEntity = (table.storage.get(componentId(EntityComponent))?.fields.get('self')
3363
+ ?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
3364
+ const movedRecord = this.records[entityIndex(movedEntity)];
3365
+ if (movedRecord?.generation === entityGeneration(movedEntity)) {
3366
+ movedRecord.archetypeRow = archetypeSwap.newRow;
3367
+ }
3368
+ }
3369
+ const tableSwap = removeTableRow(table, tableRow);
3370
+ if (tableSwap !== null) {
3371
+ const movedRecord = this.records[entityIndex(tableSwap.movedEntity)];
3372
+ if (movedRecord?.generation === entityGeneration(tableSwap.movedEntity)) {
3373
+ const movedArchetype = this.graph.archetypes[movedRecord.archetypeId];
3374
+ if (movedArchetype !== undefined) {
3375
+ movedArchetype.rows[movedRecord.archetypeRow] = tableSwap.newRow;
3376
+ }
3377
+ }
3378
+ }
3379
+ }
3380
+ this.recordStructuralEvidence({ kind: 'despawn', entity });
3381
+ record.archetypeId = -1;
3382
+ record.archetypeRow = -1;
3383
+ record.generation += 1;
3384
+ if (!isRetiredSlot(record.generation)) this.freeIndices.push(slot);
3385
+ for (const child of linkedChildren) {
3386
+ const childResult = this.despawnEntity(child, true);
3387
+ if (!childResult.ok) return childResult;
3388
+ }
3389
+ this.advanceStructureEpoch();
3390
+ return ok(undefined);
3391
+ } catch (error) {
3392
+ if (mutationStarted) this.poisonAfterEntityMutation('World.despawn', error);
3393
+ throw error;
3394
+ }
3395
+ }
3396
+
3397
+ // ──────────────────────────────────────────────────────────────────────────
1286
3398
  // Spawn
1287
3399
  // ──────────────────────────────────────────────────────────────────────────
1288
3400
 
@@ -1311,26 +3423,15 @@ export class World {
1311
3423
  }
1312
3424
  ): Result<EntityHandle, EcsError>;
1313
3425
  spawn(...componentDatas: ComponentData[]): Result<EntityHandle, EcsError> {
3426
+ const poisoned = this.poisonedResult<EntityHandle>();
3427
+ if (poisoned !== undefined) return poisoned;
1314
3428
  const target = componentDatas.find(
1315
3429
  (data) =>
1316
3430
  isRelationshipTarget(data.component) &&
1317
3431
  this.relationshipTargetPayloadWrites(data.data as Record<string, unknown>),
1318
3432
  );
1319
3433
  if (target !== undefined) return this.relationshipTargetWriteError(target.component, 'spawn');
1320
- return spawnCore(this, componentDatas, false);
1321
- }
1322
-
1323
- /**
1324
- * Core implementation of `spawn` with reentry guard.
1325
- *
1326
- * @param internal — `true` when called from relationship maintenance
1327
- * (lazy mirror create or exclusive reparent).
1328
- */
1329
- private internalspawnCore(
1330
- componentDatas: ComponentData[],
1331
- internal: boolean,
1332
- ): Result<EntityHandle, EcsError> {
1333
- return spawnCore(this, componentDatas, internal);
3434
+ return spawnCore(this, componentDatas);
1334
3435
  }
1335
3436
 
1336
3437
  // ──────────────────────────────────────────────────────────────────────────
@@ -1353,11 +3454,15 @@ export class World {
1353
3454
  * ```
1354
3455
  */
1355
3456
  despawn(entity: EntityHandle): Result<void, EcsError> {
1356
- return despawnCore(this, entity, false);
3457
+ const poisoned = this.poisonedResult<void>();
3458
+ if (poisoned !== undefined) return poisoned;
3459
+ return this.despawnEntity(entity, false);
1357
3460
  }
1358
3461
 
1359
3462
  /** Despawn every live entity through the normal lifecycle and ref cleanup path. */
1360
3463
  despawnAll(): Result<void, EcsError> {
3464
+ const poisoned = this.poisonedResult<void>();
3465
+ if (poisoned !== undefined) return poisoned;
1361
3466
  const entities: EntityHandle[] = [];
1362
3467
  for (let index = 0; index < this.records.length; index += 1) {
1363
3468
  const record = this.records[index];
@@ -1372,21 +3477,9 @@ export class World {
1372
3477
  return ok(undefined);
1373
3478
  }
1374
3479
 
1375
- /**
1376
- * Core implementation of `despawn` with reentry guard.
1377
- *
1378
- * @param internal — `true` when called from within linkedSpawn cascade.
1379
- * Nested despawn skips relationship pruning after the parent is retired;
1380
- * the linkedSpawn collection still walks the subtree so grandchildren
1381
- * cascade correctly (tweak-20260714 M2, R-6).
1382
- */
1383
- private internaldespawnCore(entity: EntityHandle, internal: boolean): Result<void, EcsError> {
1384
- return despawnCore(this, entity, internal);
1385
- }
1386
-
1387
3480
  // ──────────────────────────────────────────────────────────────────────────
1388
3481
  // Hierarchy facade — lifecycle orchestration lives in world-entity-lifecycle.
1389
- // Component storage still owns typed relationship mutation primitives.
3482
+ // World owns the component storage and relationship mutation primitives.
1390
3483
  // ──────────────────────────────────────────────────────────────────────────
1391
3484
 
1392
3485
  addChild<S extends ComponentSchema>(
@@ -1395,6 +3488,8 @@ export class World {
1395
3488
  component: Component<string, S>,
1396
3489
  data: Partial<InputShapeOf<S>>,
1397
3490
  ): Result<void, EcsError> {
3491
+ const poisoned = this.poisonedResult<void>();
3492
+ if (poisoned !== undefined) return poisoned;
1398
3493
  return worldAddChild(this, parent, child, component, data);
1399
3494
  }
1400
3495
 
@@ -1403,6 +3498,8 @@ export class World {
1403
3498
  child: EntityHandle,
1404
3499
  component: Component<string, S>,
1405
3500
  ): Result<void, EcsError> {
3501
+ const poisoned = this.poisonedResult<void>();
3502
+ if (poisoned !== undefined) return poisoned;
1406
3503
  return worldRemoveChild(this, parent, child, component);
1407
3504
  }
1408
3505
 
@@ -1412,6 +3509,8 @@ export class World {
1412
3509
  component: Component<string, S>,
1413
3510
  data: Partial<InputShapeOf<S>>,
1414
3511
  ): Result<void, EcsError> {
3512
+ const poisoned = this.poisonedResult<void>();
3513
+ if (poisoned !== undefined) return poisoned;
1415
3514
  return worldReparent(this, child, newParent, component, data);
1416
3515
  }
1417
3516
 
@@ -1423,21 +3522,3 @@ export class World {
1423
3522
  return worldIterDescendants(this, entity);
1424
3523
  }
1425
3524
  }
1426
-
1427
- /** Freeze the detached POD produced by World.inspect(). */
1428
- function detachWorldInspection<T extends object>(snapshot: T): Readonly<T> {
1429
- return freezeInspection(snapshot);
1430
- }
1431
-
1432
- function freezeInspection<T>(value: T): Readonly<T> {
1433
- if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
1434
- return value as Readonly<T>;
1435
- }
1436
- for (const key of Reflect.ownKeys(value as object)) {
1437
- const child = (value as Record<PropertyKey, unknown>)[key];
1438
- if (child !== null && (typeof child === 'object' || typeof child === 'function')) {
1439
- freezeInspection(child);
1440
- }
1441
- }
1442
- return Object.freeze(value) as Readonly<T>;
1443
- }