@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.
- package/README.md +94 -35
- package/dist/__tests__/world-read.unit.test.d.ts +2 -0
- package/dist/__tests__/world-read.unit.test.d.ts.map +1 -0
- package/dist/commands.d.ts +2 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/index.mjs +3471 -4263
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +2 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.mjs +3 -333
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.mjs.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/world-entity-lifecycle.d.ts +3 -14
- package/dist/world-entity-lifecycle.d.ts.map +1 -1
- package/dist/world-internal.d.ts +65 -5
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world-read.d.ts +16 -0
- package/dist/world-read.d.ts.map +1 -0
- package/dist/world-read.mjs +8 -0
- package/dist/world-read.mjs.map +1 -0
- package/dist/world-scheduling.d.ts +0 -4
- package/dist/world-scheduling.d.ts.map +1 -1
- package/dist/world-storage-primitives.d.ts +26 -0
- package/dist/world-storage-primitives.d.ts.map +1 -0
- package/dist/world.d.ts +352 -157
- package/dist/world.d.ts.map +1 -1
- package/package.json +8 -4
- package/src/__tests__/command-buffer.test.ts +29 -3
- package/src/__tests__/hierarchy.unit.test.ts +3 -3
- package/src/__tests__/world-health.contract.test.ts +195 -2
- package/src/__tests__/world-read.unit.test.ts +30 -0
- package/src/commands.ts +22 -9
- package/src/internal.ts +5 -3
- package/src/world-entity-lifecycle.ts +23 -252
- package/src/world-internal-augmentation.d.ts +11 -0
- package/src/world-internal.ts +114 -63
- package/src/world-read.ts +38 -0
- package/src/world-scheduling.ts +0 -26
- package/src/world-storage-primitives.ts +179 -0
- package/src/world.ts +2590 -509
- package/dist/world-component-access.d.ts +0 -311
- package/dist/world-component-access.d.ts.map +0 -1
- package/dist/world-component-storage.d.ts +0 -298
- package/dist/world-component-storage.d.ts.map +0 -1
- package/dist/world-core.d.ts +0 -39
- package/dist/world-core.d.ts.map +0 -1
- package/src/world-component-access.ts +0 -1769
- package/src/world-component-storage.ts +0 -1264
- 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
|
-
}
|