@forgeax/engine-ecs 0.1.21 → 0.1.24
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 +83 -17
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts +2 -0
- package/dist/__tests__/changed-block-summary.candidate.test.d.ts.map +1 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts +2 -0
- package/dist/__tests__/component-requirements.unit.test.d.ts.map +1 -0
- package/dist/__tests__/component-version-surface.test.d.ts +2 -0
- package/dist/__tests__/component-version-surface.test.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/derived-range-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts +2 -0
- package/dist/__tests__/structural-evidence.contract.test.d.ts.map +1 -0
- package/dist/component-schema.d.ts +17 -0
- package/dist/component-schema.d.ts.map +1 -1
- package/dist/component.d.ts +16 -7
- 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/errors/validation-errors.d.ts +24 -0
- package/dist/errors/validation-errors.d.ts.map +1 -1
- package/dist/errors.d.ts +14 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/externalization/index.mjs.map +1 -1
- package/dist/index.mjs +977 -255
- package/dist/index.mjs.map +1 -1
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.mjs +20 -2
- package/dist/internal.mjs.map +1 -1
- package/dist/projection/index.d.ts +27 -52
- package/dist/projection/index.d.ts.map +1 -1
- package/dist/projection/index.mjs +66 -76
- package/dist/projection/index.mjs.map +1 -1
- package/dist/query/derived-range-writer.d.ts +62 -0
- package/dist/query/derived-range-writer.d.ts.map +1 -0
- package/dist/query/query.d.ts +1 -1
- package/dist/query/query.d.ts.map +1 -1
- package/dist/relationship-index.d.ts +8 -0
- package/dist/relationship-index.d.ts.map +1 -1
- package/dist/shared-ref-store.d.ts +5 -9
- package/dist/shared-ref-store.d.ts.map +1 -1
- package/dist/shared.mjs.map +1 -1
- package/dist/storage/change-detection.d.ts +1 -3
- package/dist/storage/change-detection.d.ts.map +1 -1
- package/dist/storage/structural-evidence.d.ts +30 -0
- package/dist/storage/structural-evidence.d.ts.map +1 -0
- package/dist/world-component-access.d.ts +65 -19
- 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-core.d.ts +2 -2
- package/dist/world-core.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 +15 -14
- package/dist/world.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/changed-block-summary.candidate.test.ts +57 -0
- package/src/__tests__/component-requirements.unit.test.ts +76 -0
- package/src/__tests__/component-version-surface.test.ts +45 -0
- package/src/__tests__/derived-range-writer.contract.test-d.ts +35 -0
- package/src/__tests__/derived-range-writer.contract.test.ts +339 -0
- package/src/__tests__/errors.unit.test.ts +21 -5
- package/src/__tests__/externalization-render-read-lease.test-d.ts +1 -1
- package/src/__tests__/externalization-render-read-lease.unit.test.ts +15 -15
- package/src/__tests__/query-storage-trends.perf.test.ts +19 -0
- package/src/__tests__/query.unit.test.ts +10 -6
- package/src/__tests__/relationship-index.test.ts +147 -1
- package/src/__tests__/shared-ref-store.unit.test.ts +3 -8
- package/src/__tests__/structural-evidence.contract.test-d.ts +6 -0
- package/src/__tests__/structural-evidence.contract.test.ts +49 -0
- package/src/component-schema.ts +51 -0
- package/src/component.ts +21 -8
- package/src/errors/query-and-component-errors.ts +4 -1
- package/src/errors/validation-errors.ts +30 -0
- package/src/errors.ts +29 -5
- package/src/internal.ts +59 -0
- package/src/projection/index.ts +68 -149
- package/src/query/derived-range-writer.ts +443 -0
- package/src/query/query.ts +115 -16
- package/src/relationship-index.ts +13 -1
- package/src/shared-ref-store.ts +16 -24
- package/src/storage/change-detection.ts +0 -15
- package/src/storage/structural-evidence.ts +64 -0
- package/src/world-component-access.ts +479 -130
- package/src/world-component-storage.ts +104 -18
- package/src/world-core.ts +2 -2
- package/src/world-entity-lifecycle.ts +59 -10
- package/src/world-internal.ts +10 -5
- package/src/world.ts +97 -53
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/world-change-journal.unit.test.d.ts +0 -2
- package/dist/__tests__/world-change-journal.unit.test.d.ts.map +0 -1
- package/dist/__tests__/world-internal-identity.unit.test.d.ts +0 -2
- package/dist/__tests__/world-internal-identity.unit.test.d.ts.map +0 -1
- package/dist/world-change-journal.d.ts +0 -34
- package/dist/world-change-journal.d.ts.map +0 -1
- package/src/__tests__/world-change-journal.unit.test.ts +0 -97
- package/src/__tests__/world-internal-identity.unit.test.ts +0 -17
- package/src/world-change-journal.ts +0 -84
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import type { Component } from '../component';
|
|
3
|
+
import * as componentOwner from '../component';
|
|
4
|
+
import { componentId } from '../component';
|
|
5
|
+
import { Entity } from '../entity';
|
|
6
|
+
import type { EntityHandle } from '../entity-handle';
|
|
7
|
+
import {
|
|
8
|
+
DerivedRangeOutOfBoundsError,
|
|
9
|
+
SharedKernelFailureError,
|
|
10
|
+
WorldPoisonedError,
|
|
11
|
+
} from '../errors';
|
|
12
|
+
import type { Table } from '../storage/table';
|
|
13
|
+
import type { EcsError, World } from '../world';
|
|
14
|
+
import { worldInternal } from '../world-internal';
|
|
15
|
+
import type { MutableColumnShape, ReadonlyColumnShape } from './query';
|
|
16
|
+
|
|
17
|
+
/** Package-internal whole-column storage owned by one dense query table. */
|
|
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>;
|
|
23
|
+
readonly read: ReadonlyColumnShape<R>;
|
|
24
|
+
readonly write: MutableColumnShape<C>;
|
|
25
|
+
readonly rowCapacity: number;
|
|
26
|
+
}
|
|
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
|
+
|
|
34
|
+
export type DerivedRangeKernel<R extends Component, C extends Component> = (
|
|
35
|
+
binding: DerivedColumnBinding<R, C>,
|
|
36
|
+
base: number,
|
|
37
|
+
start: number,
|
|
38
|
+
count: number,
|
|
39
|
+
context: unknown,
|
|
40
|
+
) => void;
|
|
41
|
+
|
|
42
|
+
/** Package-internal row probe used when the probe already has the derived value in scratch storage. */
|
|
43
|
+
export type DerivedRangeRowProbe<R extends Component, C extends Component> = (
|
|
44
|
+
binding: DerivedColumnBinding<R, C>,
|
|
45
|
+
row: number,
|
|
46
|
+
context: unknown,
|
|
47
|
+
) => boolean;
|
|
48
|
+
|
|
49
|
+
/** Package-internal commit for a value prepared by a preceding row probe. */
|
|
50
|
+
export type DerivedRangeRowCommit<R extends Component, C extends Component> = (
|
|
51
|
+
binding: DerivedColumnBinding<R, C>,
|
|
52
|
+
row: number,
|
|
53
|
+
context: unknown,
|
|
54
|
+
) => void;
|
|
55
|
+
|
|
56
|
+
interface DerivedRangeBindingSource {
|
|
57
|
+
readonly structureEpoch: () => number;
|
|
58
|
+
readonly tables: () => readonly Table[];
|
|
59
|
+
readonly readComponents: readonly Component[];
|
|
60
|
+
readonly writeComponents: readonly Component[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DerivedRangeAllocationTrace {
|
|
64
|
+
subarrayCalls: number;
|
|
65
|
+
shapeAllocations: number;
|
|
66
|
+
closureAllocations: number;
|
|
67
|
+
columnWindowAllocations: number;
|
|
68
|
+
rowFacadeAllocations: number;
|
|
69
|
+
proxyAllocations: number;
|
|
70
|
+
rangeCacheAllocations: number;
|
|
71
|
+
bindingRebuilds: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let allocationTrace: DerivedRangeAllocationTrace | undefined;
|
|
75
|
+
|
|
76
|
+
export function beginDerivedRangeAllocationTrace(): void {
|
|
77
|
+
allocationTrace = {
|
|
78
|
+
subarrayCalls: 0,
|
|
79
|
+
shapeAllocations: 0,
|
|
80
|
+
closureAllocations: 0,
|
|
81
|
+
columnWindowAllocations: 0,
|
|
82
|
+
rowFacadeAllocations: 0,
|
|
83
|
+
proxyAllocations: 0,
|
|
84
|
+
rangeCacheAllocations: 0,
|
|
85
|
+
bindingRebuilds: 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function endDerivedRangeAllocationTrace(): DerivedRangeAllocationTrace {
|
|
90
|
+
const trace = allocationTrace;
|
|
91
|
+
allocationTrace = undefined;
|
|
92
|
+
return (
|
|
93
|
+
trace ?? {
|
|
94
|
+
subarrayCalls: 0,
|
|
95
|
+
shapeAllocations: 0,
|
|
96
|
+
closureAllocations: 0,
|
|
97
|
+
columnWindowAllocations: 0,
|
|
98
|
+
rowFacadeAllocations: 0,
|
|
99
|
+
proxyAllocations: 0,
|
|
100
|
+
rangeCacheAllocations: 0,
|
|
101
|
+
bindingRebuilds: 0,
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countAllocation(name: keyof DerivedRangeAllocationTrace): void {
|
|
107
|
+
const trace = allocationTrace;
|
|
108
|
+
if (trace !== undefined) trace[name] += 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DerivedRangeWriter<R extends Component, C extends Component> {
|
|
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>;
|
|
117
|
+
writeRange(
|
|
118
|
+
bindingIndex: number,
|
|
119
|
+
base: number,
|
|
120
|
+
start: number,
|
|
121
|
+
count: number,
|
|
122
|
+
kernel: DerivedRangeKernel<R, C>,
|
|
123
|
+
context: unknown,
|
|
124
|
+
): Result<void, EcsError>;
|
|
125
|
+
/**
|
|
126
|
+
* Probe and commit rows in one pass. The probe must only inspect the derived
|
|
127
|
+
* value prepared in reusable scratch storage; the commit copies that value.
|
|
128
|
+
* The writer publishes only contiguous changed runs and does not allocate in
|
|
129
|
+
* the hot loop.
|
|
130
|
+
*/
|
|
131
|
+
probeAndCommitRange(
|
|
132
|
+
bindingIndex: number,
|
|
133
|
+
base: number,
|
|
134
|
+
start: number,
|
|
135
|
+
count: number,
|
|
136
|
+
probe: DerivedRangeRowProbe<R, C>,
|
|
137
|
+
commit: DerivedRangeRowCommit<R, C>,
|
|
138
|
+
context: unknown,
|
|
139
|
+
): Result<void, EcsError>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createDerivedRangeWriter<R extends Component, C extends Component>(
|
|
143
|
+
world: World,
|
|
144
|
+
component: C,
|
|
145
|
+
source: DerivedRangeBindingSource,
|
|
146
|
+
): DerivedRangeWriter<R, C> {
|
|
147
|
+
let boundEpoch = -1;
|
|
148
|
+
let bindingTables: readonly Table[] = [];
|
|
149
|
+
let bindings: readonly DerivedColumnBinding<R, C>[] = [];
|
|
150
|
+
let bindingByTable = new Map<number, number>();
|
|
151
|
+
let runStartBuffers: readonly Int32Array[] = [];
|
|
152
|
+
let runCountBuffers: readonly Int32Array[] = [];
|
|
153
|
+
|
|
154
|
+
const rebind = (): void => {
|
|
155
|
+
countAllocation('bindingRebuilds');
|
|
156
|
+
const tables = source.tables();
|
|
157
|
+
const nextBindings: DerivedColumnBinding<R, C>[] = [];
|
|
158
|
+
const nextRunStartBuffers: Int32Array[] = [];
|
|
159
|
+
const nextRunCountBuffers: Int32Array[] = [];
|
|
160
|
+
for (const table of tables) {
|
|
161
|
+
nextBindings.push({
|
|
162
|
+
tableId: table.id,
|
|
163
|
+
entities: (table.storage.get(componentId(Entity))?.fields.get('self')?.view ??
|
|
164
|
+
new Uint32Array(0)) as Uint32Array,
|
|
165
|
+
read: buildWholeColumnShape(table, source.readComponents) as ReadonlyColumnShape<R>,
|
|
166
|
+
write: buildWholeColumnShape(table, source.writeComponents) as MutableColumnShape<C>,
|
|
167
|
+
rowCapacity: table.size,
|
|
168
|
+
});
|
|
169
|
+
// A table with N rows can contain at most N changed runs. These buffers
|
|
170
|
+
// are owned by the structural rebind and reused by every hot call.
|
|
171
|
+
const runCapacity = Math.max(1, table.size);
|
|
172
|
+
nextRunStartBuffers.push(new Int32Array(runCapacity));
|
|
173
|
+
nextRunCountBuffers.push(new Int32Array(runCapacity));
|
|
174
|
+
}
|
|
175
|
+
bindingTables = tables;
|
|
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
|
+
}
|
|
182
|
+
runStartBuffers = nextRunStartBuffers;
|
|
183
|
+
runCountBuffers = nextRunCountBuffers;
|
|
184
|
+
boundEpoch = source.structureEpoch();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
rebind();
|
|
188
|
+
|
|
189
|
+
const writer: DerivedRangeWriter<R, C> = {
|
|
190
|
+
get bindings() {
|
|
191
|
+
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
192
|
+
return bindings;
|
|
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
|
+
},
|
|
279
|
+
writeRange(bindingIndex, base, start, count, kernel, context) {
|
|
280
|
+
if (world.execution.health === 'poisoned') {
|
|
281
|
+
return err(new WorldPoisonedError(world.identity, world.execution.fault));
|
|
282
|
+
}
|
|
283
|
+
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
284
|
+
const binding = bindings[bindingIndex];
|
|
285
|
+
const table = bindingTables[bindingIndex];
|
|
286
|
+
if (
|
|
287
|
+
binding === undefined ||
|
|
288
|
+
table === undefined ||
|
|
289
|
+
!Number.isSafeInteger(bindingIndex) ||
|
|
290
|
+
!Number.isSafeInteger(base) ||
|
|
291
|
+
!Number.isSafeInteger(start) ||
|
|
292
|
+
!Number.isSafeInteger(count) ||
|
|
293
|
+
bindingIndex < 0 ||
|
|
294
|
+
base < 0 ||
|
|
295
|
+
start < 0 ||
|
|
296
|
+
count < 0 ||
|
|
297
|
+
base + start + count > binding.rowCapacity ||
|
|
298
|
+
table.storage.get(componentOwner.componentId(component)) === undefined
|
|
299
|
+
) {
|
|
300
|
+
return err(new DerivedRangeOutOfBoundsError(start, count, binding?.rowCapacity ?? 0));
|
|
301
|
+
}
|
|
302
|
+
if (count === 0) return ok(undefined);
|
|
303
|
+
|
|
304
|
+
const previousEpoch = world[worldInternal].getMutationEpoch();
|
|
305
|
+
let epoch: number;
|
|
306
|
+
try {
|
|
307
|
+
epoch = world[worldInternal].nextMutationEpoch();
|
|
308
|
+
} catch (cause) {
|
|
309
|
+
return err(cause as EcsError);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
kernel(binding, base, start, count, context);
|
|
313
|
+
world[worldInternal].publishDerivedRange(
|
|
314
|
+
table,
|
|
315
|
+
componentOwner.componentId(component),
|
|
316
|
+
base + start,
|
|
317
|
+
count,
|
|
318
|
+
epoch,
|
|
319
|
+
);
|
|
320
|
+
return ok(undefined);
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
world[worldInternal].restoreMutationEpoch(previousEpoch);
|
|
323
|
+
world[worldInternal].poisonExecution({
|
|
324
|
+
code: 'shared-kernel-failed',
|
|
325
|
+
kernelName: `derived-range:${component.name}`,
|
|
326
|
+
cause,
|
|
327
|
+
partialWrite: true,
|
|
328
|
+
retryable: false,
|
|
329
|
+
});
|
|
330
|
+
return err(new SharedKernelFailureError(component.name, world.identity, cause, true));
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
probeAndCommitRange(bindingIndex, base, start, count, probe, commit, context) {
|
|
334
|
+
if (world.execution.health === 'poisoned') {
|
|
335
|
+
return err(new WorldPoisonedError(world.identity, world.execution.fault));
|
|
336
|
+
}
|
|
337
|
+
if (boundEpoch !== source.structureEpoch()) rebind();
|
|
338
|
+
const binding = bindings[bindingIndex];
|
|
339
|
+
const table = bindingTables[bindingIndex];
|
|
340
|
+
const runStarts = runStartBuffers[bindingIndex];
|
|
341
|
+
const runCounts = runCountBuffers[bindingIndex];
|
|
342
|
+
if (
|
|
343
|
+
binding === undefined ||
|
|
344
|
+
table === undefined ||
|
|
345
|
+
runStarts === undefined ||
|
|
346
|
+
runCounts === undefined ||
|
|
347
|
+
!Number.isSafeInteger(bindingIndex) ||
|
|
348
|
+
!Number.isSafeInteger(base) ||
|
|
349
|
+
!Number.isSafeInteger(start) ||
|
|
350
|
+
!Number.isSafeInteger(count) ||
|
|
351
|
+
bindingIndex < 0 ||
|
|
352
|
+
base < 0 ||
|
|
353
|
+
start < 0 ||
|
|
354
|
+
count < 0 ||
|
|
355
|
+
base + start + count > binding.rowCapacity ||
|
|
356
|
+
table.storage.get(componentOwner.componentId(component)) === undefined
|
|
357
|
+
) {
|
|
358
|
+
return err(new DerivedRangeOutOfBoundsError(start, count, binding?.rowCapacity ?? 0));
|
|
359
|
+
}
|
|
360
|
+
if (count === 0) return ok(undefined);
|
|
361
|
+
|
|
362
|
+
const previousEpoch = world[worldInternal].getMutationEpoch();
|
|
363
|
+
let runCount = 0;
|
|
364
|
+
let epoch = 0;
|
|
365
|
+
let epochReserved = false;
|
|
366
|
+
let runStart = -1;
|
|
367
|
+
try {
|
|
368
|
+
for (let offset = 0; offset < count; offset += 1) {
|
|
369
|
+
const row = base + start + offset;
|
|
370
|
+
const changed = probe(binding, row, context);
|
|
371
|
+
if (changed) {
|
|
372
|
+
if (!epochReserved) {
|
|
373
|
+
try {
|
|
374
|
+
epoch = world[worldInternal].nextMutationEpoch();
|
|
375
|
+
epochReserved = true;
|
|
376
|
+
} catch (cause) {
|
|
377
|
+
// Nothing has been committed yet. Keep the World healthy and
|
|
378
|
+
// preserve the source error so the caller can retry unchanged.
|
|
379
|
+
return err(cause as EcsError);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (runStart < 0) runStart = row;
|
|
383
|
+
commit(binding, row, context);
|
|
384
|
+
} else if (runStart >= 0) {
|
|
385
|
+
runStarts[runCount] = runStart;
|
|
386
|
+
runCounts[runCount] = row - runStart;
|
|
387
|
+
runCount += 1;
|
|
388
|
+
runStart = -1;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (runStart >= 0) {
|
|
392
|
+
runStarts[runCount] = runStart;
|
|
393
|
+
runCounts[runCount] = base + start + count - runStart;
|
|
394
|
+
runCount += 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Every changed run in this call shares the epoch reserved immediately
|
|
398
|
+
// before the first commit. Publication remains deferred until the
|
|
399
|
+
// complete probe/commit pass has succeeded.
|
|
400
|
+
if (runCount > 0) {
|
|
401
|
+
const componentIdentifier = componentOwner.componentId(component);
|
|
402
|
+
for (let index = 0; index < runCount; index += 1) {
|
|
403
|
+
const runStartValue = runStarts[index] ?? 0;
|
|
404
|
+
const runLength = runCounts[index] ?? 0;
|
|
405
|
+
world[worldInternal].publishDerivedRange(
|
|
406
|
+
table,
|
|
407
|
+
componentIdentifier,
|
|
408
|
+
runStartValue,
|
|
409
|
+
runLength,
|
|
410
|
+
epoch,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return ok(undefined);
|
|
415
|
+
} catch (cause) {
|
|
416
|
+
if (epochReserved) world[worldInternal].restoreMutationEpoch(previousEpoch);
|
|
417
|
+
world[worldInternal].poisonExecution({
|
|
418
|
+
code: 'shared-kernel-failed',
|
|
419
|
+
kernelName: `derived-range:${component.name}:probe`,
|
|
420
|
+
cause,
|
|
421
|
+
partialWrite: true,
|
|
422
|
+
retryable: false,
|
|
423
|
+
});
|
|
424
|
+
return err(new SharedKernelFailureError(component.name, world.identity, cause, true));
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
return writer;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildWholeColumnShape(
|
|
432
|
+
table: Table,
|
|
433
|
+
components: readonly Component[],
|
|
434
|
+
): Record<string, unknown> {
|
|
435
|
+
countAllocation('shapeAllocations');
|
|
436
|
+
const shape: Record<string, unknown> = {};
|
|
437
|
+
for (const component of components) {
|
|
438
|
+
const fields = table.storage.get(componentOwner.componentId(component))?.fields;
|
|
439
|
+
if (fields === undefined) continue;
|
|
440
|
+
for (const [fieldName, column] of fields) shape[fieldName] = column.view;
|
|
441
|
+
}
|
|
442
|
+
return shape;
|
|
443
|
+
}
|
package/src/query/query.ts
CHANGED
|
@@ -18,13 +18,17 @@ import {
|
|
|
18
18
|
QueryIterationInvalidatedError,
|
|
19
19
|
QuerySpanUnavailableError,
|
|
20
20
|
type QuerySpanUnavailableReason,
|
|
21
|
+
RelationshipTargetReadonlyError,
|
|
21
22
|
} from '../errors';
|
|
23
|
+
import { DERIVED_WRITER } from '../internal';
|
|
24
|
+
import { isRelationshipTarget, relationshipRole } from '../relationship-index';
|
|
22
25
|
import type { Archetype, ArchetypeId } from '../storage/archetype';
|
|
23
26
|
import type { ArchetypeGraph } from '../storage/archetype-graph';
|
|
24
27
|
import { sparseTagIndex } from '../storage/change-detection';
|
|
25
28
|
import type { FieldView, ManagedColumnReader } from '../storage/column';
|
|
26
29
|
import type { Table } from '../storage/table';
|
|
27
30
|
import { type WorldInternal, worldInternal } from '../world-internal';
|
|
31
|
+
import { createDerivedRangeWriter, type DerivedRangeWriter } from './derived-range-writer';
|
|
28
32
|
|
|
29
33
|
export interface QueryDescriptor<
|
|
30
34
|
R extends readonly Component[] = readonly Component[],
|
|
@@ -91,7 +95,7 @@ export interface Query<
|
|
|
91
95
|
* Read one known entity without iterating the query's matching tables.
|
|
92
96
|
* Structural descriptor constraints are applied; temporal `changed` and
|
|
93
97
|
* `added` filters are intentionally not evaluated because this is an
|
|
94
|
-
* identity-addressed read, not a
|
|
98
|
+
* identity-addressed read, not a version scan.
|
|
95
99
|
*/
|
|
96
100
|
at(entity: EntityHandle): QueryRow<R, W, O> | undefined;
|
|
97
101
|
spans(): Result<Iterable<QuerySpan<R, W>>, QuerySpanUnavailableError>;
|
|
@@ -227,7 +231,17 @@ class QueryRowFacade<
|
|
|
227
231
|
mut<C extends W[number]>(component: C): MutableRowShape<C> {
|
|
228
232
|
const current = this.get(component);
|
|
229
233
|
if (current === undefined) throw new Error(`Query row lacks ${component.name}.`);
|
|
230
|
-
|
|
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
|
+
}
|
|
231
245
|
return new Proxy(current, {
|
|
232
246
|
set: (target, property, value) => {
|
|
233
247
|
if (typeof property !== 'string') return false;
|
|
@@ -254,8 +268,13 @@ class QuerySpanFacade<R extends readonly Component[], W extends readonly Compone
|
|
|
254
268
|
readonly length: number,
|
|
255
269
|
) {
|
|
256
270
|
const entityColumn = table.storage.get(componentId(Entity))?.fields.get('self');
|
|
257
|
-
this.entities = (
|
|
258
|
-
|
|
271
|
+
this.entities = (
|
|
272
|
+
entityColumn === undefined
|
|
273
|
+
? new Uint32Array(0)
|
|
274
|
+
: rowStart === 0 && length === entityColumn.view.length
|
|
275
|
+
? entityColumn.view
|
|
276
|
+
: entityColumn.view.subarray(rowStart, rowStart + length)
|
|
277
|
+
) as Readonly<Uint32Array>;
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
get<C extends R[number]>(component: C): ReadonlyColumnShape<C> {
|
|
@@ -268,6 +287,12 @@ class QuerySpanFacade<R extends readonly Component[], W extends readonly Compone
|
|
|
268
287
|
}
|
|
269
288
|
|
|
270
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
|
+
}
|
|
271
296
|
this.world[worldInternal].markComponentRangeChanged(
|
|
272
297
|
this.table,
|
|
273
298
|
componentId(component),
|
|
@@ -288,7 +313,7 @@ function makeManagedColumnReader(
|
|
|
288
313
|
length: number,
|
|
289
314
|
fieldType: string,
|
|
290
315
|
): ManagedColumnReader<string> {
|
|
291
|
-
const slots = view.subarray(0, length);
|
|
316
|
+
const slots = view.length === length ? view : view.subarray(0, length);
|
|
292
317
|
return Object.freeze({
|
|
293
318
|
length,
|
|
294
319
|
get(i: number): number {
|
|
@@ -310,7 +335,8 @@ function buildColumnShape(
|
|
|
310
335
|
for (const [fieldName, column] of fields) {
|
|
311
336
|
const start = rowStart * column.arity;
|
|
312
337
|
const end = start + rowCount * column.arity;
|
|
313
|
-
const view =
|
|
338
|
+
const view =
|
|
339
|
+
start === 0 && end === column.view.length ? column.view : column.view.subarray(start, end);
|
|
314
340
|
const fieldType = componentSchema(component)[fieldName];
|
|
315
341
|
shape[fieldName] =
|
|
316
342
|
fieldType !== undefined && isManagedField(fieldType)
|
|
@@ -328,6 +354,7 @@ class ExecutableQuery<
|
|
|
328
354
|
{
|
|
329
355
|
private matchedArchetypes: ArchetypeId[] = [];
|
|
330
356
|
private matchedTables: number[] = [];
|
|
357
|
+
private matchedTableObjects: Table[] = [];
|
|
331
358
|
private lastGraphGeneration = -1;
|
|
332
359
|
private lastObservedEpoch = 0;
|
|
333
360
|
private active = false;
|
|
@@ -432,7 +459,8 @@ class ExecutableQuery<
|
|
|
432
459
|
query.refreshMatches();
|
|
433
460
|
const structureEpoch = query.world[worldInternal].getStructureEpoch();
|
|
434
461
|
const upperBound = query.world[worldInternal].getMutationEpoch();
|
|
435
|
-
let
|
|
462
|
+
let tableIndex = 0;
|
|
463
|
+
let rowIndex = 0;
|
|
436
464
|
let finished = false;
|
|
437
465
|
const close = (commit: boolean): void => {
|
|
438
466
|
if (finished) return;
|
|
@@ -450,13 +478,44 @@ class ExecutableQuery<
|
|
|
450
478
|
query.world[worldInternal].getStructureEpoch(),
|
|
451
479
|
);
|
|
452
480
|
}
|
|
453
|
-
while (
|
|
481
|
+
while (tableIndex < query.matchedTables.length) {
|
|
454
482
|
const table =
|
|
455
|
-
query.world[worldInternal].getGraph().tables[query.matchedTables[
|
|
456
|
-
if (table === undefined || table.size === 0)
|
|
483
|
+
query.world[worldInternal].getGraph().tables[query.matchedTables[tableIndex] ?? -1];
|
|
484
|
+
if (table === undefined || table.size === 0) {
|
|
485
|
+
tableIndex += 1;
|
|
486
|
+
rowIndex = 0;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (query.compiled.changedIds.length === 0 && query.compiled.addedIds.length === 0) {
|
|
490
|
+
tableIndex += 1;
|
|
491
|
+
rowIndex = 0;
|
|
492
|
+
return {
|
|
493
|
+
done: false,
|
|
494
|
+
value: new QuerySpanFacade<R, W>(query.world, table, 0, table.size),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
while (
|
|
498
|
+
rowIndex < table.size &&
|
|
499
|
+
!query.denseChangeMatches(table, rowIndex, upperBound)
|
|
500
|
+
) {
|
|
501
|
+
rowIndex += 1;
|
|
502
|
+
}
|
|
503
|
+
if (rowIndex >= table.size) {
|
|
504
|
+
tableIndex += 1;
|
|
505
|
+
rowIndex = 0;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const start = rowIndex;
|
|
509
|
+
rowIndex += 1;
|
|
510
|
+
while (
|
|
511
|
+
rowIndex < table.size &&
|
|
512
|
+
query.denseChangeMatches(table, rowIndex, upperBound)
|
|
513
|
+
) {
|
|
514
|
+
rowIndex += 1;
|
|
515
|
+
}
|
|
457
516
|
return {
|
|
458
517
|
done: false,
|
|
459
|
-
value: new QuerySpanFacade<R, W>(query.world, table,
|
|
518
|
+
value: new QuerySpanFacade<R, W>(query.world, table, start, rowIndex - start),
|
|
460
519
|
};
|
|
461
520
|
}
|
|
462
521
|
close(true);
|
|
@@ -471,6 +530,28 @@ class ExecutableQuery<
|
|
|
471
530
|
});
|
|
472
531
|
}
|
|
473
532
|
|
|
533
|
+
[DERIVED_WRITER]<C extends W[number]>(
|
|
534
|
+
component: C,
|
|
535
|
+
): Result<DerivedRangeWriter<R[number], C>, QuerySpanUnavailableError> {
|
|
536
|
+
const reason = this.spanUnavailableReason();
|
|
537
|
+
if (reason !== undefined) return err(new QuerySpanUnavailableError(reason));
|
|
538
|
+
return ok(
|
|
539
|
+
createDerivedRangeWriter<R[number], C>(
|
|
540
|
+
this.world as unknown as import('../world').World,
|
|
541
|
+
component,
|
|
542
|
+
{
|
|
543
|
+
structureEpoch: () => this.world[worldInternal].getStructureEpoch(),
|
|
544
|
+
tables: () => {
|
|
545
|
+
this.refreshMatches();
|
|
546
|
+
return this.matchedTableObjects;
|
|
547
|
+
},
|
|
548
|
+
readComponents: this.compiled.descriptor.read ?? [],
|
|
549
|
+
writeComponents: this.compiled.descriptor.write ?? [],
|
|
550
|
+
},
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
474
555
|
combinations(k = 2): Iterable<readonly QueryRow<R, W, O>[]> {
|
|
475
556
|
const query = this;
|
|
476
557
|
return {
|
|
@@ -527,6 +608,10 @@ class ExecutableQuery<
|
|
|
527
608
|
this.matchedTables = graph.tables
|
|
528
609
|
.filter((table) => this.tableMatches(table))
|
|
529
610
|
.map((table) => table.id);
|
|
611
|
+
this.matchedTableObjects = this.matchedTables.flatMap((id) => {
|
|
612
|
+
const table = graph.tables[id];
|
|
613
|
+
return table === undefined ? [] : [table];
|
|
614
|
+
});
|
|
530
615
|
this.lastGraphGeneration = graph.generation;
|
|
531
616
|
}
|
|
532
617
|
|
|
@@ -580,14 +665,28 @@ class ExecutableQuery<
|
|
|
580
665
|
return true;
|
|
581
666
|
}
|
|
582
667
|
|
|
668
|
+
private denseChangeMatches(table: Table, row: number, upperBound: number): boolean {
|
|
669
|
+
for (const componentId of this.compiled.changedIds) {
|
|
670
|
+
const epoch = table.storage.get(componentId)?.epochs.changed[row] ?? 0;
|
|
671
|
+
if (epoch <= this.lastObservedEpoch || epoch > upperBound) return false;
|
|
672
|
+
}
|
|
673
|
+
for (const componentId of this.compiled.addedIds) {
|
|
674
|
+
const epoch = table.storage.get(componentId)?.epochs.added[row] ?? 0;
|
|
675
|
+
if (epoch <= this.lastObservedEpoch || epoch > upperBound) return false;
|
|
676
|
+
}
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
583
680
|
private spanUnavailableReason(): QuerySpanUnavailableReason | undefined {
|
|
584
681
|
if (this.compiled.sparseRoute) return 'sparse-component';
|
|
585
682
|
if ((this.compiled.descriptor.optional?.length ?? 0) > 0) return 'optional-data';
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
+
}
|
|
591
690
|
return undefined;
|
|
592
691
|
}
|
|
593
692
|
}
|