@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
@@ -1,1264 +0,0 @@
1
- // @forgeax/engine-ecs — world-component-storage: row and resource storage.
2
- //
3
- // Owns the low-level archetype row representation, managed reference lifetime,
4
- // array/buffer byte storage, and archetype migration. WorldComponentAccess owns
5
- // the typed public component operations and composes this state capability.
6
-
7
- import { BUILTIN_BASE, type Handle, toShared, toUnique, unwrapHandle } from '@forgeax/engine-types';
8
- import type { BufferPool } from './buffer-pool';
9
- import {
10
- type ArrayMeta,
11
- type Component,
12
- type ComponentSchema,
13
- componentId,
14
- componentSchema,
15
- fieldTypeToMetaKey,
16
- isEntityField,
17
- isManagedArrayField,
18
- isManagedBufferField,
19
- isManagedField,
20
- type ManagedArrayElementType,
21
- type ShapeOf,
22
- TYPE_METADATA,
23
- } from './component';
24
- import { componentDefinition } from './component-schema';
25
- import { Entity as EntityComponent } from './entity';
26
- import { ENTITY_NULL_RAW, type EntityHandle, entityGeneration, entityIndex } from './entity-handle';
27
- import type { ManagedArrayErrorEnvelope } from './errors';
28
-
29
- type ErrorContext = { readonly systemName: string };
30
-
31
- import type { SharedRefStore } from './shared-ref-store';
32
- import { type Archetype, appendArchetypeRow, removeArchetypeRow } from './storage/archetype';
33
- import { type ArchetypeGraph, getTable } from './storage/archetype-graph';
34
- import { copyComponentEpoch } from './storage/change-detection';
35
- import { arrayCountColumnName, type FieldView, normalizeBufferWrite } from './storage/column';
36
- import { appendTableRow, removeTableRow, type Table } from './storage/table';
37
- import type { UniqueRefStore } from './unique-ref-store';
38
- import type { EntityRecord } from './world';
39
-
40
- export interface ComponentStorageState {
41
- readonly graph: ArchetypeGraph;
42
- readonly records: EntityRecord[];
43
- readonly bufferPool: BufferPool;
44
- readonly uniqueRefs: UniqueRefStore;
45
- readonly sharedRefs: SharedRefStore;
46
- routeError(err: unknown, ctx: ErrorContext): void;
47
- }
48
-
49
- export class ComponentStorage {
50
- constructor(private readonly state: ComponentStorageState) {}
51
-
52
- private get records(): EntityRecord[] {
53
- return this.state.records;
54
- }
55
-
56
- private table(archetype: Archetype): Table {
57
- return getTable(this.state.graph, archetype.tableId);
58
- }
59
-
60
- private get bufferPool(): BufferPool {
61
- return this.state.bufferPool;
62
- }
63
-
64
- private get uniqueRefs(): UniqueRefStore {
65
- return this.state.uniqueRefs;
66
- }
67
-
68
- private get sharedRefs(): SharedRefStore {
69
- return this.state.sharedRefs;
70
- }
71
-
72
- private routeError(err: unknown, ctx: ErrorContext): void {
73
- this.state.routeError(err, ctx);
74
- }
75
-
76
- readArrayView(
77
- arch: Archetype,
78
- component: Component,
79
- row: number,
80
- fieldName: string,
81
- ): FieldView | undefined {
82
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
83
- if (!fieldCols) return undefined;
84
-
85
- const fieldType = componentSchema(component)[fieldName];
86
- if (fieldType === undefined) return undefined;
87
- // Component reflection already parses and freezes array metadata at
88
- // registration time. Reusing it here keeps the per-entity zero-copy path
89
- // parse-free; this accessor is called once for every renderable every
90
- // frame. The field lookup also preserves the existing undefined result
91
- // for non-array fields without reparsing arbitrary schema strings.
92
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
93
- if (arrayMeta === undefined) return undefined;
94
-
95
- const col = fieldCols.get(fieldName);
96
- if (!col) return undefined;
97
-
98
- if (arrayMeta.length !== undefined) {
99
- const elementBytes = elementByteSize(arrayMeta.elementType);
100
- const arity = col.arity;
101
- const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
102
- return reinterpretBufferRegion(
103
- col.view.buffer,
104
- rowByteOffset,
105
- arrayMeta.elementType,
106
- arrayMeta.length,
107
- );
108
- }
109
-
110
- const slotId = col.view[row] as number;
111
- const liveBytes = this.bufferPool.view(slotId);
112
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
113
- const elementCount = (countCol?.view[row] as number | undefined) ?? 0;
114
- return reinterpretSlotBytes(liveBytes, arrayMeta.elementType, elementCount);
115
- }
116
-
117
- /**
118
- * Read the live length of an array field without materialising a typed view.
119
- * Relationship consumers use this narrow storage seam for hot reverse-list
120
- * walks; the target array remains owned by the ECS column and no snapshot is
121
- * created per entity.
122
- */
123
- readArrayLength(
124
- arch: Archetype,
125
- component: Component,
126
- row: number,
127
- fieldName: string,
128
- ): number | undefined {
129
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
130
- if (fieldCols === undefined) return undefined;
131
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
132
- if (arrayMeta === undefined) return undefined;
133
- if (arrayMeta.length !== undefined) return arrayMeta.length;
134
- const count = fieldCols.get(arrayCountColumnName(fieldName))?.view[row];
135
- return typeof count === 'number' ? count : 0;
136
- }
137
-
138
- /**
139
- * Read one array element directly from its backing column/BufferPool slot.
140
- * The relationship target vocabulary is `array<entity>`, so its hot path
141
- * decodes the packed u32 in-place and does not allocate a TypedArray view.
142
- */
143
- readArrayElement(
144
- arch: Archetype,
145
- component: Component,
146
- row: number,
147
- fieldName: string,
148
- index: number,
149
- ): number | undefined {
150
- if (!Number.isSafeInteger(index) || index < 0) return undefined;
151
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
152
- if (fieldCols === undefined) return undefined;
153
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
154
- if (arrayMeta === undefined) return undefined;
155
- const length = this.readArrayLength(arch, component, row, fieldName);
156
- if (length === undefined || index >= length) return undefined;
157
- const col = fieldCols.get(fieldName);
158
- if (col === undefined) return undefined;
159
- if (arrayMeta.length !== undefined) {
160
- return col.view[row * col.arity + index] as number;
161
- }
162
- const slotId = col.view[row] as number;
163
- const bytes = this.bufferPool.view(slotId);
164
- if (arrayMeta.elementType === 'entity') {
165
- const byteOffset = index * 4;
166
- if (byteOffset + 4 > bytes.byteLength) return undefined;
167
- return (
168
- ((bytes[byteOffset] ?? 0) |
169
- ((bytes[byteOffset + 1] ?? 0) << 8) |
170
- ((bytes[byteOffset + 2] ?? 0) << 16) |
171
- ((bytes[byteOffset + 3] ?? 0) << 24)) >>>
172
- 0
173
- );
174
- }
175
- return this.readArrayElementAt(bytes, index, arrayMeta.elementType);
176
- }
177
-
178
- /** Read one scalar field without constructing a component shape. */
179
- readFieldValue(
180
- arch: Archetype,
181
- component: Component,
182
- row: number,
183
- fieldName: string,
184
- ): number | undefined {
185
- return this.table(arch).storage.get(componentId(component))?.fields.get(fieldName)?.view[row] as
186
- | number
187
- | undefined;
188
- }
189
-
190
- // ──────────────────────────────────────────────────────────────────────────
191
- // Internal — archetype data read/write
192
- // ──────────────────────────────────────────────────────────────────────────
193
-
194
- readRow<S extends ComponentSchema>(
195
- arch: Archetype,
196
- component: Component<string, S>,
197
- row: number,
198
- ): ShapeOf<S> {
199
- const localId = componentId(component);
200
- const fieldCols = this.table(arch).storage.get(localId)?.fields;
201
- const out = {} as ShapeOf<S>;
202
- /* istanbul ignore next -- defensive: component is registered and arch has it */
203
- if (!fieldCols) {
204
- return out;
205
- }
206
- for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
207
- const col = fieldCols.get(fieldName);
208
- if (!col) {
209
- continue;
210
- }
211
- const raw = col.view[row];
212
- if (fieldType === 'bool') {
213
- (out as Record<string, unknown>)[fieldName] = raw === 1;
214
- } else if (isEntityField(fieldType)) {
215
- // Entity field: decode the stored raw u32 verbatim back to Entity
216
- // (or null when the slot carries the ENTITY_NULL_RAW sentinel). No
217
- // liveness validation happens here -- a slot referencing a despawned
218
- // target returns its original raw encoding unchanged; the consumer is
219
- // responsible for checking liveness (e.g. `world.get(ref, Entity)`).
220
- (out as Record<string, unknown>)[fieldName] = raw === ENTITY_NULL_RAW ? null : raw;
221
- } else if (isManagedBufferField(fieldType)) {
222
- if (fieldType !== 'buffer') {
223
- // feat-20260602: fixed `buffer<N>` lives inline (stride-N u8 column,
224
- // arity = N). Return the row's byte window directly -- no pool slot.
225
- const arity = col.arity;
226
- (out as Record<string, unknown>)[fieldName] = (col.view as Uint8Array).subarray(
227
- row * arity,
228
- row * arity + arity,
229
- );
230
- } else {
231
- // Variable `'buffer'`: column stores slot id; the live view is
232
- // resolved on demand so post-grow callers always see the refreshed
233
- // Uint8Array.
234
- (out as Record<string, unknown>)[fieldName] = this.bufferPool.view(raw as number);
235
- }
236
- } else if (fieldType === 'string') {
237
- // M1 string-field read path (AC-03 / AC-09): resolve the column
238
- // u32 handle through UniqueRefStore -- same dispatch arm as the
239
- // 'unique<T>' read (D-R3). Returns the native JS string payload by
240
- // strong reference; identity is stable across reads until the
241
- // next set or release (AC-03 read-side identity contract).
242
- // Released / sentinel handles surface as unique-ref-released via
243
- // resolve; we fall back to '' rather than propagate the error so
244
- // the read shape (`out.value: string`) stays total -- AI users
245
- // never see undefined or wrapper objects.
246
- const resolveR = this.uniqueRefs.resolve<'String'>(toUnique<'String'>(raw as number));
247
- (out as Record<string, unknown>)[fieldName] = resolveR.ok ? resolveR.value : '';
248
- } else {
249
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
250
- if (arrayMeta !== undefined) {
251
- // Internal row path: materialise a fresh TypedArray view each call
252
- // without copying its backing bytes. Public World.get detaches a
253
- // relationship target array after this read; ECS relationship and
254
- // Scene owners use this readRow seam as zero-copy storage access.
255
- (out as Record<string, unknown>)[fieldName] = this.materializeArrayView(
256
- arch,
257
- component,
258
- row,
259
- fieldName,
260
- arrayMeta.elementType,
261
- arrayMeta.length,
262
- raw as number,
263
- );
264
- } else {
265
- (out as Record<string, unknown>)[fieldName] = raw;
266
- }
267
- }
268
- }
269
- return out;
270
- }
271
-
272
- /**
273
- * Write the full packed entity handle into the row's essential id=0 `Entity`
274
- * column (`self` field). Called by `spawn` / `_materializePendingEntity`
275
- * after the row is appended (feat-20260602 / plan-strategy D-3). The column
276
- * always exists -- `createArchetype` folds the Entity column into every
277
- * archetype -- so this is a direct u32 store, no readRow/writeRow walk.
278
- */
279
- writeEntitySelf(arch: Archetype, row: number, handle: EntityHandle): void {
280
- const col = this.table(arch).storage.get(componentId(EntityComponent))?.fields.get('self');
281
- /* istanbul ignore next -- defensive: Entity column is folded into every archetype */
282
- if (!col) return;
283
- col.view[row] = handle as unknown as number;
284
- }
285
-
286
- writeRow<S extends ComponentSchema>(
287
- arch: Archetype,
288
- component: Component<string, S>,
289
- row: number,
290
- value: ShapeOf<S>,
291
- options?: { readonly skipVariableArrayInitialization?: boolean },
292
- ): void {
293
- const localId = componentId(component);
294
- const fieldCols = this.table(arch).storage.get(localId)?.fields;
295
- /* istanbul ignore next -- defensive: component is registered and arch has it */
296
- if (!fieldCols) {
297
- return;
298
- }
299
- for (const [fieldName, fieldType] of Object.entries(componentSchema(component))) {
300
- const col = fieldCols.get(fieldName);
301
- /* istanbul ignore next -- defensive: schema fields always have columns */
302
- if (!col) {
303
- continue;
304
- }
305
- const raw = (value as Record<string, unknown>)[fieldName];
306
- if (fieldType === 'bool') {
307
- col.view[row] = raw ? 1 : 0;
308
- } else if (isEntityField(fieldType)) {
309
- // M3 entity field: encode (slot, gen) into u32 column. `null`
310
- // / undefined map to ENTITY_NULL_RAW sentinel.
311
- col.view[row] = raw === null || raw === undefined ? ENTITY_NULL_RAW : (raw as number);
312
- } else if (isManagedBufferField(fieldType)) {
313
- // M2 spawn path: collapsed-vocab keyword family `'buffer'` (variable)
314
- // + `'buffer<N>'` (fixed):
315
- // - `buffer<N>` (feat-20260602) — lives inline as a stride-N u8
316
- // column (arity = N). Copy any provided payload straight into the
317
- // row window (truncate to N); no BufferPool slot.
318
- // - `'buffer'` — variable capacity; alloc one BufferPool slot sized
319
- // to the provided payload's byteLength. Missing / non-buffer raw
320
- // -> alloc(0) zero-length live view (verify round 1 B2 fix path;
321
- // pre-fix the bare keyword routed `bufferFieldByteLength('buffer')`
322
- // -> NaN -> alloc(NaN) -> managed-buffer-out-of-bounds, dropping
323
- // the payload bytes silently). Failures route to Layer 3
324
- // ErrorHandler; column slot stays at 0 (sentinel) so subsequent
325
- // release short-circuits.
326
- // raw is normalized from any AllowSharedBufferSource view to a
327
- // Uint8Array over its bytes (feat-20260621 V2 / AC-A4).
328
- const bytes = normalizeBufferWrite(raw);
329
- if (fieldType !== 'buffer') {
330
- const arity = col.arity;
331
- if (bytes !== null) {
332
- const copyLen = Math.min(bytes.byteLength, arity);
333
- (col.view as Uint8Array).set(bytes.subarray(0, copyLen), row * arity);
334
- }
335
- } else {
336
- const allocBytes = bytes !== null ? bytes.byteLength : 0;
337
- const allocR = this.bufferPool.alloc(allocBytes);
338
- if (!allocR.ok) {
339
- const ctx: ErrorContext = {
340
- systemName: `World.spawn (${component.name}.${fieldName})`,
341
- };
342
- this.routeError(allocR.error, ctx);
343
- col.view[row] = 0;
344
- continue;
345
- }
346
- const slot = allocR.value;
347
- if (bytes !== null) {
348
- // allocBytes is the payload's exact byteLength so no truncation.
349
- const copyLen = Math.min(bytes.byteLength, slot.view.byteLength);
350
- slot.view.set(bytes.subarray(0, copyLen));
351
- }
352
- col.view[row] = slot.id;
353
- }
354
- } else if (fieldType === 'string') {
355
- // M1 string-field spawn path (AC-04 / AC-06): route the JS string
356
- // payload through `uniqueRefs.alloc('String', text)` -- the same
357
- // UniqueRefStore the `ref<T>` arm uses (D-R3 single-arm dispatch).
358
- // The store holds the immutable string by strong reference so
359
- // identity is stable across reads (AC-03). Missing / non-string raw
360
- // falls back to '' so AI users always see a readable string on
361
- // get (no nullable handling).
362
- const text = typeof raw === 'string' ? raw : '';
363
- const handle = this.uniqueRefs.alloc<'String'>('String', text);
364
- col.view[row] = unwrapHandle(handle);
365
- } else {
366
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
367
- if (arrayMeta !== undefined) {
368
- if (options?.skipVariableArrayInitialization === true && arrayMeta.length === undefined) {
369
- // Relationship preparation can reserve the first target slot
370
- // before the mirror archetype is created. Leave the fresh row at
371
- // the sentinel until the owner installs that reservation; this
372
- // keeps an injected alloc failure out of the structural path.
373
- col.view[row] = 0;
374
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
375
- if (countCol !== undefined) countCol.view[row] = 0;
376
- continue;
377
- }
378
- // M1 spawn path for array<T> / array<T,N> fields (D-3 double-
379
- // column for variable; single column for fixed).
380
- // Spawn path: no prior-slot release (fresh rows carry stale debris
381
- // owned by the migrated entity in the new archetype) — feat-20260614
382
- // D-3 calling convention.
383
- this.writeArrayField(arch, component, row, fieldName, fieldType, arrayMeta, raw);
384
- } else {
385
- col.view[row] = raw as number;
386
- // feat-20260614 M5 / D-5: scalar 'shared<T>' spawn retain. The
387
- // alloc-grant rc=1 stays held by the producer (e.g. AssetRegistry);
388
- // each ECS holder bumps rc via this retain so despawn / overwrite
389
- // releases bring rc back symmetrically. Sentinel slot 0 is a no-op.
390
- if (fieldType.startsWith('shared<') && (raw as number) !== 0) {
391
- this.retainSharedScalarHandle(raw as number, component.name, fieldName);
392
- }
393
- }
394
- }
395
- }
396
- }
397
-
398
- // ──────────────────────────────────────────────────────────────────────────
399
- // Internal — managed-ref + managed-buffer release loop (M1 / M2)
400
- // ──────────────────────────────────────────────────────────────────────────
401
-
402
- /**
403
- * Release every managed-resource field on `component` for the row at
404
- * `row` in `arch`. Walks the schema once and delegates each field to
405
- * `releaseManagedFieldOnRow` (feat-20260614 M2 SSOT). Family coverage
406
- * (per-field) lives in that helper's JSDoc.
407
- *
408
- * Naming note (D-6 whitelist): the prefix `managed` here means
409
- * `managed = ECS-tracked` — i.e. fields whose lifecycle the ECS
410
- * actively releases on despawn / overwrite / removeComponent. It does
411
- * NOT refer to the retired `'managed' | 'unmanaged'` Handle brand
412
- * (renamed to `'unique' | 'shared'` in feat-20260614 M1). The helper
413
- * walks BOTH `'unique<T>'` and `'shared<T>'` fields because both are
414
- * ECS-tracked from the column's perspective; the per-field dispatch
415
- * inside `releaseManagedFieldOnRow` distinguishes drop-on-despawn
416
- * (unique) vs ref-counted release (shared).
417
- *
418
- * Skips sentinel slots (handle / id 0) and missing stores. Failures
419
- * (double release / lookup mismatch) route to the Layer 3 ErrorHandler so
420
- * the despawn / removeComponent chain never aborts (charter: explicit-
421
- * failure boundary; the chain is total).
422
- *
423
- * Four release paths route through this helper (AC-11, plan §6 M2):
424
- * 1. `world.despawn(e)` - every component on `e`.
425
- * 2. `world.removeComponent(e, C)` - the removed component only.
426
- * 3. `world.set` ref/string/buffer overwrite - per-field, before the new
427
- * value lands in the column.
428
- * 4. `writeArrayField` set arm prior-slot release (variable array<T>).
429
- *
430
- * After feat-20260614 M2 SSOT collapse the schema-field 3-arm dispatch
431
- * lives in `releaseManagedFieldOnRow` (one site); this method walks the
432
- * component schema and delegates per field.
433
- *
434
- * BufferPool slot id is reusable post-release: same-bucket free-list LIFO
435
- * (D-7) returns the freed id on the next `alloc(byteLength)`. Tests:
436
- * `__tests__/managed-array-release.test.ts` (w10) +
437
- * `__tests__/world-managed-roundtrip.unit.test.ts` (w3 net-zero matrix).
438
- */
439
- releaseManagedRefsOnRow(arch: Archetype, component: Component, row: number): void {
440
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
441
- if (!fieldCols) return;
442
- for (const fieldName of Object.keys(componentSchema(component))) {
443
- this.releaseManagedFieldOnRow(arch, component, row, fieldName);
444
- }
445
- }
446
-
447
- /**
448
- * SSOT release dispatch for a single managed field on a row (feat-20260614
449
- * M2 / D-2). Inspects the component schema's field type and routes to the
450
- * matching release path:
451
- *
452
- * - `'unique<T>'` / `'string'` (`isManagedField`) — release the
453
- * UniqueRefStore handle u32 stored in the column.
454
- * - `'buffer'` variable (`isManagedBufferField`) — release the
455
- * BufferPool slot id stored in the column. Fixed `'buffer<N>'` is
456
- * inline stride-N (feat-20260602) and has no slot to release.
457
- * - `array<T>` variable (`isManagedArrayField`) — release the
458
- * BufferPool slot id in the primary column + zero the column and the
459
- * `<fieldName>:count` sidecar so post-recycle reads observe count=0
460
- * (defense-in-depth; the swap-pop row migration overwrites both
461
- * columns anyway). Fixed `array<T,N>` is inline stride-N — nothing to
462
- * release.
463
- *
464
- * Sentinel handle / slot id 0 short-circuits silently. Double-release /
465
- * lookup mismatch routes via Layer 3 ErrorHandler so the despawn /
466
- * removeComponent / set chain never aborts (charter explicit-failure
467
- * boundary; the chain is total).
468
- *
469
- * SceneInstance state alloc/release rollback at world.ts:3494/3522 stays
470
- * a direct `uniqueRefs.release(stateRef)` (research Finding 1.8 / D-5):
471
- * those two sites are alloc-pair rollback, not schema-field dispatch.
472
- *
473
- * Naming note (D-6 whitelist): `releaseManagedFieldOnRow` uses
474
- * `managed = ECS-tracked` — every field family this dispatcher knows
475
- * (`'unique<T>'`, `'shared<T>'`, `'string'`, variable `'buffer'`,
476
- * variable `'array<T>'`) is one whose lifecycle the ECS owns. The
477
- * `'managed' | 'unmanaged'` Handle brand is gone (M1 renamed to
478
- * `'unique' | 'shared'`); the helper name was deliberately kept
479
- * because `managed` here is a column-side semantic, not a brand label.
480
- */
481
- releaseManagedFieldOnRow(
482
- arch: Archetype,
483
- component: Component,
484
- row: number,
485
- fieldName: string,
486
- ): void {
487
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
488
- if (!fieldCols) return;
489
- const col = fieldCols.get(fieldName);
490
- if (!col) return;
491
- const fieldType = (componentSchema(component) as Record<string, string>)[fieldName] ?? '';
492
- if (isManagedField(fieldType)) {
493
- // Sub-dispatch by the schema-vocab keyword (feat-20260614 M4 / AC-08):
494
- // - 'shared<T>' scalar -> SharedRefStore.release (rc--; drop on rc=0)
495
- // - 'unique<T>' / 'string' -> UniqueRefStore.release (direct slot drop)
496
- // Both column shapes are u32 handles; the lookup store differs.
497
- // Keeping both arms inside the unified `isManagedField` block is
498
- // intentional: meta key (TYPE_METADATA `'shared'` vs `'ref'`) decides
499
- // the store, not a separate top-level branch (architecture-principles
500
- // §1 SSOT — meta key = release semantics).
501
- const handleU32 = col.view[row] as number;
502
- if (fieldType.startsWith('shared<')) {
503
- this.releaseSharedRefHandle(handleU32, component.name, fieldName);
504
- return;
505
- }
506
- this.releaseManagedRefHandle(handleU32, component.name, fieldName);
507
- return;
508
- }
509
- if (isManagedBufferField(fieldType)) {
510
- if (fieldType === 'buffer') {
511
- const slotId = col.view[row] as number;
512
- this.releaseManagedBufferSlot(slotId, component.name, fieldName);
513
- col.view[row] = 0;
514
- }
515
- return;
516
- }
517
- if (isManagedArrayField(fieldType)) {
518
- // Use the pre-parsed arrayMeta cached on the component descriptor at
519
- // registration (AC-03c parse-free hot path); reaching for
520
- // `parseManagedArraySchema` here would violate the parse-free
521
- // invariant exercised by hierarchy.unit.test.ts §w5 AC-03(a).
522
- const arrayMeta = componentDefinition(component).fields[fieldName]?.arrayMeta;
523
- if (arrayMeta === undefined) return;
524
- const isSharedElement = arrayMeta.elementType.startsWith('shared<');
525
- if (arrayMeta.length === undefined) {
526
- // Variable `array<T>`: BufferPool slot id in primary column + live
527
- // count in `<fieldName>:count` sidecar. For `array<shared<T>>`,
528
- // walk live elements and release each shared handle BEFORE
529
- // releasing the slot bytes (feat-20260614 M4 / D-3 — slot bytes
530
- // are only valid until the slot is recycled).
531
- const slotId = col.view[row] as number;
532
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
533
- if (isSharedElement && slotId !== 0) {
534
- const liveCount = countCol !== undefined ? (countCol.view[row] as number) : 0;
535
- const slotView = liveCount > 0 ? this.bufferPool.view(slotId) : null;
536
- if (slotView !== null && slotView.byteLength > 0) {
537
- this.releaseSharedArrayElements(slotView, liveCount);
538
- }
539
- }
540
- this.releaseManagedBufferSlot(slotId, component.name, fieldName);
541
- col.view[row] = 0;
542
- if (countCol !== undefined) countCol.view[row] = 0;
543
- return;
544
- }
545
- // Fixed `array<T,N>` (feat-20260602): inline stride-N column, no
546
- // BufferPool slot to release. For `array<shared<T>,N>`, walk the N
547
- // inline elements and release each shared handle. Zero the row
548
- // window so subsequent writes do not double-release.
549
- if (isSharedElement) {
550
- const arity = col.arity;
551
- const elementBytes = (TYPE_METADATA.shared?.byteSize ?? 4) as number;
552
- const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
553
- const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
554
- this.releaseSharedArrayElements(rowBytes, arity);
555
- rowBytes.fill(0);
556
- }
557
- }
558
- }
559
-
560
- /**
561
- * Release a single managed handle u32. Routes failures through Layer 3.
562
- * Slot-0 sentinel short-circuits silently (no error); already-released
563
- * slots surface `unique-ref-double-release` through the ErrorHandler so
564
- * AI users see the structured payload (`.code` / `.hint` / `.expected` /
565
- * `.detail`) - charter explicit-failure boundary.
566
- *
567
- * Helper-internal only after feat-20260614 M2 (AC-03 grep gate). External
568
- * callers route via `releaseManagedFieldOnRow`.
569
- */
570
- releaseManagedRefHandle(handleU32: number, componentName: string, fieldName: string): void {
571
- if (handleU32 === 0) return; // sentinel: skip silently.
572
- const r = this.uniqueRefs.release(handleU32 as Handle<string, 'unique'>);
573
- if (r.ok) return;
574
- // Layer 3 routing: surface double-release as a structured error so AI
575
- // users see {code, hint, expected, detail} on their handler. Severity
576
- // defaults to Error so the chain continues; matchSeverity prints to
577
- // console.error rather than throw.
578
- const ctx: ErrorContext = {
579
- systemName: `World.release (${componentName}.${fieldName})`,
580
- };
581
- this.routeError(r.error, ctx);
582
- }
583
-
584
- /**
585
- * Release a single shared-ref handle u32 (feat-20260614 M4 / AC-08).
586
- * Decrements the SharedRefStore refcount; the slot drops on rc 1 -> 0.
587
- * Slot-0 sentinel short-circuits silently. Already-released slots route
588
- * `shared-ref-double-release` through Layer 3 ErrorHandler so AI users
589
- * see structured payloads (charter explicit-failure boundary).
590
- *
591
- * Mirrors `releaseManagedRefHandle` in shape; the store + error code are
592
- * the only differences. Helper-internal — external callers route via
593
- * `releaseManagedFieldOnRow` (D-2 SSOT).
594
- */
595
- releaseSharedRefHandle(handleU32: number, componentName: string, fieldName: string): void {
596
- // feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
597
- // sentinel 0) are process-static and never reference-counted -> short-circuit
598
- // before touching SharedRefStore. This single guard is the SSOT for both the
599
- // scalar arm (here) and the array-element arm (releaseSharedArrayElements).
600
- if (handleU32 < BUILTIN_BASE) return;
601
- const r = this.sharedRefs.release(toShared<string>(handleU32));
602
- if (r.ok) return;
603
- const ctx: ErrorContext = {
604
- systemName: `World.release (${componentName}.${fieldName})`,
605
- };
606
- this.routeError(r.error, ctx);
607
- }
608
-
609
- /**
610
- * Retain a single `shared<T>` scalar slot id (feat-20260614 M5 / D-5).
611
- * Mirrors `releaseSharedRefHandle`; called from spawn / set scalar write
612
- * paths so each ECS holder participates in the SharedRefStore rc.
613
- * Sentinel slot 0 is a no-op. Failures (handle already released) route
614
- * via Layer 3 ErrorHandler so the spawn / set chain stays total.
615
- */
616
- retainSharedScalarHandle(handleU32: number, componentName: string, fieldName: string): void {
617
- // feat-20260614 M6 D-15 / R-14: builtin slots (< BUILTIN_BASE, including the
618
- // sentinel 0) short-circuit — process-static, never reference-counted. SSOT
619
- // guard shared with the array-element arm (retainSharedArrayElements).
620
- if (handleU32 < BUILTIN_BASE) return;
621
- const r = this.sharedRefs.retain(toShared<string>(handleU32));
622
- if (r.ok) return;
623
- const ctx: ErrorContext = {
624
- systemName: `World.write (${componentName}.${fieldName} shared scalar retain)`,
625
- };
626
- this.routeError(r.error, ctx);
627
- }
628
-
629
- /**
630
- * Release a single managed buffer slot id. Routes failures through Layer 3.
631
- * Slot id 0 (sentinel for unallocated buffer fields) short-circuits
632
- * silently. M2 v1 release surface is total - `BufferPool.release` returns
633
- * `Result<void, never>` for unknown ids, so the chain stays noise-free.
634
- *
635
- * Helper-internal only after feat-20260614 M2 (AC-03 grep gate). External
636
- * callers route via `releaseManagedFieldOnRow`.
637
- */
638
- releaseManagedBufferSlot(slotId: number, componentName: string, fieldName: string): void {
639
- if (slotId === 0) return; // sentinel: skip silently.
640
- const r = this.bufferPool.release(slotId);
641
- /* istanbul ignore if -- BufferPool.release is total in v1 (Result<void, never>); branch reserved for future fail-fast extension. */
642
- if (!r.ok) {
643
- const ctx: ErrorContext = {
644
- systemName: `World.release (${componentName}.${fieldName})`,
645
- };
646
- this.routeError(r.error, ctx);
647
- }
648
- }
649
-
650
- // ──────────────────────────────────────────────────────────────────────────
651
- // Internal — array<T> / array<T,N> spawn / set helpers (M1 / w7)
652
- // ──────────────────────────────────────────────────────────────────────────
653
-
654
- /**
655
- * Bridge a Layer-3 ErrorHandler call from a `ManagedArrayErrorEnvelope`.
656
- * The envelope shape (`code / hint / expected / detail`) already mirrors
657
- * the EcsError contract; this helper only attaches the systemName context
658
- * so AI users can correlate the error with the holder component / field.
659
- */
660
- routeArrayError(err: ManagedArrayErrorEnvelope, componentName: string, fieldName: string): void {
661
- const ctx: ErrorContext = {
662
- systemName: `World.write (${componentName}.${fieldName})`,
663
- };
664
- this.routeError(err, ctx);
665
- }
666
-
667
- /**
668
- * Write the value of an `array<T>` / `array<T,N>` field at `row` (D-3 +
669
- /**
670
- * Write an `array<T>` / `array<T,N>` field's payload at `row` (M1 / w7,
671
- * D-1; feat-20260614 M2 / D-3). Spawn and set both delegate here without
672
- * an `operation` parameter — the caller's calling convention encodes the
673
- * difference:
674
- * - Set path: caller invokes `releaseManagedFieldOnRow(arch, comp, row,
675
- * fieldName)` BEFORE this method to release the prior slot (variable
676
- * `array<T>`). Fixed `array<T,N>` is inline stride-N (feat-20260602)
677
- * and has no slot to release on either path.
678
- * - Spawn path: caller does NOT call the helper — fresh rows treat any
679
- * non-zero u32 in the column as stale swap-pop debris owned by the
680
- * migrated entity in the new archetype.
681
- *
682
- * Body:
683
- * - Alloc a fresh BufferPool slot of `payload.length * elementBytes`.
684
- * - Copy bytes from the payload's typed-array buffer into the slot view.
685
- * - Persist slot id in the primary u32 column. For `array<T>` (variable),
686
- * persist the live count in the sidecar `<fieldName>:count` column.
687
- *
688
- * Errors flow through `routeArrayError` with a uniform `World.write` label.
689
- */
690
- writeArrayField(
691
- arch: Archetype,
692
- component: Component,
693
- row: number,
694
- fieldName: string,
695
- _fieldType: string,
696
- arrayMeta: ArrayMeta,
697
- raw: unknown,
698
- ): void {
699
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
700
- /* istanbul ignore next -- writeArrayField caller validated the column map */
701
- if (!fieldCols) return;
702
- const col = fieldCols.get(fieldName);
703
- /* istanbul ignore next -- writeArrayField caller validated the column */
704
- if (!col) return;
705
-
706
- const elementType = arrayMeta.elementType;
707
- // Normalize parametrised element-type template literals to the family
708
- // key for TYPE_METADATA lookup; the column stores plain u32 handles
709
- // either way:
710
- // - `shared<X>` -> 'shared' (feat-20260614 M4 / D-3 -- element-level
711
- // retain/release semantics route via the dedicated `'shared'` arm
712
- // below)
713
- const metaKey = elementType.startsWith('shared<') ? 'shared' : (elementType as string);
714
- const meta = TYPE_METADATA[metaKey];
715
- /* istanbul ignore next -- arrayMeta.elementType is guaranteed in TYPE_METADATA */
716
- if (!meta) return;
717
- // biome-ignore lint/style/noNonNullAssertion: ManagedArrayElementType always scalar -> byteSize present
718
- const elementBytes = meta.byteSize!;
719
-
720
- const isVariable = arrayMeta.length === undefined;
721
- const fixedLength = arrayMeta.length ?? 0;
722
-
723
- // Determine the payload's logical element count. Accept any TypedArray
724
- // (Float32Array / Uint32Array / etc.) plus plain numeric arrays; an
725
- // undefined / missing payload is treated as a length-0 init. Bytes are
726
- // copied from the source's underlying ArrayBuffer when present.
727
- let payloadCount = 0;
728
- let payloadBytes: Uint8Array | null = null;
729
- if (raw !== null && raw !== undefined) {
730
- if (
731
- raw instanceof Float32Array ||
732
- raw instanceof Float64Array ||
733
- raw instanceof Int32Array ||
734
- raw instanceof Uint32Array ||
735
- raw instanceof Int16Array ||
736
- raw instanceof Uint16Array ||
737
- raw instanceof Int8Array ||
738
- raw instanceof Uint8Array
739
- ) {
740
- payloadCount = raw.length;
741
- payloadBytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
742
- } else if (Array.isArray(raw)) {
743
- payloadCount = raw.length;
744
- // Plain JS array: pack each element through the declared element
745
- // type's TypedArray constructor (`meta.viewCtor`) so the numeric
746
- // VALUE is encoded, not its integer bit pattern. Dispatching on
747
- // `viewCtor` (the type SSOT) rather than byte size is what keeps
748
- // `array<f32,N>` distinct from `array<u32,N>` -- both are 4 bytes,
749
- // so a size-keyed setter would store an f32 `1.0` as the u32 bits
750
- // `0x00000001` (reads back ~1.4e-45). The TypedArray then exposes
751
- // its little-endian bytes for the shared copy path below.
752
- if (payloadCount > 0 && meta.viewCtor !== undefined) {
753
- const typed = new meta.viewCtor(payloadCount);
754
- for (let i = 0; i < payloadCount; i++) {
755
- const val = raw[i];
756
- typed[i] = typeof val === 'number' ? val : 0;
757
- }
758
- payloadBytes = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
759
- }
760
- }
761
- }
762
-
763
- // Effective count for variable arrays = payload count; for fixed
764
- // arrays = schema-declared N (the payload's length is advisory — we
765
- // copy up to N elements and pad the rest with zero).
766
- const effectiveCount = isVariable ? payloadCount : fixedLength;
767
-
768
- // Fixed `array<T,N>` (feat-20260602): the column is an inline stride-N
769
- // view (`col.arity === N`), so write the payload bytes directly into the
770
- // row's stride window — no BufferPool slot, no slot-id store, no
771
- // prior-slot release. The byte window starts at `row * arity * elementBytes`
772
- // and spans N elements; payloads shorter than N copy a prefix and leave
773
- // the tail at its current value (spawn rows are zero-initialised by the
774
- // fresh column buffer; the swap-pop migration copies the whole block).
775
- if (!isVariable) {
776
- const arity = col.arity;
777
- const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
778
- const rowBytes = new Uint8Array(col.view.buffer, rowByteOffset, arity * elementBytes);
779
- const copyLen =
780
- payloadBytes === null ? 0 : Math.min(payloadBytes.byteLength, rowBytes.byteLength);
781
- if (copyLen > 0 && payloadBytes !== null) {
782
- rowBytes.set(payloadBytes.subarray(0, copyLen));
783
- }
784
- // Zero the tail past the copied prefix so a short / missing payload
785
- // matches the prior fresh-slot semantics (the old pool path always
786
- // alloc'd a zeroed slot, so unwritten elements read back as 0).
787
- if (copyLen < rowBytes.byteLength) {
788
- rowBytes.fill(0, copyLen);
789
- }
790
- // feat-20260614 M4 / D-3: `array<shared<T>,N>` element-level retain.
791
- // Walk the copied prefix as u32 handles and retain each non-sentinel
792
- // element. Caller releases priors via `releaseManagedFieldOnRow` on
793
- // the set path (D-3 calling convention); the spawn path's fresh row
794
- // is zero-initialised so no priors exist.
795
- if (metaKey === 'shared' && copyLen > 0) {
796
- this.retainSharedArrayElements(rowBytes, copyLen >>> 2);
797
- }
798
- return;
799
- }
800
-
801
- // Variable `array<T>`: prior-slot release lives at the caller (set path)
802
- // — D-3 calling convention. Spawn path's fresh rows carry stale swap-pop
803
- // debris which MUST NOT be released here.
804
- const byteLength = effectiveCount * elementBytes;
805
- const allocR = this.bufferPool.alloc(byteLength);
806
- if (!allocR.ok) {
807
- this.routeArrayError(
808
- {
809
- code: allocR.error.code,
810
- hint: allocR.error.hint,
811
- expected: allocR.error.expected,
812
- detail: allocR.error.detail,
813
- } as ManagedArrayErrorEnvelope,
814
- component.name,
815
- fieldName,
816
- );
817
- col.view[row] = 0;
818
- if (isVariable) {
819
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
820
- if (countCol !== undefined) countCol.view[row] = 0;
821
- }
822
- return;
823
- }
824
- const slot = allocR.value;
825
- if (payloadBytes !== null) {
826
- const copyLen = Math.min(payloadBytes.byteLength, slot.view.byteLength);
827
- slot.view.set(payloadBytes.subarray(0, copyLen));
828
- }
829
- col.view[row] = slot.id;
830
- if (isVariable) {
831
- const countCol = fieldCols.get(arrayCountColumnName(fieldName));
832
- /* istanbul ignore else -- count column allocated by createArchetype */
833
- if (countCol !== undefined) countCol.view[row] = effectiveCount;
834
- }
835
- // feat-20260614 M4 / D-3: variable `array<shared<T>>` element-level
836
- // retain. Walk the live element prefix (effectiveCount u32 handles) and
837
- // retain each non-sentinel handle. Prior elements were released by the
838
- // caller via `releaseManagedFieldOnRow` on the set path (D-3 calling
839
- // convention); the spawn path has no priors.
840
- if (metaKey === 'shared' && effectiveCount > 0) {
841
- this.retainSharedArrayElements(slot.view, effectiveCount);
842
- }
843
- }
844
-
845
- /**
846
- * Walk the first `count` u32 handles in `bytes` and call
847
- * `SharedRefStore.retain` on each non-sentinel slot id (feat-20260614 M4 /
848
- * D-3). Failures route via Layer 3 ErrorHandler so the write chain stays
849
- * total; charter explicit-failure boundary lets AI users see structured
850
- * `shared-ref-released` payloads when retaining a stale handle.
851
- *
852
- * Helper-internal -- only called from `writeArrayField`'s `'shared'` arm.
853
- */
854
- retainSharedArrayElements(bytes: Uint8Array, count: number): void {
855
- const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
856
- for (let i = 0; i < count; i++) {
857
- const raw = view[i];
858
- if (raw === undefined) continue;
859
- // R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
860
- // short-circuit (builtin slots + sentinel 0) lives in exactly one place.
861
- this.retainSharedScalarHandle(raw, 'array<shared<T>>', 'element');
862
- }
863
- }
864
-
865
- /**
866
- * Walk the first `count` u32 handles in `bytes` and call
867
- * `SharedRefStore.release` on each non-sentinel slot id (feat-20260614 M4 /
868
- * D-3). Mirrors `retainSharedArrayElements`; called from
869
- * `releaseManagedFieldOnRow`'s array arm BEFORE the BufferPool slot is
870
- * released so the underlying bytes are still valid.
871
- */
872
- releaseSharedArrayElements(bytes: Uint8Array, count: number): void {
873
- const view = new Uint32Array(bytes.buffer, bytes.byteOffset, count);
874
- for (let i = 0; i < count; i++) {
875
- const raw = view[i];
876
- if (raw === undefined) continue;
877
- // R-14: route through the scalar SSOT helper so the `< BUILTIN_BASE`
878
- // short-circuit (builtin slots + sentinel 0) lives in exactly one place.
879
- this.releaseSharedRefHandle(raw, 'array<shared<T>>', 'element');
880
- }
881
- }
882
-
883
- writeArrayElementAt(
884
- bytes: Uint8Array,
885
- idx: number,
886
- elementType: ManagedArrayElementType,
887
- value: number,
888
- ): void {
889
- writeArrayElementAt(bytes, idx, elementType, value);
890
- }
891
-
892
- readArrayElementAt(bytes: Uint8Array, idx: number, elementType: ManagedArrayElementType): number {
893
- return readArrayElementAt(bytes, idx, elementType);
894
- }
895
-
896
- /**
897
- * Materialise a fresh `TypedArray` view for an `array<T,N>` / `array<T>`
898
- * field at `row`. This is the internal row-storage path: the returned view
899
- * aliases the live column or BufferPool slot. `WorldComponentAccess.get`
900
- * detaches relationship target arrays at the public boundary; internal ECS
901
- * relationship maintenance and Scene traversal keep this zero-copy path.
902
- *
903
- * **Transient view contract (feat-20260602):** for fixed `array<T,N>`
904
- * columns the returned `TypedArray` aliases the inline column buffer
905
- * directly (`col.view.subarray(row * arity, ...)`); for variable
906
- * `array<T>` columns it aliases the live `BufferPool` slot bytes
907
- * (zero-copy; `pool.view(slotId)` is the SSOT byte region). In both cases
908
- * the view is valid only until the next structural change. Internal callers
909
- * must not write through it except via the owner mutation helpers.
910
- *
911
- * For variable arrays the typed-array length matches the live count from
912
- * the sidecar `<fieldName>:count` column; for fixed arrays it matches the
913
- * schema-declared `N`. `entity` element fields surface as a `Uint32Array`
914
- * view (Entity packs slot+gen into u32).
915
- */
916
- materializeArrayView(
917
- arch: Archetype,
918
- component: Component,
919
- row: number,
920
- fieldName: string,
921
- elementType: ManagedArrayElementType,
922
- fixedLength: number | undefined,
923
- slotId: number,
924
- ):
925
- | Float32Array
926
- | Float64Array
927
- | Int32Array
928
- | Uint32Array
929
- | Int16Array
930
- | Uint16Array
931
- | Int8Array
932
- | Uint8Array {
933
- const fieldCols = this.table(arch).storage.get(componentId(component))?.fields;
934
- if (fixedLength !== undefined) {
935
- // Fixed `array<T,N>` (feat-20260602): the elements live INLINE in the
936
- // stride-N column. Reinterpret the row's byte window directly — no
937
- // BufferPool indirection (`slotId` is unused for fixed arrays).
938
- const col = fieldCols?.get(fieldName);
939
- /* istanbul ignore next -- caller validated the column exists */
940
- if (col === undefined) return reinterpretSlotBytes(new Uint8Array(0), elementType, 0);
941
- const elementBytes = elementByteSize(elementType);
942
- const arity = col.arity;
943
- const rowByteOffset = col.view.byteOffset + row * arity * elementBytes;
944
- return reinterpretBufferRegion(col.view.buffer, rowByteOffset, elementType, fixedLength);
945
- }
946
- const liveBytes = this.bufferPool.view(slotId);
947
- const countCol = fieldCols?.get(arrayCountColumnName(fieldName));
948
- const elementCount = (countCol?.view[row] as number | undefined) ?? 0;
949
- return reinterpretSlotBytes(liveBytes, elementType, elementCount);
950
- }
951
-
952
- // ──────────────────────────────────────────────────────────────────────────
953
- // Internal — archetype migration
954
- // ──────────────────────────────────────────────────────────────────────────
955
-
956
- /**
957
- * Migrate an entity from srcArch to targetArch.
958
- * Copies all shared component data, then removes from src via swap-pop.
959
- *
960
- * AC-04 carry-over contract (M4): for every component that survives the
961
- * migration (i.e. the target archetype carries the same `compId`), every
962
- * field column's u32 value is copied verbatim from src to target. This
963
- * preserves every managed resource handle in place:
964
- *
965
- * - `ref<T>` field - the u32 (managed handle = (slot << 8) | gen) is
966
- * bit-equal across migrate, so `Object.is` on the
967
- * handle holds and `UniqueRefStore.resolve` returns
968
- * the same payload object reference (per-(slot, gen)
969
- * wrapper singleton, D-3).
970
- * - `buffer<N>` field - fixed-capacity inline stride-N `u8` column
971
- * (feat-20260602): the N bytes are copied verbatim
972
- * by the generic per-column row copy, so the live
973
- * bytes survive byte-for-byte. No BufferPool slot
974
- * (only variable `buffer` carries a pool slot id).
975
- * - `entity` field - the u32 (encoded slot+gen) is bit-equal across
976
- * migrate.
977
- * - `array<T,N>` field - fixed-capacity inline stride-N column
978
- * (feat-20260602): the N elements live contiguously
979
- * in the column row and are copied verbatim by the
980
- * generic per-column row copy, so they survive
981
- * byte-for-byte (no BufferPool slot). The TypedArray
982
- * snapshot is rematerialised on every `world.get`
983
- * (D-4 no cache); we do NOT guarantee `Object.is` on
984
- * the wrapper — only the underlying bytes (D-R7 weak
985
- * carry-over).
986
- * - `array<T>` field - dual u32 columns: primary slot id + sidecar
987
- * `<fieldName>:count`. Both columns are copied via
988
- * the generic per-column loop below, so count and
989
- * slot id stay in lock-step. Capacity is derived
990
- * from `BufferPool.view(slotId).byteLength /
991
- * elementBytes` and is therefore preserved by the
992
- * slot-id carry-over alone (no separate column).
993
- *
994
- * Negative invariant: this routine MUST NOT call UniqueRefStore.release /
995
- * BufferPool.release for surviving components. The pre-migrate release
996
- * loop lives at `removeComponent` (only for the component being removed)
997
- * and `despawn` (every component); migrate is only a column copy. The
998
- * array-field release-loop split (D-3 / D-5) further guarantees that
999
- * spawn into a vacated row treats stale slot-id debris as no-op (the slot
1000
- * is owned by the migrated entity in the new archetype) — see
1001
- * `writeArrayField`'s `operation: 'spawn' | 'set'` discriminant.
1002
- * Tests: `__tests__/managed-carry-over.test.ts` (w16),
1003
- * `__tests__/managed-array-carry-over.test.ts` (w8).
1004
- */
1005
- migrateEntity(record: EntityRecord, srcArch: Archetype, targetArch: Archetype): void {
1006
- const oldArchetypeRow = record.archetypeRow;
1007
- const oldTableRow = srcArch.rows[oldArchetypeRow] ?? 0;
1008
- const srcTable = this.table(srcArch);
1009
- const targetTable = this.table(targetArch);
1010
- const entity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')?.view[
1011
- oldTableRow
1012
- ] ?? 0) as EntityHandle;
1013
- const newTableRow = appendTableRow(targetTable, entity);
1014
- const newArchetypeRow = appendArchetypeRow(targetArch, newTableRow);
1015
-
1016
- // Copy shared component data.
1017
- for (const [compId, srcComponentStorage] of srcTable.storage) {
1018
- const srcFieldCols = srcComponentStorage.fields;
1019
- const targetComponentStorage = targetTable.storage.get(compId);
1020
- const targetFieldCols = targetComponentStorage?.fields;
1021
- if (!targetFieldCols) {
1022
- continue; // Component was removed — skip.
1023
- }
1024
- for (const [fieldName, srcCol] of srcFieldCols) {
1025
- const targetCol = targetFieldCols.get(fieldName);
1026
- if (!targetCol) {
1027
- continue;
1028
- }
1029
- // Copy the whole stride-N block per row. Scalar / variable / `:count`
1030
- // columns have arity 1 (single-element copy, byte-identical to the
1031
- // prior `view[newRow] = view[oldRow]` form); fixed inline
1032
- // `array<T,N>` / `buffer<N>` columns carry their N elements inline and
1033
- // must migrate the entire block (feat-20260602).
1034
- const arity = srcCol.arity;
1035
- targetCol.view.set(
1036
- srcCol.view.subarray(oldTableRow * arity, oldTableRow * arity + arity),
1037
- newTableRow * arity,
1038
- );
1039
- }
1040
- if (targetComponentStorage !== undefined) {
1041
- copyComponentEpoch(
1042
- srcComponentStorage.epochs,
1043
- oldTableRow,
1044
- targetComponentStorage.epochs,
1045
- newTableRow,
1046
- );
1047
- }
1048
- }
1049
-
1050
- const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
1051
- if (archetypeSwap !== null) {
1052
- const movedEntity = (srcTable.storage.get(componentId(EntityComponent))?.fields.get('self')
1053
- ?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
1054
- const movedRecord = this.records[entityIndex(movedEntity)];
1055
- if (movedRecord?.generation === entityGeneration(movedEntity)) {
1056
- movedRecord.archetypeRow = archetypeSwap.newRow;
1057
- }
1058
- }
1059
- const tableSwap = removeTableRow(srcTable, oldTableRow);
1060
- if (tableSwap !== null) {
1061
- const movedRecord = this.records[entityIndex(tableSwap.movedEntity)];
1062
- if (movedRecord?.generation === entityGeneration(tableSwap.movedEntity)) {
1063
- const movedArchetype = this.state.graph.archetypes[movedRecord.archetypeId];
1064
- if (movedArchetype !== undefined) {
1065
- movedArchetype.rows[movedRecord.archetypeRow] = tableSwap.newRow;
1066
- }
1067
- }
1068
- }
1069
-
1070
- record.archetypeId = targetArch.id;
1071
- record.archetypeRow = newArchetypeRow;
1072
- }
1073
-
1074
- moveEntityArchetype(record: EntityRecord, srcArch: Archetype, targetArch: Archetype): void {
1075
- const table = this.table(srcArch);
1076
- if (srcArch.tableId !== targetArch.tableId) {
1077
- throw new Error('Logical archetype move requires a shared Table.');
1078
- }
1079
- const oldArchetypeRow = record.archetypeRow;
1080
- const tableRow = srcArch.rows[oldArchetypeRow] ?? 0;
1081
- const archetypeSwap = removeArchetypeRow(srcArch, oldArchetypeRow);
1082
- if (archetypeSwap !== null) {
1083
- const movedEntity = (table.storage.get(componentId(EntityComponent))?.fields.get('self')
1084
- ?.view[archetypeSwap.movedTableRow] ?? 0) as EntityHandle;
1085
- const movedRecord = this.records[entityIndex(movedEntity)];
1086
- if (movedRecord?.generation === entityGeneration(movedEntity)) {
1087
- movedRecord.archetypeRow = archetypeSwap.newRow;
1088
- }
1089
- }
1090
- record.archetypeId = targetArch.id;
1091
- record.archetypeRow = appendArchetypeRow(targetArch, tableRow);
1092
- }
1093
- }
1094
-
1095
- /**
1096
- * Reinterpret a `BufferPool` slot's `Uint8Array` byte region as the typed
1097
- * view for `elementType`, sliced to `elementCount` elements. This is the
1098
- * internal materialization used by `readRow`/`materializeArrayView`: the
1099
- * returned view aliases live slot bytes. `WorldComponentAccess.get` detaches
1100
- * relationship-target `array<entity>` values at the public boundary; other
1101
- * public array fields retain the existing transient live-view contract.
1102
- * `_getArrayView` and other internal owners always retain zero-copy access.
1103
- * `entity` element fields surface as `Uint32Array` (Entity packs slot+gen
1104
- * into u32).
1105
- */
1106
- /**
1107
- * Element-byte-width for a managed-array element type, read off the global
1108
- * TYPE_METADATA table (single SSOT). `entity` stores as `u32` (4 bytes); every
1109
- * scalar maps to its own key. `fieldTypeToMetaKey` always resolves a key for a
1110
- * ManagedArrayElementType, and every such row carries a concrete `byteSize`.
1111
- */
1112
- function elementByteSize(elementType: ManagedArrayElementType): number {
1113
- const key = fieldTypeToMetaKey(elementType);
1114
- // biome-ignore lint/style/noNonNullAssertion: every ManagedArrayElementType resolves to a row with a concrete byteSize
1115
- return TYPE_METADATA[key!]!.byteSize!;
1116
- }
1117
-
1118
- function reinterpretSlotBytes(
1119
- bytes: Uint8Array,
1120
- elementType: ManagedArrayElementType,
1121
- elementCount: number,
1122
- ): ReturnType<typeof reinterpretBufferRegion> {
1123
- return reinterpretBufferRegion(bytes.buffer, bytes.byteOffset, elementType, elementCount);
1124
- }
1125
-
1126
- function reinterpretBufferRegion(
1127
- buffer: ArrayBufferLike,
1128
- byteOffset: number,
1129
- elementType: ManagedArrayElementType,
1130
- elementCount: number,
1131
- ):
1132
- | Float32Array
1133
- | Float64Array
1134
- | Int32Array
1135
- | Uint32Array
1136
- | Int16Array
1137
- | Uint16Array
1138
- | Int8Array
1139
- | Uint8Array {
1140
- // shared<X> template literals: column-stored as u32 handles, reinterpret
1141
- // as Uint32Array. The brand is applied at the FieldValueType level;
1142
- // runtime storage is plain u32 (feat-20260614 M5; replaces the retired
1143
- // 'handle<X>' arm).
1144
- if (elementType.startsWith('shared<')) {
1145
- return new Uint32Array(buffer, byteOffset, elementCount);
1146
- }
1147
- switch (elementType) {
1148
- case 'f32':
1149
- return new Float32Array(buffer, byteOffset, elementCount);
1150
- case 'f64':
1151
- return new Float64Array(buffer, byteOffset, elementCount);
1152
- case 'i32':
1153
- return new Int32Array(buffer, byteOffset, elementCount);
1154
- case 'u32':
1155
- case 'enum':
1156
- case 'ref':
1157
- case 'entity':
1158
- return new Uint32Array(buffer, byteOffset, elementCount);
1159
- case 'i16':
1160
- return new Int16Array(buffer, byteOffset, elementCount);
1161
- case 'u16':
1162
- return new Uint16Array(buffer, byteOffset, elementCount);
1163
- case 'i8':
1164
- return new Int8Array(buffer, byteOffset, elementCount);
1165
- case 'u8':
1166
- case 'bool':
1167
- return new Uint8Array(buffer, byteOffset, elementCount);
1168
- }
1169
- // Exhaustiveness fallthrough: TypeScript template-literal type
1170
- // (`shared<${string}>`) is structurally not narrowed away by the
1171
- // `startsWith` guard above, so this branch is unreachable yet TS still
1172
- // requires a return path.
1173
- return new Uint32Array(buffer, byteOffset, elementCount);
1174
- }
1175
-
1176
- /**
1177
- * Reinterpret `bytes` as the typed view for `elementType` and write `value`
1178
- * at element index `idx`. Mirrors the `array<T>` storage law (4/8/2/1-byte
1179
- * element widths per TYPE_METADATA.byteSize) and is used by `world.push`
1180
- * to land a new tail element into BufferPool slot bytes after a `grow`.
1181
- */
1182
- function writeArrayElementAt(
1183
- bytes: Uint8Array,
1184
- idx: number,
1185
- elementType: ManagedArrayElementType,
1186
- value: number,
1187
- ): void {
1188
- const buf = bytes.buffer;
1189
- const offset = bytes.byteOffset;
1190
- const byteLen = bytes.byteLength;
1191
- switch (elementType) {
1192
- case 'f32':
1193
- new Float32Array(buf, offset, byteLen >>> 2)[idx] = value;
1194
- return;
1195
- case 'f64':
1196
- new Float64Array(buf, offset, byteLen >>> 3)[idx] = value;
1197
- return;
1198
- case 'i32':
1199
- new Int32Array(buf, offset, byteLen >>> 2)[idx] = value;
1200
- return;
1201
- case 'u32':
1202
- case 'enum':
1203
- case 'ref':
1204
- case 'entity':
1205
- new Uint32Array(buf, offset, byteLen >>> 2)[idx] = value;
1206
- return;
1207
- case 'i16':
1208
- new Int16Array(buf, offset, byteLen >>> 1)[idx] = value;
1209
- return;
1210
- case 'u16':
1211
- new Uint16Array(buf, offset, byteLen >>> 1)[idx] = value;
1212
- return;
1213
- case 'i8':
1214
- new Int8Array(buf, offset, byteLen)[idx] = value;
1215
- return;
1216
- case 'u8':
1217
- case 'bool':
1218
- new Uint8Array(buf, offset, byteLen)[idx] = value;
1219
- return;
1220
- }
1221
- }
1222
-
1223
- /**
1224
- * Reinterpret `bytes` as the typed view for `elementType` and read element
1225
- * `idx`. Mirrors `writeArrayElementAt` -- consumed by `world.pop` to materialise
1226
- * the tail value before the count is decremented.
1227
- */
1228
- function readArrayElementAt(
1229
- bytes: Uint8Array,
1230
- idx: number,
1231
- elementType: ManagedArrayElementType,
1232
- ): number {
1233
- const buf = bytes.buffer;
1234
- const offset = bytes.byteOffset;
1235
- const byteLen = bytes.byteLength;
1236
- // shared<X> template literals: column-stored as u32 handles, read as
1237
- // Uint32Array. (feat-20260614 M5; replaces retired 'handle<X>' arm.)
1238
- if (elementType.startsWith('shared<')) {
1239
- return new Uint32Array(buf, offset, byteLen >>> 2)[idx] ?? 0;
1240
- }
1241
- switch (elementType) {
1242
- case 'f32':
1243
- return new Float32Array(buf, offset, byteLen >>> 2)[idx] ?? 0;
1244
- case 'f64':
1245
- return new Float64Array(buf, offset, byteLen >>> 3)[idx] ?? 0;
1246
- case 'i32':
1247
- return new Int32Array(buf, offset, byteLen >>> 2)[idx] ?? 0;
1248
- case 'u32':
1249
- case 'enum':
1250
- case 'ref':
1251
- case 'entity':
1252
- return new Uint32Array(buf, offset, byteLen >>> 2)[idx] ?? 0;
1253
- case 'i16':
1254
- return new Int16Array(buf, offset, byteLen >>> 1)[idx] ?? 0;
1255
- case 'u16':
1256
- return new Uint16Array(buf, offset, byteLen >>> 1)[idx] ?? 0;
1257
- case 'i8':
1258
- return new Int8Array(buf, offset, byteLen)[idx] ?? 0;
1259
- case 'u8':
1260
- case 'bool':
1261
- return new Uint8Array(buf, offset, byteLen)[idx] ?? 0;
1262
- }
1263
- return 0;
1264
- }