@adobe/ecs 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/implementation/compiler/augmentations.js +8 -1
  2. package/dist/implementation/compiler/augmentations.js.map +1 -1
  3. package/dist/implementation/compiler/body-access.d.ts +18 -1
  4. package/dist/implementation/compiler/body-access.js +104 -15
  5. package/dist/implementation/compiler/body-access.js.map +1 -1
  6. package/dist/implementation/compiler/estree.d.ts +5 -2
  7. package/dist/implementation/compiler/estree.js +8 -5
  8. package/dist/implementation/compiler/estree.js.map +1 -1
  9. package/dist/implementation/compiler/inferred.js +5 -2
  10. package/dist/implementation/compiler/inferred.js.map +1 -1
  11. package/dist/implementation/compiler/transform.js +5 -4
  12. package/dist/implementation/compiler/transform.js.map +1 -1
  13. package/dist/implementation/compiler/type-schemas.d.ts +1 -0
  14. package/dist/implementation/compiler/type-schemas.js +35 -2
  15. package/dist/implementation/compiler/type-schemas.js.map +1 -1
  16. package/dist/implementation/world/activate.d.ts +14 -3
  17. package/dist/implementation/world/activate.js +28 -10
  18. package/dist/implementation/world/activate.js.map +1 -1
  19. package/dist/implementation/world/buffer.d.ts +7 -0
  20. package/dist/implementation/world/buffer.js +7 -0
  21. package/dist/implementation/world/buffer.js.map +1 -1
  22. package/dist/implementation/world/cascade.d.ts +10 -3
  23. package/dist/implementation/world/cascade.js +48 -23
  24. package/dist/implementation/world/cascade.js.map +1 -1
  25. package/dist/implementation/world/commands.js +41 -5
  26. package/dist/implementation/world/commands.js.map +1 -1
  27. package/dist/implementation/world/create.js +57 -6
  28. package/dist/implementation/world/create.js.map +1 -1
  29. package/dist/implementation/world/execute.d.ts +4 -4
  30. package/dist/implementation/world/execute.js +228 -93
  31. package/dist/implementation/world/execute.js.map +1 -1
  32. package/dist/implementation/world/finalizers.d.ts +10 -0
  33. package/dist/implementation/world/finalizers.js +60 -10
  34. package/dist/implementation/world/finalizers.js.map +1 -1
  35. package/dist/implementation/world/join.js +12 -2
  36. package/dist/implementation/world/join.js.map +1 -1
  37. package/dist/implementation/world/program.js +5 -1
  38. package/dist/implementation/world/program.js.map +1 -1
  39. package/dist/implementation/world/resource-changes.d.ts +18 -0
  40. package/dist/implementation/world/resource-changes.js +66 -0
  41. package/dist/implementation/world/resource-changes.js.map +1 -0
  42. package/dist/implementation/world/rows.js +21 -16
  43. package/dist/implementation/world/rows.js.map +1 -1
  44. package/dist/implementation/world/run-changes.d.ts +38 -0
  45. package/dist/implementation/world/run-changes.js +138 -0
  46. package/dist/implementation/world/run-changes.js.map +1 -0
  47. package/dist/implementation/world/runner.js +16 -12
  48. package/dist/implementation/world/runner.js.map +1 -1
  49. package/dist/implementation/world/state.d.ts +15 -0
  50. package/dist/implementation/world/state.js +22 -1
  51. package/dist/implementation/world/state.js.map +1 -1
  52. package/dist/implementation/world/synchronize.js +81 -24
  53. package/dist/implementation/world/synchronize.js.map +1 -1
  54. package/dist/implementation/world/tables-read.d.ts +5 -3
  55. package/dist/implementation/world/tables-read.js +16 -4
  56. package/dist/implementation/world/tables-read.js.map +1 -1
  57. package/dist/implementation/world/tables.d.ts +4 -2
  58. package/dist/implementation/world/tables.js +137 -38
  59. package/dist/implementation/world/tables.js.map +1 -1
  60. package/dist/implementation/world/thread-observer.js +1 -1
  61. package/dist/implementation/world/thread-observer.js.map +1 -1
  62. package/dist/implementation/world/thread-systems.js +10 -6
  63. package/dist/implementation/world/thread-systems.js.map +1 -1
  64. package/dist/implementation/world/up.js +7 -1
  65. package/dist/implementation/world/up.js.map +1 -1
  66. package/dist/implementation/world/where.d.ts +8 -0
  67. package/dist/implementation/world/where.js +40 -11
  68. package/dist/implementation/world/where.js.map +1 -1
  69. package/dist/interface/finalizer/body.d.ts +40 -4
  70. package/dist/interface/finalizer/create.d.ts +8 -6
  71. package/dist/interface/finalizer/create.js +45 -10
  72. package/dist/interface/finalizer/create.js.map +1 -1
  73. package/dist/interface/finalizer/declaration.d.ts +26 -4
  74. package/dist/interface/indexes/declaration-of.d.ts +17 -0
  75. package/dist/interface/indexes/declaration-of.js +20 -0
  76. package/dist/interface/indexes/declaration-of.js.map +1 -0
  77. package/dist/interface/indexes/index.d.ts +1 -0
  78. package/dist/interface/indexes/index.js +1 -0
  79. package/dist/interface/indexes/index.js.map +1 -1
  80. package/dist/interface/registry/index.d.ts +1 -1
  81. package/dist/interface/registry/index.js +1 -1
  82. package/dist/interface/registry/index.js.map +1 -1
  83. package/dist/interface/registry/names.d.ts +4 -0
  84. package/dist/interface/registry/names.js +20 -0
  85. package/dist/interface/registry/names.js.map +1 -1
  86. package/dist/interface/registry/schedule.d.ts +29 -1
  87. package/dist/interface/registry/schedule.js +261 -25
  88. package/dist/interface/registry/schedule.js.map +1 -1
  89. package/dist/interface/system/access.d.ts +15 -0
  90. package/dist/interface/system/check.d.ts +7 -0
  91. package/dist/interface/system/check.js +21 -3
  92. package/dist/interface/system/check.js.map +1 -1
  93. package/dist/interface/system/commands.d.ts +31 -5
  94. package/dist/interface/system/create-resolved.js +2 -1
  95. package/dist/interface/system/create-resolved.js.map +1 -1
  96. package/dist/interface/system/create.d.ts +10 -6
  97. package/dist/interface/system/create.js +5 -4
  98. package/dist/interface/system/create.js.map +1 -1
  99. package/dist/interface/system/declaration.d.ts +11 -4
  100. package/dist/interface/system/describe.d.ts +120 -0
  101. package/dist/interface/system/describe.js +116 -0
  102. package/dist/interface/system/describe.js.map +1 -0
  103. package/dist/interface/system/index.d.ts +1 -0
  104. package/dist/interface/system/index.js +1 -0
  105. package/dist/interface/system/index.js.map +1 -1
  106. package/dist/interface/system/leaf.d.ts +7 -0
  107. package/dist/interface/system/render.js +2 -1
  108. package/dist/interface/system/render.js.map +1 -1
  109. package/dist/interface/system/resolve.d.ts +14 -1
  110. package/dist/interface/system/resolve.js +50 -1
  111. package/dist/interface/system/resolve.js.map +1 -1
  112. package/dist/interface/system/shared-only.d.ts +7 -0
  113. package/dist/interface/system/shared-only.js +2 -0
  114. package/dist/interface/system/shared-only.js.map +1 -0
  115. package/dist/interface/system/tables.d.ts +9 -1
  116. package/dist/interface/world/table/index.d.ts +3 -2
  117. package/dist/interface/world/world.d.ts +15 -0
  118. package/package.json +11 -2
  119. package/src/guide/01-resources.md +4 -3
  120. package/src/guide/03-systems.out +1 -1
  121. package/src/guide/05-scheduling.md +12 -6
  122. package/src/guide/05-scheduling.out +1 -0
  123. package/src/guide/05-scheduling.ts +17 -0
  124. package/src/guide/06-sync-points.md +20 -8
  125. package/src/guide/06-sync-points.out +3 -2
  126. package/src/guide/06-sync-points.ts +24 -5
  127. package/src/guide/10-finalizers.md +5 -0
  128. package/src/guide/18-identity.out +1 -1
  129. package/src/guide/guide.test.ts +6 -153
  130. package/src/implementation/compiler/augmentations.test.ts +28 -1
  131. package/src/implementation/compiler/augmentations.ts +5 -1
  132. package/src/implementation/compiler/body-access.ts +102 -12
  133. package/src/implementation/compiler/estree.ts +9 -6
  134. package/src/implementation/compiler/inferred.test.ts +1 -1
  135. package/src/implementation/compiler/inferred.ts +5 -2
  136. package/src/implementation/compiler/transform.test.ts +41 -21
  137. package/src/implementation/compiler/transform.ts +5 -3
  138. package/src/implementation/compiler/type-schemas.ts +28 -2
  139. package/src/implementation/world/activate.ts +32 -10
  140. package/src/implementation/world/batches.test.ts +3 -3
  141. package/src/implementation/world/buffer.ts +7 -0
  142. package/src/implementation/world/cascade.ts +46 -22
  143. package/src/implementation/world/commands.ts +37 -5
  144. package/src/implementation/world/create.test.ts +8 -8
  145. package/src/implementation/world/create.ts +61 -6
  146. package/src/implementation/world/defaults.test.ts +24 -1
  147. package/src/implementation/world/despawn-where.test.ts +310 -0
  148. package/src/implementation/world/execute.ts +222 -86
  149. package/src/implementation/world/finalizers.ts +63 -11
  150. package/src/implementation/world/ids.test.ts +1 -1
  151. package/src/implementation/world/indexes.test.ts +2 -1
  152. package/src/implementation/world/join.test.ts +19 -0
  153. package/src/implementation/world/join.ts +16 -2
  154. package/src/implementation/world/lifetime.test-helper.ts +1 -1
  155. package/src/implementation/world/names.test.ts +1 -2
  156. package/src/implementation/world/passes.test.ts +61 -0
  157. package/src/implementation/world/perf.md +12 -1
  158. package/src/implementation/world/program.ts +4 -1
  159. package/src/implementation/world/resource-changes.test.ts +61 -0
  160. package/src/implementation/world/resource-changes.ts +67 -0
  161. package/src/implementation/world/rows.ts +21 -14
  162. package/src/implementation/world/rules.test.ts +37 -0
  163. package/src/implementation/world/run-changes.test.ts +88 -0
  164. package/src/implementation/world/run-changes.ts +160 -0
  165. package/src/implementation/world/run-results.test.ts +17 -0
  166. package/src/implementation/world/runner.ts +17 -13
  167. package/src/implementation/world/schedule.test.ts +42 -1
  168. package/src/implementation/world/shared.test.ts +2 -2
  169. package/src/implementation/world/state.ts +27 -0
  170. package/src/implementation/world/sync-points.test.ts +147 -0
  171. package/src/implementation/world/synchronize.ts +73 -20
  172. package/src/implementation/world/tables-read.ts +16 -5
  173. package/src/implementation/world/tables.test.ts +139 -4
  174. package/src/implementation/world/tables.ts +118 -35
  175. package/src/implementation/world/thread-observer.ts +1 -1
  176. package/src/implementation/world/thread-systems.ts +10 -6
  177. package/src/implementation/world/up.test.ts +13 -1
  178. package/src/implementation/world/up.ts +6 -1
  179. package/src/implementation/world/watch-traits-late.test-helper.ts +14 -0
  180. package/src/implementation/world/watch-traits.test-helper.ts +10 -0
  181. package/src/implementation/world/watch-traits.test.ts +62 -0
  182. package/src/implementation/world/where.ts +38 -10
  183. package/src/interface/finalizer/body.ts +33 -4
  184. package/src/interface/finalizer/create.test.ts +6 -1
  185. package/src/interface/finalizer/create.ts +45 -12
  186. package/src/interface/finalizer/declaration.ts +31 -4
  187. package/src/interface/indexes/create.test.ts +10 -0
  188. package/src/interface/indexes/declaration-of.ts +34 -0
  189. package/src/interface/indexes/index.ts +1 -0
  190. package/src/interface/registry/index.ts +1 -1
  191. package/src/interface/registry/names.ts +19 -0
  192. package/src/interface/registry/schedule.ts +275 -29
  193. package/src/interface/rule/rule.type-test.ts +23 -1
  194. package/src/interface/system/access.ts +16 -0
  195. package/src/interface/system/check.ts +17 -2
  196. package/src/interface/system/commands.ts +28 -4
  197. package/src/interface/system/commands.type-test.ts +31 -0
  198. package/src/interface/system/create-resolved.ts +2 -1
  199. package/src/interface/system/create.test.ts +10 -2
  200. package/src/interface/system/create.ts +9 -5
  201. package/src/interface/system/declaration.ts +10 -4
  202. package/src/interface/system/describe.test.ts +98 -0
  203. package/src/interface/system/describe.ts +122 -0
  204. package/src/interface/system/index.ts +1 -0
  205. package/src/interface/system/leaf.ts +7 -0
  206. package/src/interface/system/render.ts +2 -1
  207. package/src/interface/system/resolve.test.ts +17 -1
  208. package/src/interface/system/resolve.ts +60 -2
  209. package/src/interface/system/shared-only.ts +5 -0
  210. package/src/interface/system/system.type-test.ts +6 -0
  211. package/src/interface/system/tables.ts +7 -1
  212. package/src/interface/world/run.type-test.ts +11 -1
  213. package/src/interface/world/table/index.ts +3 -2
  214. package/src/interface/world/world.ts +13 -0
  215. package/src/perftest/benchmarks.ts +20 -0
  216. package/src/perftest/chained.ts +108 -0
  217. package/src/perftest/structural.ts +20 -0
  218. package/src/perftest/tables.ts +107 -0
