@forgeax/engine-ecs 0.1.23 → 0.1.25
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 +42 -1
- package/dist/buffer-pool.d.ts +4 -5
- package/dist/buffer-pool.d.ts.map +1 -1
- package/dist/component.d.ts +9 -6
- package/dist/component.d.ts.map +1 -1
- package/dist/errors/query-and-component-errors.d.ts +1 -1
- package/dist/errors/query-and-component-errors.d.ts.map +1 -1
- package/dist/externalization/index.mjs.map +1 -1
- package/dist/index.mjs +597 -152
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +3 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.mjs +6 -1
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.mjs.map +1 -1
- package/dist/query/derived-range-writer.d.ts +14 -0
- package/dist/query/derived-range-writer.d.ts.map +1 -1
- package/dist/query/query.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/world-component-access.d.ts +63 -18
- package/dist/world-component-access.d.ts.map +1 -1
- package/dist/world-component-storage.d.ts +26 -10
- package/dist/world-component-storage.d.ts.map +1 -1
- package/dist/world-entity-lifecycle.d.ts.map +1 -1
- package/dist/world-internal.d.ts +1 -1
- package/dist/world-internal.d.ts.map +1 -1
- package/dist/world.d.ts +5 -0
- package/dist/world.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/archetype.unit.test.ts +38 -10
- package/src/__tests__/relationship-index.test.ts +147 -1
- package/src/buffer-pool.ts +47 -74
- package/src/component.ts +9 -6
- package/src/errors/query-and-component-errors.ts +4 -1
- package/src/internal.ts +3 -0
- package/src/query/derived-range-writer.ts +111 -0
- package/src/query/query.ts +26 -1
- package/src/world-component-access.ts +427 -127
- package/src/world-component-storage.ts +104 -18
- package/src/world-entity-lifecycle.ts +49 -9
- package/src/world-internal.ts +5 -0
- package/src/world.ts +52 -3
package/src/buffer-pool.ts
CHANGED
|
@@ -1,45 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// free-lists (radix 4) and uses ArrayBuffer.transfer for cross-bucket
|
|
6
|
-
// growth. The integer `id` returned by `alloc` is the runtime stand-in for
|
|
7
|
-
// the buffer slot; archetype columns store the `id` as u32 (M4 carry-over
|
|
8
|
-
// reuses this id across archetype migrate without copying bytes).
|
|
9
|
-
//
|
|
10
|
-
// §contract — managed handles are operational, not persistent
|
|
11
|
-
// Spec: docs/specs/2026-06-14-ecs-managed-lifecycle-ssot-design.md §3.3.
|
|
12
|
-
// The slot id never escapes ECS internals — there is no public Handle<Buffer>
|
|
13
|
-
// surface today. There is no generation tag on BufferPool; `release` is
|
|
14
|
-
// typed `Result<void, never>` (no stale-slot error arm) and the dual
|
|
15
|
-
// guards live at packages/ecs/src/__tests__/buffer-pool.test-d.ts (compile
|
|
16
|
-
// time) + packages/ecs/README.md §"Managed handles are operational, not
|
|
17
|
-
// persistent" (AI-user-facing). Should a future feat introduce a public
|
|
18
|
-
// Handle<Buffer> surface, this design (no gen tag) MUST be re-debated —
|
|
19
|
-
// the silent-resolve contract that UniqueRefStore tolerates relies on
|
|
20
|
-
// the holder being a single ECS field, not a free-floating cache; a
|
|
21
|
-
// public Handle<Buffer> changes that calculus.
|
|
22
|
-
//
|
|
23
|
-
// Design contract (frozen by plan-decisions):
|
|
24
|
-
// D-5 size-class 8 buckets (radix 4):
|
|
25
|
-
// 16 / 64 / 256 / 1K / 4K / 16K / 64K / 256K bytes.
|
|
26
|
-
// Allocation rounds up to the smallest bucket >= byteLength; alloc(0)
|
|
27
|
-
// is legal and returns a zero-length view (no bucket touched).
|
|
28
|
-
// Requests > 256K surface 'managed-buffer-out-of-bounds'.
|
|
29
|
-
// D-6 grow(id, newBytes) returns Result<Uint8Array, EcsError> - never
|
|
30
|
-
// throws, never mutates the prior view in place. Cross-bucket growth
|
|
31
|
-
// detaches the old ArrayBuffer (ES2024 transfer when available, copy
|
|
32
|
-
// fallback otherwise) and installs a fresh view backed by the new
|
|
33
|
-
// bucket's ArrayBuffer; same-bucket growth re-slices the existing
|
|
34
|
-
// backing buffer to the new byteLength.
|
|
35
|
-
// D-7 v1 forbids shrink (newBytes < current -> err); newBytes == current
|
|
36
|
-
// is a legal no-op that returns the same view. Bucket free-lists are
|
|
37
|
-
// NEVER trimmed - released slots stay parked on their bucket forever
|
|
38
|
-
// in v1 (memory bloat is acceptable until M5/M6 telemetry).
|
|
39
|
-
//
|
|
40
|
-
// The pool is a `class` rather than a frozen module-level singleton because
|
|
41
|
-
// World owns one BufferPool per instance; M2 wires it into the release loop
|
|
42
|
-
// alongside UniqueRefStore (D-2 - per-World lifecycle).
|
|
1
|
+
// World-owned managed array storage. Small fields use eight radix-4 free
|
|
2
|
+
// lists; larger fields allocate dedicated buffers and drop them on release.
|
|
3
|
+
// Pooling policy never limits a field to the largest pooled class. Slot IDs
|
|
4
|
+
// remain private to ECS and survive archetype migration and growth.
|
|
43
5
|
|
|
44
6
|
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
45
7
|
import { ManagedBufferOutOfBoundsError, ManagedBufferShrinkNotSupportedError } from './errors';
|
|
@@ -50,8 +12,7 @@ import { ManagedBufferOutOfBoundsError, ManagedBufferShrinkNotSupportedError } f
|
|
|
50
12
|
*
|
|
51
13
|
* `alloc(byteLength)` rounds up to `SIZE_CLASSES[i]` for the smallest `i`
|
|
52
14
|
* with `byteLength <= SIZE_CLASSES[i]`. byteLength === 0 is the special path
|
|
53
|
-
* (no bucket);
|
|
54
|
-
* `managed-buffer-out-of-bounds`.
|
|
15
|
+
* (no bucket); larger requests use dedicated, unpooled allocations.
|
|
55
16
|
*/
|
|
56
17
|
export const SIZE_CLASSES: readonly number[] = Object.freeze([
|
|
57
18
|
16, 64, 256, 1024, 4096, 16384, 65536, 262144,
|
|
@@ -77,7 +38,7 @@ const HAS_TRANSFER: boolean =
|
|
|
77
38
|
typeof (ArrayBuffer.prototype as { transfer?: unknown }).transfer === 'function';
|
|
78
39
|
|
|
79
40
|
interface SlotState {
|
|
80
|
-
/** Bucket index
|
|
41
|
+
/** Bucket index, SIZE_CLASSES.length for dedicated storage, or -1 for zero length. */
|
|
81
42
|
sizeClassIdx: number;
|
|
82
43
|
/** Underlying ArrayBuffer for the slot's current bucket (zero-length for sizeClassIdx === -1). */
|
|
83
44
|
buffer: ArrayBuffer;
|
|
@@ -92,8 +53,7 @@ interface SlotState {
|
|
|
92
53
|
/**
|
|
93
54
|
* Round `byteLength` up to a bucket index. Returns -1 for the legal zero
|
|
94
55
|
* path; returns `SIZE_CLASSES.length` (out-of-range) for byteLength larger
|
|
95
|
-
* than the top bucket so the caller can
|
|
96
|
-
* `managed-buffer-out-of-bounds` error.
|
|
56
|
+
* than the top bucket so the caller can use a dedicated allocation.
|
|
97
57
|
*/
|
|
98
58
|
function bucketIndex(byteLength: number): number {
|
|
99
59
|
if (byteLength === 0) return -1;
|
|
@@ -130,10 +90,10 @@ export class BufferPool {
|
|
|
130
90
|
* Allocate a managed buffer slot of at least `byteLength` bytes.
|
|
131
91
|
*
|
|
132
92
|
* Routes:
|
|
133
|
-
* - byteLength
|
|
93
|
+
* - invalid byteLength -> structured out-of-bounds error.
|
|
134
94
|
* - byteLength == 0 -> ok({ id, view: zero-length Uint8Array }) (no bucket).
|
|
135
95
|
* - byteLength <= 262144 -> ok({ id, view }), bucket = smallest >= byteLength.
|
|
136
|
-
* - byteLength
|
|
96
|
+
* - byteLength > 262144 -> a dedicated allocation; allocation failure is structured.
|
|
137
97
|
*
|
|
138
98
|
* D-5: size classes are radix-4 (16 / 64 / 256 / 1K / 4K / 16K / 64K / 256K).
|
|
139
99
|
* Free-list pop reuses the most recently released slot id at the same bucket;
|
|
@@ -147,11 +107,22 @@ export class BufferPool {
|
|
|
147
107
|
this.slots.set(id, { sizeClassIdx: -1, buffer, view, byteLength: 0, live: true });
|
|
148
108
|
return ok({ id, view });
|
|
149
109
|
}
|
|
110
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
|
|
111
|
+
return err(new ManagedBufferOutOfBoundsError(byteLength, 0));
|
|
112
|
+
}
|
|
150
113
|
const idx = bucketIndex(byteLength);
|
|
151
114
|
if (idx === SIZE_CLASSES.length) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
115
|
+
// Large fields are dedicated allocations, not permanently retained
|
|
116
|
+
// pool buckets. Small fields keep exactly the existing allocation cost.
|
|
117
|
+
try {
|
|
118
|
+
const buffer = new ArrayBuffer(byteLength);
|
|
119
|
+
const view = new Uint8Array(buffer);
|
|
120
|
+
const id = this.nextId++;
|
|
121
|
+
this.slots.set(id, { sizeClassIdx: idx, buffer, view, byteLength, live: true });
|
|
122
|
+
return ok({ id, view });
|
|
123
|
+
} catch {
|
|
124
|
+
return err(new ManagedBufferOutOfBoundsError(byteLength, 0));
|
|
125
|
+
}
|
|
155
126
|
}
|
|
156
127
|
const bucketBytes = SIZE_CLASSES[idx];
|
|
157
128
|
/* istanbul ignore next -- bucketIndex returns < SIZE_CLASSES.length here */
|
|
@@ -204,7 +175,7 @@ export class BufferPool {
|
|
|
204
175
|
* the caller become detached / orphaned - callers must use `pool.view(id)`
|
|
205
176
|
* after grow to read the refreshed view (the `release` loop refreshes
|
|
206
177
|
* automatically).
|
|
207
|
-
* - newBytes
|
|
178
|
+
* - newBytes beyond the last pooled class -> dedicated allocation.
|
|
208
179
|
*/
|
|
209
180
|
grow(
|
|
210
181
|
id: number,
|
|
@@ -222,13 +193,11 @@ export class BufferPool {
|
|
|
222
193
|
if (newBytes === slot.byteLength) {
|
|
223
194
|
return ok(slot.view);
|
|
224
195
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return err(
|
|
228
|
-
new ManagedBufferOutOfBoundsError(newBytes, SIZE_CLASSES[SIZE_CLASSES.length - 1] ?? 0),
|
|
229
|
-
);
|
|
196
|
+
if (!Number.isSafeInteger(newBytes) || newBytes < 0) {
|
|
197
|
+
return err(new ManagedBufferOutOfBoundsError(newBytes, slot.buffer.byteLength));
|
|
230
198
|
}
|
|
231
|
-
|
|
199
|
+
const newIdx = bucketIndex(newBytes);
|
|
200
|
+
if (newBytes <= slot.buffer.byteLength) {
|
|
232
201
|
// Same bucket: re-slice the existing backing ArrayBuffer to the new
|
|
233
202
|
// logical length. No transfer; old view stays valid until next grow.
|
|
234
203
|
slot.byteLength = newBytes;
|
|
@@ -236,19 +205,23 @@ export class BufferPool {
|
|
|
236
205
|
return ok(slot.view);
|
|
237
206
|
}
|
|
238
207
|
// Cross-bucket: detach old buffer + carry old bytes into the new bucket.
|
|
239
|
-
const newBucketBytes = SIZE_CLASSES[newIdx]
|
|
208
|
+
const newBucketBytes = SIZE_CLASSES[newIdx] ?? Math.max(newBytes, slot.buffer.byteLength * 2);
|
|
240
209
|
const oldByteLength = slot.byteLength;
|
|
241
210
|
let nextBuffer: ArrayBuffer;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
211
|
+
try {
|
|
212
|
+
if (HAS_TRANSFER) {
|
|
213
|
+
// ES2024 transfer: copy contents into a fresh ArrayBuffer of the new
|
|
214
|
+
// bucket size and detach the source. The transferred buffer keeps the
|
|
215
|
+
// old prefix bytes intact.
|
|
216
|
+
nextBuffer = (
|
|
217
|
+
slot.buffer as unknown as { transfer(newByteLength: number): ArrayBuffer }
|
|
218
|
+
).transfer(newBucketBytes);
|
|
219
|
+
} /* istanbul ignore next -- fallback only on runtimes without ES2024 transfer() */ else {
|
|
220
|
+
nextBuffer = new ArrayBuffer(newBucketBytes);
|
|
221
|
+
new Uint8Array(nextBuffer).set(new Uint8Array(slot.buffer, 0, oldByteLength));
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
return err(new ManagedBufferOutOfBoundsError(newBytes, slot.buffer.byteLength));
|
|
252
225
|
}
|
|
253
226
|
slot.sizeClassIdx = newIdx;
|
|
254
227
|
slot.buffer = nextBuffer;
|
|
@@ -268,11 +241,11 @@ export class BufferPool {
|
|
|
268
241
|
if (slot === undefined) return ok(undefined);
|
|
269
242
|
if (!slot.live) return ok(undefined);
|
|
270
243
|
slot.live = false;
|
|
271
|
-
if (slot.sizeClassIdx >= 0) {
|
|
244
|
+
if (slot.sizeClassIdx >= 0 && slot.sizeClassIdx < SIZE_CLASSES.length) {
|
|
272
245
|
const bucket = this.freeBuckets[slot.sizeClassIdx];
|
|
273
246
|
if (bucket !== undefined) bucket.push(id);
|
|
274
247
|
} else {
|
|
275
|
-
// Zero-length slot:
|
|
248
|
+
// Zero-length or dedicated slot: drop the slot record entirely so
|
|
276
249
|
// the id is not retained.
|
|
277
250
|
this.slots.delete(id);
|
|
278
251
|
}
|
|
@@ -301,7 +274,7 @@ export class BufferPool {
|
|
|
301
274
|
const slot = this.slots.get(id);
|
|
302
275
|
if (slot === undefined || !slot.live) return 0;
|
|
303
276
|
if (slot.sizeClassIdx < 0) return 0;
|
|
304
|
-
return
|
|
277
|
+
return slot.buffer.byteLength;
|
|
305
278
|
}
|
|
306
279
|
|
|
307
280
|
/**
|
|
@@ -334,7 +307,7 @@ export class BufferPool {
|
|
|
334
307
|
}
|
|
335
308
|
return err(new ManagedBufferOutOfBoundsError(newByteLength, 0));
|
|
336
309
|
}
|
|
337
|
-
const bucketBytes =
|
|
310
|
+
const bucketBytes = slot.buffer.byteLength;
|
|
338
311
|
if (newByteLength > bucketBytes) {
|
|
339
312
|
return err(new ManagedBufferOutOfBoundsError(newByteLength, bucketBytes));
|
|
340
313
|
}
|
package/src/component.ts
CHANGED
|
@@ -392,12 +392,15 @@ export type ManagedArrayElementValue<T extends ManagedArrayElementType> = T exte
|
|
|
392
392
|
*
|
|
393
393
|
* The 4 buffer/array keywords (`'buffer'` / `'buffer<N>'` / `'array<T>'` /
|
|
394
394
|
* `'array<T, N>'`) all resolve directly to a concrete TypedArray (or
|
|
395
|
-
* Uint8Array for the byte-only buffer family).
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
395
|
+
* Uint8Array for the byte-only buffer family). At the public `world.get`
|
|
396
|
+
* boundary, a relationship-target `array<entity>` is a detached `Uint32Array`
|
|
397
|
+
* snapshot. Other public array fields retain their existing transient live
|
|
398
|
+
* TypedArray alias: fixed `buffer<N>` / `array<T,N>` values alias the inline
|
|
399
|
+
* column buffer (feat-20260602), while variable `buffer` / `array<T>` values
|
|
400
|
+
* alias the BufferPool slot bytes. Internal `readRow`, `_getArrayView`, and
|
|
401
|
+
* `materializeArrayView` paths always use the live zero-copy alias. Mutation
|
|
402
|
+
* flows through `world.set` / `world.push` / `world.pop`, not direct
|
|
403
|
+
* assignment to a returned TypedArray.
|
|
401
404
|
*
|
|
402
405
|
* The `'string'` arm resolves to a native JS `string` (D-R1 / AC-13): the
|
|
403
406
|
* dispatch routes the column u32 through `UniqueRefStore.resolve(handle)`
|
|
@@ -122,7 +122,10 @@ export class SpawnDataUnknownFieldError extends Error {
|
|
|
122
122
|
this.detail = { component: componentName, field: fieldName, knownFields: sortedKnown };
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export type QuerySpanUnavailableReason =
|
|
125
|
+
export type QuerySpanUnavailableReason =
|
|
126
|
+
| 'optional-data'
|
|
127
|
+
| 'sparse-component'
|
|
128
|
+
| 'relationship-component';
|
|
126
129
|
|
|
127
130
|
export class QueryDescriptorConflictError extends Error {
|
|
128
131
|
override readonly name = 'QueryDescriptorConflictError';
|
package/src/internal.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// import or an untyped cast at each consumer.
|
|
9
9
|
|
|
10
10
|
export * from './component';
|
|
11
|
+
export type { WorldInternal } from './world-internal';
|
|
12
|
+
export { worldInternal } from './world-internal';
|
|
11
13
|
|
|
12
14
|
import type { Result } from '@forgeax/engine-types';
|
|
13
15
|
import type { Component } from './component';
|
|
@@ -50,6 +52,7 @@ export function getDerivedWriter<
|
|
|
50
52
|
|
|
51
53
|
export type {
|
|
52
54
|
DerivedColumnBinding,
|
|
55
|
+
DerivedRangeCursor,
|
|
53
56
|
DerivedRangeRowCommit,
|
|
54
57
|
DerivedRangeRowProbe,
|
|
55
58
|
DerivedRangeWriter,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
2
|
import type { Component } from '../component';
|
|
3
3
|
import * as componentOwner from '../component';
|
|
4
|
+
import { componentId } from '../component';
|
|
5
|
+
import { Entity } from '../entity';
|
|
6
|
+
import type { EntityHandle } from '../entity-handle';
|
|
4
7
|
import {
|
|
5
8
|
DerivedRangeOutOfBoundsError,
|
|
6
9
|
SharedKernelFailureError,
|
|
@@ -13,11 +16,21 @@ import type { MutableColumnShape, ReadonlyColumnShape } from './query';
|
|
|
13
16
|
|
|
14
17
|
/** Package-internal whole-column storage owned by one dense query table. */
|
|
15
18
|
export interface DerivedColumnBinding<R extends Component, C extends Component> {
|
|
19
|
+
/** Physical table identity for package-internal projection consumers. */
|
|
20
|
+
readonly tableId: number;
|
|
21
|
+
/** Whole entity column; every dense table carries the essential Entity row. */
|
|
22
|
+
readonly entities: Readonly<Uint32Array>;
|
|
16
23
|
readonly read: ReadonlyColumnShape<R>;
|
|
17
24
|
readonly write: MutableColumnShape<C>;
|
|
18
25
|
readonly rowCapacity: number;
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
/** Reusable identity cursor for O(1) dense-table lookup without a row facade. */
|
|
29
|
+
export interface DerivedRangeCursor {
|
|
30
|
+
bindingIndex: number;
|
|
31
|
+
row: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
export type DerivedRangeKernel<R extends Component, C extends Component> = (
|
|
22
35
|
binding: DerivedColumnBinding<R, C>,
|
|
23
36
|
base: number,
|
|
@@ -97,6 +110,10 @@ function countAllocation(name: keyof DerivedRangeAllocationTrace): void {
|
|
|
97
110
|
|
|
98
111
|
export interface DerivedRangeWriter<R extends Component, C extends Component> {
|
|
99
112
|
readonly bindings: readonly DerivedColumnBinding<R, C>[];
|
|
113
|
+
/** Locate a live entity in its bound table and return its physical row. */
|
|
114
|
+
locateEntity(entity: EntityHandle, cursor: DerivedRangeCursor): boolean;
|
|
115
|
+
/** Publish rows whose values were written directly into a derived column. */
|
|
116
|
+
publishChangedRows(bindingIndex: number, changed: Uint8Array): Result<void, EcsError>;
|
|
100
117
|
writeRange(
|
|
101
118
|
bindingIndex: number,
|
|
102
119
|
base: number,
|
|
@@ -130,6 +147,7 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
|
|
|
130
147
|
let boundEpoch = -1;
|
|
131
148
|
let bindingTables: readonly Table[] = [];
|
|
132
149
|
let bindings: readonly DerivedColumnBinding<R, C>[] = [];
|
|
150
|
+
let bindingByTable = new Map<number, number>();
|
|
133
151
|
let runStartBuffers: readonly Int32Array[] = [];
|
|
134
152
|
let runCountBuffers: readonly Int32Array[] = [];
|
|
135
153
|
|
|
@@ -141,6 +159,9 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
|
|
|
141
159
|
const nextRunCountBuffers: Int32Array[] = [];
|
|
142
160
|
for (const table of tables) {
|
|
143
161
|
nextBindings.push({
|
|
162
|
+
tableId: table.id,
|
|
163
|
+
entities: (table.storage.get(componentId(Entity))?.fields.get('self')?.view ??
|
|
164
|
+
new Uint32Array(0)) as Uint32Array,
|
|
144
165
|
read: buildWholeColumnShape(table, source.readComponents) as ReadonlyColumnShape<R>,
|
|
145
166
|
write: buildWholeColumnShape(table, source.writeComponents) as MutableColumnShape<C>,
|
|
146
167
|
rowCapacity: table.size,
|
|
@@ -153,6 +174,11 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
|
|
|
153
174
|
}
|
|
154
175
|
bindingTables = tables;
|
|
155
176
|
bindings = nextBindings;
|
|
177
|
+
bindingByTable = new Map<number, number>();
|
|
178
|
+
for (let index = 0; index < nextBindings.length; index += 1) {
|
|
179
|
+
const binding = nextBindings[index];
|
|
180
|
+
if (binding !== undefined) bindingByTable.set(binding.tableId, index);
|
|
181
|
+
}
|
|
156
182
|
runStartBuffers = nextRunStartBuffers;
|
|
157
183
|
runCountBuffers = nextRunCountBuffers;
|
|
158
184
|
boundEpoch = source.structureEpoch();
|
|
@@ -165,6 +191,91 @@ export function createDerivedRangeWriter<R extends Component, C extends Componen
|
|
|
165
191
|
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
166
192
|
return bindings;
|
|
167
193
|
},
|
|
194
|
+
locateEntity(entity, cursor) {
|
|
195
|
+
if (world.execution.health === 'poisoned') return false;
|
|
196
|
+
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
197
|
+
const archetype = world[worldInternal].getEntityArchetype(entity);
|
|
198
|
+
if (archetype === undefined) return false;
|
|
199
|
+
const bindingIndex = bindingByTable.get(archetype.tableId);
|
|
200
|
+
if (bindingIndex === undefined) return false;
|
|
201
|
+
const record = world[worldInternal].getRecords()[(entity as unknown as number) & 0x00ffffff];
|
|
202
|
+
if (record === undefined || record.archetypeId !== archetype.id) return false;
|
|
203
|
+
const row = archetype.rows[record.archetypeRow];
|
|
204
|
+
const binding = bindings[bindingIndex];
|
|
205
|
+
if (binding === undefined || row === undefined || row < 0 || row >= binding.rowCapacity) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
cursor.bindingIndex = bindingIndex;
|
|
209
|
+
cursor.row = row;
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
212
|
+
publishChangedRows(bindingIndex, changed) {
|
|
213
|
+
if (world.execution.health === 'poisoned') {
|
|
214
|
+
return err(new WorldPoisonedError(world.identity, world.execution.fault));
|
|
215
|
+
}
|
|
216
|
+
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
217
|
+
const binding = bindings[bindingIndex];
|
|
218
|
+
const table = bindingTables[bindingIndex];
|
|
219
|
+
const runStarts = runStartBuffers[bindingIndex];
|
|
220
|
+
const runCounts = runCountBuffers[bindingIndex];
|
|
221
|
+
if (
|
|
222
|
+
binding === undefined ||
|
|
223
|
+
table === undefined ||
|
|
224
|
+
runStarts === undefined ||
|
|
225
|
+
runCounts === undefined ||
|
|
226
|
+
!Number.isSafeInteger(bindingIndex) ||
|
|
227
|
+
bindingIndex < 0 ||
|
|
228
|
+
changed.length < binding.rowCapacity ||
|
|
229
|
+
table.storage.get(componentOwner.componentId(component)) === undefined
|
|
230
|
+
) {
|
|
231
|
+
return err(new DerivedRangeOutOfBoundsError(0, changed.length, binding?.rowCapacity ?? 0));
|
|
232
|
+
}
|
|
233
|
+
let runCount = 0;
|
|
234
|
+
let runStart = -1;
|
|
235
|
+
for (let row = 0; row < binding.rowCapacity; row += 1) {
|
|
236
|
+
if ((changed[row] ?? 0) !== 0) {
|
|
237
|
+
if (runStart < 0) runStart = row;
|
|
238
|
+
} else if (runStart >= 0) {
|
|
239
|
+
runStarts[runCount] = runStart;
|
|
240
|
+
runCounts[runCount] = row - runStart;
|
|
241
|
+
runCount += 1;
|
|
242
|
+
runStart = -1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (runStart >= 0) {
|
|
246
|
+
runStarts[runCount] = runStart;
|
|
247
|
+
runCounts[runCount] = binding.rowCapacity - runStart;
|
|
248
|
+
runCount += 1;
|
|
249
|
+
}
|
|
250
|
+
if (runCount === 0) return ok(undefined);
|
|
251
|
+
const previousEpoch = world[worldInternal].getMutationEpoch();
|
|
252
|
+
let epoch: number;
|
|
253
|
+
try {
|
|
254
|
+
epoch = world[worldInternal].nextMutationEpoch();
|
|
255
|
+
const componentIdentifier = componentOwner.componentId(component);
|
|
256
|
+
for (let index = 0; index < runCount; index += 1) {
|
|
257
|
+
world[worldInternal].publishDerivedRange(
|
|
258
|
+
table,
|
|
259
|
+
componentIdentifier,
|
|
260
|
+
runStarts[index] ?? 0,
|
|
261
|
+
runCounts[index] ?? 0,
|
|
262
|
+
epoch,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
changed.fill(0, 0, binding.rowCapacity);
|
|
266
|
+
return ok(undefined);
|
|
267
|
+
} catch (cause) {
|
|
268
|
+
world[worldInternal].restoreMutationEpoch(previousEpoch);
|
|
269
|
+
world[worldInternal].poisonExecution({
|
|
270
|
+
code: 'shared-kernel-failed',
|
|
271
|
+
kernelName: `derived-range:${component.name}:publish`,
|
|
272
|
+
cause,
|
|
273
|
+
partialWrite: true,
|
|
274
|
+
retryable: false,
|
|
275
|
+
});
|
|
276
|
+
return err(new SharedKernelFailureError(component.name, world.identity, cause, true));
|
|
277
|
+
}
|
|
278
|
+
},
|
|
168
279
|
writeRange(bindingIndex, base, start, count, kernel, context) {
|
|
169
280
|
if (world.execution.health === 'poisoned') {
|
|
170
281
|
return err(new WorldPoisonedError(world.identity, world.execution.fault));
|
package/src/query/query.ts
CHANGED
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
QueryIterationInvalidatedError,
|
|
19
19
|
QuerySpanUnavailableError,
|
|
20
20
|
type QuerySpanUnavailableReason,
|
|
21
|
+
RelationshipTargetReadonlyError,
|
|
21
22
|
} from '../errors';
|
|
22
23
|
import { DERIVED_WRITER } from '../internal';
|
|
24
|
+
import { isRelationshipTarget, relationshipRole } from '../relationship-index';
|
|
23
25
|
import type { Archetype, ArchetypeId } from '../storage/archetype';
|
|
24
26
|
import type { ArchetypeGraph } from '../storage/archetype-graph';
|
|
25
27
|
import { sparseTagIndex } from '../storage/change-detection';
|
|
@@ -229,7 +231,17 @@ class QueryRowFacade<
|
|
|
229
231
|
mut<C extends W[number]>(component: C): MutableRowShape<C> {
|
|
230
232
|
const current = this.get(component);
|
|
231
233
|
if (current === undefined) throw new Error(`Query row lacks ${component.name}.`);
|
|
232
|
-
|
|
234
|
+
if (isRelationshipTarget(component)) {
|
|
235
|
+
throw new RelationshipTargetReadonlyError(component.name, 'query row');
|
|
236
|
+
}
|
|
237
|
+
// Relationship sources must let the owner validate before recording
|
|
238
|
+
// authored evidence. A stale/cyclic target therefore has zero mutation
|
|
239
|
+
// side effects, while ordinary components retain the existing eager
|
|
240
|
+
// `mut()` evidence semantics.
|
|
241
|
+
const relationshipSource = relationshipRole(component)?.kind === 'source';
|
|
242
|
+
if (!relationshipSource) {
|
|
243
|
+
this.world[worldInternal].markComponentChanged(this.entity, componentId(component));
|
|
244
|
+
}
|
|
233
245
|
return new Proxy(current, {
|
|
234
246
|
set: (target, property, value) => {
|
|
235
247
|
if (typeof property !== 'string') return false;
|
|
@@ -275,6 +287,12 @@ class QuerySpanFacade<R extends readonly Component[], W extends readonly Compone
|
|
|
275
287
|
}
|
|
276
288
|
|
|
277
289
|
mut<C extends W[number]>(component: C): MutableColumnShape<C> {
|
|
290
|
+
// A span exposes live column storage. Relationship source and target
|
|
291
|
+
// columns must never obtain that raw write capability: source writes need
|
|
292
|
+
// target-side maintenance and target writes are engine-owned projections.
|
|
293
|
+
if (relationshipRole(component) !== undefined) {
|
|
294
|
+
throw new RelationshipTargetReadonlyError(component.name, 'query span');
|
|
295
|
+
}
|
|
278
296
|
this.world[worldInternal].markComponentRangeChanged(
|
|
279
297
|
this.table,
|
|
280
298
|
componentId(component),
|
|
@@ -662,6 +680,13 @@ class ExecutableQuery<
|
|
|
662
680
|
private spanUnavailableReason(): QuerySpanUnavailableReason | undefined {
|
|
663
681
|
if (this.compiled.sparseRoute) return 'sparse-component';
|
|
664
682
|
if ((this.compiled.descriptor.optional?.length ?? 0) > 0) return 'optional-data';
|
|
683
|
+
// A writable relationship source would bypass the source owner (and its
|
|
684
|
+
// materialized target/backpointer), while a target is an ECS-owned
|
|
685
|
+
// projection. Keep read-only relationship inputs usable by Scene's
|
|
686
|
+
// derived writer; only the descriptor's writable role is ineligible.
|
|
687
|
+
if ((this.compiled.descriptor.write ?? []).some((component) => relationshipRole(component))) {
|
|
688
|
+
return 'relationship-component';
|
|
689
|
+
}
|
|
665
690
|
return undefined;
|
|
666
691
|
}
|
|
667
692
|
}
|