@@ -2,10 +2,10 @@
2
2
  import type { Views } from '../../interface/registry/layout.js';
3
3
  import { copyValue, ensureCapacity, swapRemove, writeF32, writeF32Lanes, writeF64, writeI32, writeU32, type Archetype, type Storage } from './archetype.js';
4
4
  import { blockShift, chunkShift } from './kernel.js';
5
- import { addCommand, clearBuffer, despawnCommand, endOf, referenceAt, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
5
+ import { addCommand, clearBuffer, despawnCommand, despawnWhereCommand, despawnWhereHolds, endOf, referenceAt, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
6
6
  import { readCell, writeCell } from './cells.js';
7
7
  import { commitBatches, commitStaged } from './batches.js';
8
- import { applyIndexChanges, cascadeReferences, indexChangesOf, type IndexChanges, type Written } from './cascade.js';
8
+ import { applyIndexChanges, cascadeReferences, indexChangesOf, linkBorn, type IndexChanges, type Written } from './cascade.js';
9
9
  import { refreshDirectories } from './directories.js';
10
10
  import { createArrivals, logArrival } from './arrivals.js';
11
11
  import { noteEdge, noteEmptied, reclaimEmptied } from './reclaim.js';
@@ -21,6 +21,8 @@ import type { Plan } from './plan.js';
21
21
  import type { MoveLog } from './move-log.js';
22
22
  import { archetypeOf, movesOf, sharedNumberOf, withComponent, withoutComponent, withShared, type State } from './state.js';
23
23
  import { releaseValue } from './transfers.js';
24
+ import { resourcesWritten } from './resource-changes.js';
25
+ import { recordPlan } from './run-changes.js';
24
26
 
25
27
  /** Buffers queued for the next sync point, in merge order. Commands in guarded buffers can't touch the resource entity. */
26
28
  export type Queued = { readonly buffer: CommandBuffer; readonly guarded: boolean };
@@ -127,11 +129,67 @@ const releaseDropped = (state: State, buffer: CommandBuffer, command: number): v
127
129
  }
128
130
  };
129
131
 
132
+ /**
133
+ * Whether `archetype` has each component of `ids` from `start` to `middle` and none from there to `end`, and holds the
134
+ * values `holds` tells it holds, when it isn't null.
135
+ */
136
+ const matches = (archetype: Archetype, ids: Int32Array, start: number, middle: number, end: number, holds: Function | null): boolean => {
137
+ const { storageById } = archetype;
138
+ for (let k = start; k < middle; k++) if (storageById[ids[k]] === undefined) return false;
139
+ for (let k = middle; k < end; k++) if (storageById[ids[k]] !== undefined) return false;
140
+ return holds === null || Reflect.apply(holds, undefined, [archetype]) === true;
141
+ };
142
+
143
+ /** The archetype an entity planned into `archetype` ends up in once derived. */
144
+ const closedOf = (state: State, archetype: Archetype): Archetype => {
145
+ const cached = state.structure.closures[archetype.id] ?? -1;
146
+ return cached >= 0 ? state.archetypes[cached] : closureOf(state.structure, state.derived, archetype, (components, shared) => archetypeOf(state, components, shared), state.archetypes);
147
+ };
148
+
149
+ /**
150
+ * Despawns each entity alive before the sync point that a `despawnWhere` command selects given the plan so far: those
151
+ * planned, by where they're going, then the rest, by the archetype they're in. Never the resource entity, nor an
152
+ * entity the sync point spawns: `born` holds the first row each archetype gained at it.
153
+ */
154
+ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean, born: ReadonlyMap<number, number>): void => {
155
+ const { plan, archetypes } = state;
156
+ const { ids } = buffer;
157
+ const start = buffer.starts[command];
158
+ const middle = start + buffer.entities[command];
159
+ const last = endOf(buffer, command);
160
+ const where = ids[last - 1] === despawnWhereHolds ? buffer.values[last - 1] : null;
161
+ const holds = typeof where === "function" ? where : null;
162
+ const end = holds === null ? last : last - 1;
163
+ const { row } = state.entities;
164
+ for (let entry = 0; entry < plan.count; entry++) {
165
+ const from = plan.from[entry];
166
+ if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity || from < 0) continue;
167
+ const first = born.get(from);
168
+ if (first !== undefined && row[indexOf(plan.entity[entry])] >= first) continue;
169
+ const to = archetypes[plan.to[entry]];
170
+ if (matches(within && plan.to[entry] !== plan.from[entry] ? closedOf(state, to) : to, ids, start, middle, end, holds)) plan.despawned[entry] = 1;
171
+ }
172
+ const { slots } = plan;
173
+ const count = archetypes.length;
174
+ for (let id = 0; id < count; id++) {
175
+ const archetype = archetypes[id];
176
+ if (archetype.resource || archetype.count === 0 || !matches(archetype, ids, start, middle, end, holds)) continue;
177
+ ensurePlanCapacity(state, plan.count + archetype.count);
178
+ const { entities } = archetype;
179
+ const alive = born.get(id) ?? archetype.count;
180
+ for (let at = 0; at < alive; at++) {
181
+ const entity = entities[at];
182
+ const index = indexOf(entity);
183
+ if (slots[index] < 0) plan.despawned[addPlanned(state, entity, index, id, id)] = 1;
184
+ }
185
+ }
186
+ };
187
+
130
188
  /**
131
189
  * Plan: the net change for each entity, in issue order. Commands against dead entities do nothing. Reference values
132
190
  * written by commands go to `written`, to check once despawns are known.
133
191
  */
134
- const planOf = (state: State, queued: readonly Queued[], written: Written): void => {
192
+ const planOf = (state: State, queued: readonly Queued[], written: Written, born: ReadonlyMap<number, number>): void => {
135
193
  const { plan, structure } = state;
136
194
  if (plan.slots.length < state.entities.length) plan.slots = new Int32Array(Math.max(state.entities.length, plan.slots.length * 2)).fill(-1);
137
195
  const { slots } = plan;
@@ -146,6 +204,11 @@ const planOf = (state: State, queued: readonly Queued[], written: Written): void
146
204
  ensurePlanCapacity(state, plan.count + buffer.count);
147
205
  for (let command = 0; command < buffer.count; command++) {
148
206
  const kind = kinds[command];
207
+ if (kind === despawnWhereCommand) {
208
+ despawnWhere(state, buffer, command, within, born);
209
+ ensurePlanCapacity(state, plan.count + buffer.count - command);
210
+ continue;
211
+ }
149
212
  const entity = entities[command];
150
213
  if (guarded && entity === state.resourceEntity) continue;
151
214
  const index = indexOf(entity);
@@ -631,7 +694,9 @@ const noneQueued: readonly Queued[] = [];
631
694
  export const syncPending = (state: State, queued: readonly Queued[] = state.queued): boolean =>
632
695
  anyCommands(queued) || reserved(state.entities) || state.carried !== null || state.structure.rederive
633
696
  // Failures for its run, as a gone thread's stops it never finalized are.
634
- || state.unplaced.length > 0;
697
+ || state.unplaced.length > 0
698
+ // A watched resource written, which its changes finalizers see at a sync point.
699
+ || (state.resourcesSeen.size > 0 && resourcesWritten(state));
635
700
 
636
701
  /**
637
702
  * The first half of a sync point: commits batch spawns, plans every command, cascades references and closes the plan
@@ -683,27 +748,14 @@ const preparePending = (state: State, queued: readonly Queued[], isolated: boole
683
748
  if (!isolated) plan.spawned += commitBatches(state, tick, born, deriving ? close : null);
684
749
  plan.spawned += commitStaged(state, queued, tick, born, deriving ? close : null);
685
750
  const written: Written = [];
686
- planOf(state, queued, written);
751
+ planOf(state, queued, written, born);
687
752
  if (structure.rederive && !isolated) rederive(state);
688
753
  if (deriving) for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 0) plan.to[entry] = close(state.archetypes[plan.to[entry]]).id;
689
754
  let index: IndexChanges | null = null;
690
755
  if (structure.referenceIds.length > 0) {
691
- for (const [id, first] of born) {
692
- const archetype = state.archetypes[id];
693
- for (const component of structure.referenceIds) {
694
- const storage = archetype.storageById[component];
695
- if (storage === undefined) continue;
696
- for (let row = first; row < archetype.count; row++) written.push(archetype.entities[row], component, Number(readCell(storage, row)));
697
- }
698
- }
756
+ linkBorn(state, born, written, tick);
699
757
  cascadeReferences(state, queued, written, entity => entryOf(state, entity));
700
- const fromOf = (entry: number): Archetype | null => {
701
- const from = plan.from[entry];
702
- if (from < 0) return null;
703
- const first = born.get(from);
704
- return first !== undefined && state.entities.row[indexOf(plan.entity[entry])] >= first ? null : state.archetypes[from];
705
- };
706
- index = indexChangesOf(state, queued, fromOf, [...born].map(([id, first]) => [state.archetypes[id], first] as const));
758
+ index = indexChangesOf(state, queued, entry => plan.from[entry] < 0 ? null : state.archetypes[plan.from[entry]]);
707
759
  }
708
760
  // References change only here: each component written to is stamped, and each holder journaled, as are spawns.
709
761
  for (let k = 1; k < written.length; k += 3) state.referencesWritten[written[k]] = tick;
@@ -741,6 +793,7 @@ export const applySync = (state: State): readonly Thrown[] => {
741
793
  let errors = nothingThrown;
742
794
  try {
743
795
  if (state.unplaced.length > 0 && !isolated) errors = state.unplaced.splice(0);
796
+ if (state.recording !== null && !isolated) recordPlan(state, state.recording, queued, born);
744
797
  if (state.finalizers.length > 0 && !isolated) {
745
798
  const thrown = runFinalizers(state, queued, born, tick);
746
799
  errors = errors.length === 0 ? thrown : [...errors, ...thrown];
@@ -2,10 +2,11 @@
2
2
  import { labelOf } from '../../interface/registry/names.js';
3
3
  import type { Tables, TablesDeclaration } from '../../interface/system/tables.js';
4
4
  import type { Table } from '../../interface/world/table/table.js';
5
- import type { Storage } from './archetype.js';
5
+ import type { Archetype, Storage } from './archetype.js';
6
6
  import { readCell } from './cells.js';
7
7
  import { resourceCell } from './resources.js';
8
8
  import { idOf, type Key, type State } from './state.js';
9
+ import { holdsValuesOf } from './where.js';
9
10
 
10
11
  // Reading tables, apart from spawning them: rows' contexts import it, and what spawning imports would make a cycle.
11
12
 
@@ -27,12 +28,21 @@ const columnOf = (key: Key, storage: Storage, count: number): Table.Column => {
27
28
  };
28
29
 
29
30
  /**
30
- * A readonly system's `tables`: of each archetype matching `with` and `not` and holding any of `components`, its
31
- * entities and a copy of the column of each of them it holds; and the value of each resource of `resources`.
31
+ * A readonly system's `tables`, read by the function this returns, once per read: of each archetype matching `with`,
32
+ * `not` and `where` and holding any of `components`, its entities and a copy of the column of each of them it holds;
33
+ * and the value of each resource of `resources`. `where` holds its values as a system's does, and an archetype
34
+ * matches it as one matches a system's `where`, required as `with` requires them.
32
35
  */
33
- export const readTables = (state: State, declaration: TablesDeclaration): Tables => {
36
+ export const tablesReader = (state: State, declaration: TablesDeclaration): (() => Tables) => {
37
+ const holds = declaration.where === undefined ? null : holdsValuesOf(state, declaration.where, true);
38
+ if (typeof holds === "symbol") throw new Error(`Tables' where names "${labelOf(holds)}", which isn't a shared component.`);
39
+ const where = declaration.where === undefined ? [] : Object.getOwnPropertySymbols(declaration.where);
40
+ return () => readTables(state, declaration, [...where, ...declaration.with ?? []], holds);
41
+ };
42
+
43
+ const readTables = (state: State, declaration: TablesDeclaration, requiring: readonly Key[], holds: ((archetype: Archetype) => boolean) | null): Tables => {
34
44
  const asked = declaration.components.map(key => ({ key, id: idOf(state, key) }));
35
- const required = (declaration.with ?? []).map(key => idOf(state, key));
45
+ const required = requiring.map(key => idOf(state, key));
36
46
  const excluded = (declaration.not ?? []).map(key => idOf(state, key));
37
47
  const archetypes: Table[] = [];
38
48
  for (const archetype of state.archetypes) {
@@ -40,6 +50,7 @@ export const readTables = (state: State, declaration: TablesDeclaration): Tables
40
50
  if (count === 0 || archetype.reclaimed) continue;
41
51
  const { storageById } = archetype;
42
52
  if (required.some(id => storageById[id] === undefined) || excluded.some(id => storageById[id] !== undefined)) continue;
53
+ if (holds !== null && !holds(archetype)) continue;
43
54
  const columns: { [key: symbol]: Table.Column } = {};
44
55
  let held = false;
45
56
  for (const { key, id } of asked) {
@@ -46,16 +46,33 @@ const tablesOf = (components: readonly Component.Key[], resources: readonly Reso
46
46
  System.create({ readonly: true, tables: { components, resources, not } }, ({ tables }): Tables => tables);
47
47
 
48
48
  const spawnedBy: { spawned: Table.Spawned } = { spawned: { entities: [], dropped: 0 } };
49
- const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown } } = { tables: [] };
49
+ const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap<Entity, Entity>; update?: boolean } = { tables: [] };
50
50
  const spawnGiven = System.create(({ commands }) => {
51
- spawnedBy.spawned = given.resources === undefined ? commands.spawnTables(given.tables) : commands.spawnTables(given.tables, given.resources);
51
+ spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing, update: given.update });
52
52
  });
53
- const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[]): Promise<Table.Spawned> => {
53
+ const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[], existing?: ReadonlyMap<Entity, Entity>, update?: boolean): Promise<Table.Spawned> => {
54
54
  given.tables = tables;
55
- await world.run(spawnGiven);
55
+ given.existing = existing;
56
+ given.update = update;
57
+ try {
58
+ await world.run(spawnGiven);
59
+ }
60
+ finally {
61
+ given.existing = undefined;
62
+ given.update = undefined;
63
+ }
56
64
  return spawnedBy.spawned;
57
65
  };
58
66
 
67
+ /** Spawns `labels` in `world`, giving each label's entity. */
68
+ const spawnLabels = async (world: ReturnType<typeof inline>, labels: readonly string[]): Promise<ReadonlyMap<string, Entity>> => {
69
+ const made = new Map<string, Entity>();
70
+ await world.run(System.create(({ commands }) => {
71
+ for (const text of labels) made.set(text, commands.spawn([label])({ [label]: text }));
72
+ }));
73
+ return made;
74
+ };
75
+
59
76
  const values = System.create({ readonly: true, components: { with: [label], optional: [position, parent, target, link, friends, tags, buddy] } }, row => {
60
77
  found.push({ entity: row.entity, label: row[label], position: row[position], parent: row[parent], target: row[target], link: row[link], friends: row[friends], tags: row[tags], buddy: row[buddy] });
61
78
  });
@@ -198,6 +215,84 @@ describe("tables", () => {
198
215
  expect(read.find(entry => entry.label === "pointing")).toMatchObject({ buddy: Entity.none, link: { to: Entity.none, weight: 1 }, friends: [Entity.none, inside] });
199
216
  });
200
217
 
218
+ it("spawn no row existing maps, giving the entity it maps to, which every entity value naming it names", async () => {
219
+ await using world = inline();
220
+ let steel = Entity.none;
221
+ await world.run(System.create(({ commands }) => {
222
+ steel = commands.spawn([label])({ [label]: "steel" });
223
+ commands.spawn([label, parent, target, buddy, link, friends])({ [label]: "body", [parent]: steel, [target]: steel, [buddy]: steel, [link]: { to: steel, weight: 2 }, [friends]: [steel] });
224
+ }));
225
+ const { archetypes } = await world.run(tablesOf([label, parent, target, buddy, link, friends]));
226
+ await using other = inline();
227
+ const existingSteel = (await spawnLabels(other, ["steel in other"])).get("steel in other") ?? Entity.none;
228
+ const { entities, dropped } = await spawnTables(other, archetypes, new Map([[steel, existingSteel]]));
229
+ expect(dropped).toBe(0);
230
+ expect(entities.flatMap(list => [...list])).toContain(existingSteel);
231
+ const read = await readAll(other);
232
+ // Steel isn't spawned again: the body's every entity value names the one already there.
233
+ expect(read.map(entry => entry.label)).toEqual(["body", "steel in other"]);
234
+ expect(read.find(entry => entry.label === "body")).toMatchObject({ parent: existingSteel, target: existingSteel, buddy: existingSteel, link: { to: existingSteel, weight: 2 }, friends: [existingSteel] });
235
+ });
236
+
237
+ it("let an entity existing maps stand for one outside the tables, so what it owns or points at is kept", async () => {
238
+ await using world = inline();
239
+ let steel = Entity.none;
240
+ await world.run(System.create(({ commands }) => {
241
+ steel = commands.spawn([pinned])();
242
+ commands.spawn([label, parent, target])({ [label]: "owned", [parent]: steel, [target]: steel });
243
+ }));
244
+ const { archetypes } = await world.run(tablesOf([label, parent, target]));
245
+ await using other = inline();
246
+ const existingSteel = (await spawnLabels(other, ["steel"])).get("steel") ?? Entity.none;
247
+ const { dropped } = await spawnTables(other, archetypes, new Map([[steel, existingSteel]]));
248
+ expect(dropped).toBe(0);
249
+ expect((await readAll(other)).find(entry => entry.label === "owned")).toMatchObject({ parent: existingSteel, target: existingSteel });
250
+ });
251
+
252
+ it("write a row existing maps onto its entity with update, as an add would, entity values rewritten as a spawned row's", async () => {
253
+ await using world = inline();
254
+ let steel = Entity.none;
255
+ await world.run(System.create(({ commands }) => {
256
+ const inside = commands.spawn([label])({ [label]: "inside" });
257
+ const outside = commands.spawn([pinned])();
258
+ steel = commands.spawn([label, position, buddy, target, link, team], { [team]: "red" })({ [label]: "steel", [position]: [1, 2, 3], [buddy]: inside, [target]: outside, [link]: { to: inside, weight: 4 } });
259
+ }));
260
+ const { archetypes } = await world.run(tablesOf([label, position, buddy, target, link, team]));
261
+ await using other = inline();
262
+ let mine = Entity.none, kept = Entity.none;
263
+ await other.run(System.create(({ commands }) => {
264
+ kept = commands.spawn([label])({ [label]: "kept" });
265
+ mine = commands.spawn([label, hidden, target, team], { [team]: "blue" })({ [label]: "mine", [target]: kept });
266
+ }));
267
+ const changed: string[] = [];
268
+ const watch = System.create({ components: { with: [label], changed: [label] } }, row => { changed.push(row[label]); });
269
+ await other.run(watch);
270
+ changed.length = 0;
271
+ const { entities, dropped } = await spawnTables(other, archetypes, new Map([[steel, mine]]), true);
272
+ expect([dropped, entities.flatMap(list => [...list]).includes(mine)]).toEqual([0, true]);
273
+ const read = await readAll(other);
274
+ const inside = read.find(entry => entry.label === "inside")?.entity;
275
+ // The weak reference to an entity outside the tables is left out, so the entity's own stands.
276
+ expect(read.find(entry => entry.entity === mine)).toMatchObject({ label: "steel", position: [1, 2, 3], buddy: inside, target: kept, link: { to: inside, weight: 4 } });
277
+ const teams = await other.run(System.create({ readonly: true, tables: { components: [team, hidden] } }, ({ tables }) => tables.archetypes.map(table => [table.columns[team], table.columns[hidden] !== undefined])));
278
+ expect(teams).toEqual([[{ kind: "value", value: "red" }, true]]);
279
+ await other.run(watch);
280
+ expect(changed.sort()).toEqual(["inside", "steel"]);
281
+ });
282
+
283
+ it("refuse existing mapping to an entity that isn't alive, spawning nothing", async () => {
284
+ await using world = inline();
285
+ const gone = (await spawnLabels(world, ["gone"])).get("gone") ?? Entity.none;
286
+ await world.run(System.create(({ commands }) => { commands.despawn(gone); }));
287
+ const { archetypes } = await world.run(tablesOf([label]));
288
+ await using source = inline();
289
+ const steel = (await spawnLabels(source, ["steel"])).get("steel") ?? Entity.none;
290
+ const { archetypes: tables } = await source.run(tablesOf([label]));
291
+ expect(archetypes).toEqual([]);
292
+ await expect(spawnTables(world, tables, new Map([[steel, gone]]))).rejects.toThrow(new RegExp(`as entity ${gone}, which isn't alive`));
293
+ expect(await readAll(world)).toEqual([]);
294
+ });
295
+
201
296
  it("set the resources they're given at the sync point, beside the entities", async () => {
202
297
  await using world = createWorld({ [workers]: 0, [score]: 7 });
203
298
  given.tables = [];
@@ -249,6 +344,46 @@ describe("tables", () => {
249
344
  expect(await readAll(world)).toEqual([]);
250
345
  });
251
346
 
347
+ it("keep to the archetypes holding where's shared values, each required as with requires it", async () => {
348
+ await using world = inline();
349
+ await world.run(System.create(({ commands }) => {
350
+ commands.spawn([team, label], { [team]: "red" })({ [label]: "red" });
351
+ commands.spawn([team, label, position], { [team]: "red" })({ [label]: "red placed", [position]: [1, 1, 1] });
352
+ commands.spawn([team, label], { [team]: "blue" })({ [label]: "blue" });
353
+ commands.spawn([label])({ [label]: "none" });
354
+ }));
355
+ const red = await world.run(System.create({ readonly: true, tables: { components: [label], where: { [team]: "red" } } }, ({ tables }): Tables => tables));
356
+ expect(red.archetypes.map(table => table.columns[label]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))))
357
+ .toEqual([{ kind: "values", values: ["red placed"] }, { kind: "values", values: ["red"] }]);
358
+ const placed = await world.run(System.create({ readonly: true, tables: { components: [label], with: [position], where: { [team]: "red" } } }, ({ tables }): Tables => tables));
359
+ expect(placed.archetypes.map(table => table.columns[label])).toEqual([{ kind: "values", values: ["red placed"] }]);
360
+ // One system's tables follow its archetypes as they come and go.
361
+ const blue = System.create({ readonly: true, tables: { components: [label], where: { [team]: "blue" } } }, ({ tables }): Tables => tables);
362
+ expect((await world.run(blue)).archetypes.map(table => table.count)).toEqual([1]);
363
+ await world.run(System.create(({ commands }) => {
364
+ commands.spawn([team, label, position], { [team]: "blue" })({ [label]: "blue placed", [position]: [2, 2, 2] });
365
+ }));
366
+ expect((await world.run(blue)).archetypes.map(table => table.count)).toEqual([1, 1]);
367
+ });
368
+
369
+ it("give no table for a shared value no archetype holds", async () => {
370
+ await using world = inline();
371
+ await world.run(System.create(({ commands }) => { commands.spawn([team, label], { [team]: "red" })({ [label]: "red" }); }));
372
+ const blue = System.create({ readonly: true, tables: { components: [label], where: { [team]: "blue" } } }, ({ tables }): Tables => tables);
373
+ expect((await world.run(blue)).archetypes).toEqual([]);
374
+ await world.run(System.create(({ commands }) => { commands.spawn([team, label], { [team]: "blue" })({ [label]: "blue" }); }));
375
+ expect((await world.run(blue)).archetypes.map(table => table.columns[label])).toEqual([{ kind: "values", values: ["blue"] }]);
376
+ });
377
+
378
+ it("take only values of shared components in where", () => {
379
+ // @ts-expect-error: where names only shared components.
380
+ expect(() => System.create({ readonly: true, tables: { components: [label], where: { [label]: "a" } } }, () => {})).toThrow(/tables' where names "tablesTest\/label", which isn't a shared component/);
381
+ // @ts-expect-error: tables' where takes values, not predicates.
382
+ expect(() => System.create({ readonly: true, tables: { components: [label], where: { [team]: (value: string) => value === "red" } } }, () => {})).toThrow(/tables' where gives "tablesTest\/team" a predicate/);
383
+ // @ts-expect-error: a value of the component's type.
384
+ expect(() => System.create({ readonly: true, tables: { components: [label], where: { [team]: "green" } } }, () => {})).toThrow(/isn't one of its type's values/);
385
+ });
386
+
252
387
  it("are read only by a readonly system without components", () => {
253
388
  // @ts-expect-error: tables are read only by a readonly system, which the declaration's type says too.
254
389
  expect(() => System.create({ tables: { components: [label] } }, () => {})).toThrow(/reads tables, which only a readonly system reads/);
@@ -1,14 +1,15 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
- import type { Layout, Path } from '../../interface/registry/layout.js';
2
+ import { viewsOf, type Layout, type Path } from '../../interface/registry/layout.js';
3
3
  import { declarationOf, labelOf } from '../../interface/registry/names.js';
4
4
  import { isEntity } from '../../interface/registry/schemas.js';
5
5
  import type { Schema } from '../../interface/schema/schema.js';
6
6
  import type { Table } from '../../interface/world/table/table.js';
7
7
  import { batchOf } from './batches.js';
8
- import { indexOf, reserveRun } from './entities.js';
8
+ import { indexOf, locate, reserveRun } from './entities.js';
9
9
  import { internShared } from './shared.js';
10
10
  import { claimRows, openRows, writerOf, type Column as BatchColumn } from './staging.js';
11
- import { commandIdOf, type Key, type State } from './state.js';
11
+ import { commandIdOf, storedShared, type Key, type State } from './state.js';
12
+ import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js';
12
13
 
13
14
  /** Whether a value of `schema` may hold an entity anywhere in it. */
14
15
  const holdsEntity = (schema: Schema): boolean => {
@@ -153,6 +154,12 @@ const placesOf = (plans: readonly Planned[]): { readonly entities: Float64Array;
153
154
  /** A reference column's entity value in each row: the f64 at the row's start, a reference's whole value. */
154
155
  const referencesOf = (column: Table.Column): ((row: number) => number) => {
155
156
  if (column.kind !== "bytes") return () => 0;
157
+ const { bytes } = column;
158
+ if (bytes.byteOffset % 8 === 0 && column.stride % 8 === 0) {
159
+ const view = new Float64Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 3);
160
+ const words = column.stride >> 3;
161
+ return row => view[row * words];
162
+ }
156
163
  const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength);
157
164
  const { stride } = column;
158
165
  return row => view.getFloat64(row * stride, true);
@@ -203,16 +210,64 @@ const spawnRows = (state: State, { table, keys, ids, shared }: Planned, rows: re
203
210
  claimRows(batch, entities);
204
211
  };
205
212
 
213
+ /** A linear value read out of a table's bytes, copied to row 0 here first; grown for larger layouts. */
214
+ let scratch = viewsOf(new ArrayBuffer(256), 0, 256);
215
+
216
+ /** A table's value of a component in a row: its column's one value, its value there, or one read out of its bytes. */
217
+ const valueIn = (layout: Layout, column: Table.Column, row: number): unknown => {
218
+ if (column.kind === "value") return column.value;
219
+ if (column.kind === "values") return column.values[row];
220
+ if (layout.kind !== "linear") return undefined;
221
+ const { stride } = layout;
222
+ if (stride > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(stride * 2), 0, stride * 2);
223
+ scratch.u8.set(column.bytes.subarray(row * stride, (row + 1) * stride));
224
+ return layout.read(scratch, 0);
225
+ };
226
+
227
+ /**
228
+ * Queues an add of each of its table's components onto the entity standing for a row `existing` maps, its entity
229
+ * values remapped, but a reference whose target isn't there.
230
+ */
231
+ const updateRows = (state: State, { table, keys, ids }: Planned, rows: readonly number[], standing: Float64Array, buffer: CommandBuffer, remap: Remap): void => {
232
+ const columns: Table.Column[] = keys.map(key => Reflect.get(table.columns, key));
233
+ const schemas = keys.map(key => declarationOf(key)?.type);
234
+ const references = keys.map(key => declarationOf(key)?.reference !== undefined);
235
+ for (const row of rows) {
236
+ pushCommand(buffer, addCommand, standing[row]);
237
+ for (let k = 0; k < keys.length; k++) {
238
+ const id = ids[k];
239
+ const schema = schemas[k];
240
+ let value = valueIn(state.layouts[id], columns[k], row);
241
+ if (references[k]) {
242
+ const to = remap(Number(value));
243
+ if (to === 0 && value !== 0) continue;
244
+ value = to;
245
+ }
246
+ else if (schema !== undefined && holdsEntity(schema)) value = remapValue(schema, value, remap);
247
+ pushComponent(buffer, id, state.shared[id] === undefined ? value : storedShared(state, id, value));
248
+ }
249
+ }
250
+ };
251
+
252
+ /** Throws for an entity `existing` maps to that isn't alive in this world. */
253
+ const checkExisting = (state: State, existing: ReadonlyMap<number, number>): void => {
254
+ for (const [source, target] of existing) {
255
+ if (locate(state.entities, target) < 0) throw new Error(`commands.spawnTables: entity ${source} is given as existing as entity ${target}, which isn't alive.`);
256
+ }
257
+ };
258
+
206
259
  /**
207
260
  * Spawns an entity for each row of `tables` into the batches of their components, as references would leave them had
208
261
  * the entities outside the tables despawned: a row owned, directly or through others, by an entity outside them isn't
209
262
  * spawned; a weak reference to one is removed; and any other entity value naming one is `Entity.none`. Every entity
210
- * value naming an entity spawned names the one spawned for it.
263
+ * value naming an entity spawned names the one spawned for it. A row whose entity `existing` maps isn't spawned: the
264
+ * entity it maps to stands for it, as it does for an entity outside the tables that `existing` maps.
211
265
  */
212
- export const spawnTables = (state: State, tables: readonly Table[]): Table.Spawned => {
266
+ export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap<number, number> = new Map(), updates: CommandBuffer | null = null): Table.Spawned => {
213
267
  const plans = tables.map(table => planOf(state, table));
214
- // Entities are mapped only where a value of the tables may hold one.
215
- const remapping = plans.some(({ keys }) => keys.some(key => {
268
+ checkExisting(state, existing);
269
+ // Entities are mapped only where a value of the tables may hold one, or a row is one that exists.
270
+ const remapping = existing.size > 0 || plans.some(({ keys }) => keys.some(key => {
216
271
  const schema = declarationOf(key)?.type;
217
272
  return schema !== undefined && holdsEntity(schema);
218
273
  }));
@@ -237,65 +292,87 @@ export const spawnTables = (state: State, tables: readonly Table[]): Table.Spawn
237
292
  firsts.push(total);
238
293
  total += table.count;
239
294
  }
295
+ // The entity standing for each row `existing` maps, 0 for a row spawned.
296
+ const existingAt = new Float64Array(total);
297
+ if (existing.size > 0) plans.forEach(({ table }, t) => {
298
+ for (let row = 0; row < table.count; row++) existingAt[firsts[t] + row] = existing.get(table.entities[row]) ?? 0;
299
+ });
240
300
  // A row owned by an entity outside the tables isn't spawned, nor anything it owns, as despawning its owner would.
241
301
  const dropped = new Uint8Array(total);
242
- /** Each owner reference between rows of the tables: the owned row's position, and its owner's. */
243
- const ownedRows: number[] = [];
244
- const ownerRows: number[] = [];
245
302
  const waiting: number[] = [];
246
- plans.forEach(({ table, keys }, t) => {
303
+ /**
304
+ * Calls `each` with each owner reference of a row that doesn't exist: the owned row's position, its owner's among
305
+ * the tables' rows or -1 for an owner outside them, and the owner.
306
+ */
307
+ const forEachOwner = (each: (position: number, at: number, owner: number) => void): void => plans.forEach(({ table, keys }, t) => {
247
308
  for (const key of keys) {
248
309
  if (declarationOf(key)?.reference !== "owner") continue;
249
310
  const ownerAt = referencesOf(Reflect.get(table.columns, key));
250
311
  for (let row = 0; row < table.count; row++) {
251
312
  const owner = ownerAt(row);
252
- if (owner === 0) continue;
253
- const at = positionOf(owner);
254
313
  const position = firsts[t] + row;
255
- if (at >= 0) {
256
- ownedRows.push(position);
257
- ownerRows.push(at);
258
- }
259
- else if (dropped[position] === 0) {
260
- dropped[position] = 1;
261
- waiting.push(position);
262
- }
314
+ // A row that exists stands, whatever owns it.
315
+ if (owner !== 0 && existingAt[position] === 0) each(position, positionOf(owner), owner);
263
316
  }
264
317
  }
265
318
  });
319
+ forEachOwner((position, at, owner) => {
320
+ if (at >= 0 || dropped[position] !== 0 || existing.has(owner)) return;
321
+ dropped[position] = 1;
322
+ waiting.push(position);
323
+ });
266
324
  if (waiting.length > 0) {
267
325
  const owned = new Map<number, number[]>();
268
- for (let i = 0; i < ownedRows.length; i++) {
269
- const list = owned.get(ownerRows[i]);
270
- if (list === undefined) owned.set(ownerRows[i], [ownedRows[i]]);
271
- else list.push(ownedRows[i]);
272
- }
326
+ forEachOwner((position, at) => {
327
+ if (at < 0) return;
328
+ const list = owned.get(at);
329
+ if (list === undefined) owned.set(at, [position]);
330
+ else list.push(position);
331
+ });
273
332
  for (let next = waiting.pop(); next !== undefined; next = waiting.pop()) {
274
333
  for (const child of owned.get(next) ?? []) {
275
- if (dropped[child] !== 0) continue;
334
+ if (dropped[child] !== 0 || existingAt[child] !== 0) continue;
276
335
  dropped[child] = 1;
277
336
  waiting.push(child);
278
337
  }
279
338
  }
280
339
  }
340
+ // Each row's entity in this world: the one spawned for it, the one existing for it, or 0 for one dropped.
281
341
  const spawnedAt = new Float64Array(total);
342
+ /** By table, whether every row of it is spawned: none exists or is dropped. */
343
+ const wholly: boolean[] = [];
282
344
  const entities = plans.map(({ table }, t) => {
345
+ const first = firsts[t];
346
+ const spawned = new Float64Array(table.count);
347
+ let standing = 0;
348
+ for (let row = 0; row < table.count; row++) if (existingAt[first + row] !== 0 || dropped[first + row] !== 0) standing++;
349
+ wholly.push(standing === 0);
350
+ if (standing === 0) {
351
+ reserveRun(state.entities, spawned);
352
+ spawnedAt.set(spawned, first);
353
+ return spawned;
354
+ }
283
355
  const alive = [];
284
- for (let row = 0; row < table.count; row++) if (dropped[firsts[t] + row] === 0) alive.push(row);
356
+ for (let row = 0; row < table.count; row++) {
357
+ const position = first + row;
358
+ if (existingAt[position] !== 0) spawnedAt[position] = spawned[row] = existingAt[position];
359
+ else if (dropped[position] === 0) alive.push(row);
360
+ }
285
361
  const reserved = new Float64Array(alive.length);
286
362
  reserveRun(state.entities, reserved);
287
- const spawned = new Float64Array(table.count);
288
363
  alive.forEach((row, i) => {
289
364
  spawned[row] = reserved[i];
290
- spawnedAt[firsts[t] + row] = reserved[i];
365
+ spawnedAt[first + row] = reserved[i];
291
366
  });
292
367
  return spawned;
293
368
  });
294
- const isSpawned = (entity: number): boolean => {
369
+ /** The entity in this world for one of the tables' or one `existing` maps, or 0 for one that isn't there. */
370
+ const entityFor = (entity: number): number => {
295
371
  const at = positionOf(entity);
296
- return at >= 0 && dropped[at] === 0;
372
+ return at >= 0 ? spawnedAt[at] : existing.get(entity) ?? 0;
297
373
  };
298
- const remap: Remap = entity => isSpawned(entity) ? spawnedAt[positionOf(entity)] : 0;
374
+ const isSpawned = (entity: number): boolean => entityFor(entity) !== 0;
375
+ const remap: Remap = entityFor;
299
376
  let droppedCount = 0;
300
377
  plans.forEach((plan, t) => {
301
378
  const { table, keys } = plan;
@@ -305,12 +382,18 @@ export const spawnTables = (state: State, tables: readonly Table[]): Table.Spawn
305
382
  const target = targetAt(row);
306
383
  return target !== 0 && !isSpawned(target);
307
384
  });
308
- let whole = true;
309
- for (let row = 0; row < table.count && whole; row++) if (dropped[first + row] !== 0 || loses(row)) whole = false;
385
+ if (updates !== null && !wholly[t]) {
386
+ const mapped: number[] = [];
387
+ for (let row = 0; row < table.count; row++) if (existingAt[first + row] !== 0) mapped.push(row);
388
+ if (mapped.length > 0) updateRows(state, plan, mapped, entities[t], updates, remap);
389
+ }
390
+ let whole = wholly[t];
391
+ if (weak.length > 0) for (let row = 0; row < table.count && whole; row++) if (loses(row)) whole = false;
310
392
  if (whole) return spawnRows(state, plan, null, none, entities[t], remap);
311
393
  // Rows by the weak references they lose, whose targets aren't spawned: each set of components a batch of its own.
312
394
  const groups = new Map<string, { readonly removed: Set<Key>; readonly rows: number[] }>();
313
395
  for (let row = 0; row < table.count; row++) {
396
+ if (existingAt[first + row] !== 0) continue;
314
397
  if (dropped[first + row] !== 0) {
315
398
  droppedCount++;
316
399
  continue;
@@ -69,7 +69,7 @@ const errorOf = (error: unknown): WorkerError =>
69
69
 
70
70
  const refuse = (): never => { throw new Error("An observer system issues no commands."); };
71
71
  /** An observer system's context has no commands, so these are never called. */
72
- const noCommands: Commands = { spawn: refuse, despawn: refuse, add: refuse, remove: refuse, spawnTables: refuse };
72
+ const noCommands: Commands = { spawn: refuse, despawn: refuse, despawnWhere: refuse, add: refuse, remove: refuse, spawnTables: refuse };
73
73
  const noStamps: number[] = [];
74
74
 
75
75
  export const createThreadObserver = (host: ThreadObserverHost) => {