@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
@@ -1,7 +1,7 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Archetype } from './archetype.js';
3
3
  import { readCell } from './cells.js';
4
- import { indexOf, locate } from './entities.js';
4
+ import { generationOf, indexOf, locate } from './entities.js';
5
5
  import { commandValue, isMissing } from './finalizers.js';
6
6
  import { createHolders, fitHolders, forEachHolder, linkHolder, unlinkHolder } from './holders.js';
7
7
  import { closureOf, owner } from './structure.js';
@@ -9,8 +9,8 @@ import { archetypeOf, withoutComponent, type State } from './state.js';
9
9
  import type { Queued } from './synchronize.js';
10
10
 
11
11
  /**
12
- * A reference value written in this sync point: by spawn or add commands, or a batch spawn. `holder`, `component`
13
- * and `target` triples, checked once the plan's despawns are known.
12
+ * A reference value written in this sync point to check: by spawn or add commands, or of a row spawned that `linkBorn`
13
+ * leaves to the cascade. `holder`, `component` and `target` triples, checked once the plan's despawns are known.
14
14
  */
15
15
  export type Written = number[];
16
16
 
@@ -41,6 +41,48 @@ const aliveAfter = (state: State, target: number): boolean => {
41
41
  return locate(entities, target) >= 0;
42
42
  };
43
43
 
44
+ /**
45
+ * Links each reference the rows spawned in this sync point hold to its target, before the cascade, so a target it
46
+ * despawns finds them as it finds any holder. One naming an entity that won't be alive isn't linked, and goes to
47
+ * `written` to cascade, as does every reference of a row a command touched, which the command may point elsewhere.
48
+ * The rows spawned into an archetype are those from `first`, by archetype id; an entity is one f64.
49
+ */
50
+ export const linkBorn = (state: State, born: ReadonlyMap<number, number>, written: Written, tick: number): void => {
51
+ const { plan, structure, archetypes, entities, referencesWritten } = state;
52
+ const { slots } = plan;
53
+ for (const [id, first] of born) {
54
+ const archetype = archetypes[id];
55
+ const { count } = archetype;
56
+ if (first >= count) continue;
57
+ const rows = archetype.entities;
58
+ for (const component of structure.referenceIds) {
59
+ const storage = archetype.storageById[component];
60
+ if (storage === undefined) continue;
61
+ referencesWritten[component] = tick;
62
+ const lists = structure.holders[component] ??= createHolders();
63
+ fitHolders(lists, entities.length);
64
+ const { unitStride, unitOffset } = storage;
65
+ const values = storage.views.f64;
66
+ // `aliveAfter`, through locals, since it runs for each row spawned.
67
+ const { archetype: archetypeOfIndex, generation, length } = entities;
68
+ const { entity: planned, despawned } = plan;
69
+ for (let row = first; row < count; row++) {
70
+ const target = values[row * unitStride + unitOffset];
71
+ if (target === 0) continue;
72
+ const holder = rows[row];
73
+ const index = indexOf(holder);
74
+ const commanded = index < slots.length && slots[index] >= 0;
75
+ const into = indexOf(target);
76
+ const entry = into < slots.length ? slots[into] : -1;
77
+ const alive = entry >= 0 && planned[entry] === target ? despawned[entry] === 0
78
+ : into < length && archetypeOfIndex[into] >= 0 && generation[into] === generationOf(target);
79
+ if (alive) linkHolder(lists, target, holder);
80
+ if (commanded || !alive) written.push(holder, component, target);
81
+ }
82
+ }
83
+ }
84
+ };
85
+
44
86
  /**
45
87
  * Despawns the holders that own a reference to an entity that is despawned or never lives, and removes `weak`
46
88
  * references to one, transitively, before rules close the plan. A holder acts only if its value once the plan applies
@@ -91,7 +133,7 @@ export const cascadeReferences = (state: State, queued: readonly Queued[], writt
91
133
  */
92
134
  export type IndexChanges = { readonly remove: number[]; readonly add: number[] };
93
135
 
94
- export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf: (entry: number) => Archetype | null, bornRows: readonly (readonly [Archetype, number])[]): IndexChanges => {
136
+ export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf: (entry: number) => Archetype | null): IndexChanges => {
95
137
  const { plan, structure, entities } = state;
96
138
  const changes: IndexChanges = { remove: [], add: [] };
97
139
  for (let entry = 0; entry < plan.count; entry++) {
@@ -106,24 +148,6 @@ export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf:
106
148
  if (typeof after === "number" && after !== 0) changes.add.push(after, holder, component);
107
149
  }
108
150
  }
109
- // Batch spawns no command touched still hold the references they were spawned with. An entity is one f64.
110
- const { slots } = plan;
111
- for (const [archetype, first] of bornRows) {
112
- for (const component of structure.referenceIds) {
113
- const storage = archetype.storageById[component];
114
- if (storage === undefined) continue;
115
- const { unitStride, unitOffset } = storage;
116
- const values = storage.views.f64;
117
- const rows = archetype.entities;
118
- for (let row = first; row < archetype.count; row++) {
119
- const holder = rows[row];
120
- const index = indexOf(holder);
121
- if (index < slots.length && slots[index] >= 0) continue;
122
- const value = values[row * unitStride + unitOffset];
123
- if (value !== 0) changes.add.push(value, holder, component);
124
- }
125
- }
126
- }
127
151
  return changes;
128
152
  };
129
153
 
@@ -1,10 +1,13 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Commands } from '../../interface/system/commands.js';
3
- import { addCommand, createBuffer, despawnCommand, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
3
+ import { addCommand, createBuffer, despawnCommand, despawnWhereCommand, despawnWhereHolds, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js';
4
+ import type { Archetype } from './archetype.js';
4
5
  import { reserve } from './entities.js';
5
6
  import { listShapeOf, presetSpawnerOf, spawnerOf, type ListShape } from './lists.js';
6
- import { commandIdOf, idOf, storedShared, type Key, type State } from './state.js';
7
+ import { commandIdOf, idOf, queryIdOf, storedShared, type Key, type State } from './state.js';
7
8
  import { spawnTables } from './tables.js';
9
+ import { holdsValuesOf } from './where.js';
10
+ import { checkWhereValues } from '../../interface/system/check.js';
8
11
  import { declarationOf, isWorldWritten, kindOf, labelOf, threadOfDeclaration, whyUnregistered } from '../../interface/registry/index.js';
9
12
  import { matchesSchema } from '../../interface/registry/schemas.js';
10
13
 
@@ -83,6 +86,32 @@ const queueResources = (state: State, resources: object): void => {
83
86
  /** Values of a list add given none, which only an untyped caller does. */
84
87
  const empty = {};
85
88
 
89
+ const noKeys: readonly unknown[] = [];
90
+
91
+ /**
92
+ * Queues a `despawnWhere`, its ids resolved and its `where` checked first, so a key that isn't a component, or a `where`
93
+ * that isn't of shared values, queues nothing.
94
+ */
95
+ const pushDespawnWhere = (state: State, buffer: CommandBuffer, query: Parameters<Commands["despawnWhere"]>[0]): void => {
96
+ const including: readonly unknown[] = query.with;
97
+ const excluding: readonly unknown[] = query.not ?? noKeys;
98
+ const { where } = query;
99
+ if (including.length === 0) throw new Error("commands.despawnWhere's `with` names at least one component.");
100
+ for (let k = 0; k < including.length; k++) queryIdOf(state, including[k]);
101
+ for (let k = 0; k < excluding.length; k++) queryIdOf(state, excluding[k]);
102
+ let holds: ((archetype: Archetype) => boolean) | null = null;
103
+ if (where !== undefined && Object.getOwnPropertySymbols(where).length > 0) {
104
+ checkWhereValues("commands.despawnWhere", "where", "a despawn", where, false);
105
+ const found = holdsValuesOf(state, where, false);
106
+ if (typeof found === "symbol") throw new Error(`commands.despawnWhere: where names "${labelOf(found)}", which isn't a shared component.`);
107
+ holds = found;
108
+ }
109
+ pushCommand(buffer, despawnWhereCommand, including.length);
110
+ for (let k = 0; k < including.length; k++) pushComponent(buffer, queryIdOf(state, including[k]), undefined);
111
+ for (let k = 0; k < excluding.length; k++) pushComponent(buffer, queryIdOf(state, excluding[k]), undefined);
112
+ if (holds !== null) pushComponent(buffer, despawnWhereHolds, holds);
113
+ };
114
+
86
115
  /**
87
116
  * Commands that queue for the next sync point. A key that isn't a component commands may name throws here, inside the
88
117
  * issuing system, before anything is queued. Values are immutable by convention, so they aren't copied.
@@ -97,6 +126,7 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB
97
126
  despawn: entity => {
98
127
  pushCommand(target.buffer, despawnCommand, entity);
99
128
  },
129
+ despawnWhere: query => pushDespawnWhere(state, target.buffer, query),
100
130
  add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => {
101
131
  const { buffer } = target;
102
132
  if (typeof components === "symbol") {
@@ -119,10 +149,12 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB
119
149
  pushComponent(buffer, ids[k], value === undefined ? starts[k] : value);
120
150
  }
121
151
  },
122
- spawnTables: (tables, resources) => {
123
- if (resources === undefined) return spawnTables(state, tables);
152
+ spawnTables: (tables, options) => {
153
+ const resources = options?.resources;
154
+ const updates = options?.update === true ? target.buffer : null;
155
+ if (resources === undefined) return spawnTables(state, tables, options?.existing, updates);
124
156
  checkResources(resources);
125
- const spawned = spawnTables(state, tables);
157
+ const spawned = spawnTables(state, tables, options?.existing, updates);
126
158
  queueResources(state, resources);
127
159
  return spawned;
128
160
  },
@@ -71,28 +71,28 @@ const positions = async (world: World): Promise<Vec2[]> =>
71
71
  (await readEach(world, await select(world, { required: [position], order: { [position]: true } }), position)).map(value => value ?? [NaN, NaN]);
72
72
 
73
73
  describe("World.create", () => {
74
- it("spawns once from a changed resource, then integrates", async () => {
74
+ it("spawns once from a changed resource, then integrates, from the run that spawns", async () => {
75
75
  const world = createWorld();
76
76
  await world.run(main);
77
- expect(await positions(world)).toEqual([[0, 0], [1, 0], [2, 0]]);
78
- await world.run(main);
79
77
  expect(await positions(world)).toEqual([[0, 1], [1, 1], [2, 1]]);
78
+ await world.run(main);
79
+ expect(await positions(world)).toEqual([[0, 2], [1, 2], [2, 2]]);
80
80
  expect(await readResource(world, spawnCount)).toBe(3);
81
81
  });
82
82
 
83
83
  it("reports each change to a system exactly once", async () => {
84
84
  const world = createWorld();
85
85
  await world.run(main);
86
- // Spawned at the sync point, after watch ran: seen on the next run.
87
- expect(await readResource(world, seen)).toBe(0);
88
- await world.run(main);
86
+ // Spawned at the sync point before move, which watch follows: seen in the same run.
89
87
  expect(await readResource(world, seen)).toBe(3);
88
+ await world.run(main);
89
+ expect(await readResource(world, seen)).toBe(6);
90
90
  // Moved before watch in the last run, so already reported.
91
91
  await world.run(watch);
92
- expect(await readResource(world, seen)).toBe(3);
92
+ expect(await readResource(world, seen)).toBe(6);
93
93
  await world.run(move);
94
94
  await world.run(watch);
95
- expect(await readResource(world, seen)).toBe(6);
95
+ expect(await readResource(world, seen)).toBe(9);
96
96
  });
97
97
 
98
98
  it("applies the net structural change of a sync point", async () => {
@@ -1,7 +1,7 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import type { Finalizer } from '../../interface/finalizer/created.js';
3
3
  import type { Rule } from '../../interface/rule/created.js';
4
- import { requireDefinition, type Definition } from '../../interface/registry/index.js';
4
+ import { definitionOf, requireDefinition, type Definition } from '../../interface/registry/index.js';
5
5
  import type { World } from '../../interface/world/world.js';
6
6
  import type { Input } from '../../interface/system/input.js';
7
7
  import type { System } from '../../interface/system/system.js';
@@ -33,8 +33,8 @@ import { refreshDirectories } from './directories.js';
33
33
  import { createRegistrar, type Registration } from './register.js';
34
34
  import type { Schedule } from '../../interface/registry/schedule.js';
35
35
  import { ensureResources, findResource, initialOf, setResource } from './resources.js';
36
- import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers } from './activate.js';
37
- import { isObserver, isWorldWritten, kindOf, programVersion, scheduleOf, type Composite } from '../../interface/registry/index.js';
36
+ import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers, programResourcesChanged, type Ensured } from './activate.js';
37
+ import { isObserver, isWorldWritten, kindOf, scheduleOf, type Composite } from '../../interface/registry/index.js';
38
38
  import { always } from '../../interface/resources/always.js';
39
39
  import { cadence, type Cadence } from '../../interface/resources/cadence.js';
40
40
  import { clock } from '../../interface/resources/clock.js';
@@ -43,6 +43,10 @@ import { stampChanged } from './archetype.js';
43
43
  import { createLoop, platformFrame, platformTimer, type Clock, type LoopPlatform } from './loop.js';
44
44
  import { presetLevelsOf } from './lists.js';
45
45
  import { createState, defaultOf, idOf, reside, startTicking, type Key } from './state.js';
46
+ import { asksForChanges, changesOf, resourcesWrittenBy, startRecording, takesInput } from './run-changes.js';
47
+ import type { ChangeSet } from '../../interface/finalizer/body.js';
48
+ import type { Essential } from '../../interface/component/value.js';
49
+ import type { Resource } from '../../interface/resource/resource.js';
46
50
  import { indexOf } from './entities.js';
47
51
  import { touchedHeld } from './up.js';
48
52
  import { isHeld } from './transfers.js';
@@ -53,6 +57,9 @@ type Queue = <T>(task: () => T | Promise<T>) => Promise<T>;
53
57
 
54
58
  /** A task's failure as the queue's own chain carries it, which never rejects: made only when a task fails. */
55
59
  type Carried = { readonly thrown: unknown };
60
+ /** A schedule of nothing, whose run is its sync point alone. */
61
+ const noTiers: Schedule = { tiers: [], reductions: new Map(), observer: [], syncs: [] };
62
+
56
63
  const thrownValues = new WeakSet<object>();
57
64
  const thrownOf = (error: unknown): Carried => {
58
65
  const wrapped = { thrown: error };
@@ -277,7 +284,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
277
284
  if (state.observer.posts.length !== 0) await settlePosts(state, true);
278
285
  };
279
286
  let disposed = false;
280
- const ensured = { version: -1 };
287
+ const ensured: Ensured = { version: -1, keys: -1, traits: false };
281
288
 
282
289
  const minimumChunk = options.minimumChunk ?? defaultMinimumChunk;
283
290
 
@@ -538,7 +545,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
538
545
  /** Follows what changed since the last run: program resources, change watches, workers and the device; null when nothing did. */
539
546
  const prepareRun = (): Promise<void> | null => {
540
547
  // Resources added between runs move the resource entity, whose structs a tail may still read.
541
- if (ensured.version !== programVersion() && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare();
548
+ if (programResourcesChanged(ensured) && (state.channels.length > 0 || state.observer.posts.length > 0)) return drainThenPrepare();
542
549
  ensureProgramResources(state, ensured);
543
550
  if (startWatches(state)) return followChanges(true, false);
544
551
  if (workersChanged()) return followChanges(false, true);
@@ -956,9 +963,57 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti
956
963
  },
957
964
  });
958
965
 
966
+ /**
967
+ * Runs a system or composite, recording its own changes, which it resolves to: what earlier finalizers queued
968
+ * applies first, at a sync point of its own, and the recording covers the run's sync points alone.
969
+ */
970
+ const runChanging = (definition: Definition, value: unknown): Promise<unknown> => {
971
+ const started = enqueue(() => startChanges(definition));
972
+ const ran = runWith(definition, value);
973
+ const taken = enqueue(takeChanges);
974
+ return Promise.all([started, ran, taken]).then(([, , changes]) => changes);
975
+ };
976
+ const startChanges = (definition: Definition): unknown => {
977
+ checkLive();
978
+ const flushed = execution(definition, undefined, noTiers);
979
+ if (flushed instanceof Promise) return flushed.then(report => startRecordingOf(definition, report));
980
+ startRecordingOf(definition, flushed);
981
+ return undefined;
982
+ };
983
+ const startRecordingOf = (definition: Definition, flushed: RunReport): void => {
984
+ outOfBand.reportAll(flushed.failures);
985
+ state.recording = startRecording(state, resourcesWrittenBy(definition));
986
+ };
987
+ const takeChanges = (): unknown => {
988
+ const { recording } = state;
989
+ state.recording = null;
990
+ if (recording === null) throw new Error("The run's changes weren't recorded.");
991
+ return changesOf(state, recording);
992
+ };
993
+
994
+ function run<I, R>(system: System<I, R>, ...input: Input<NoInfer<I>>): Promise<R>;
995
+ function run<I, R>(system: System<I, R>, ...input: [...Input<NoInfer<I>>, options: { readonly changes: true }]): Promise<ChangeSet<Essential, Resource.Key>>;
996
+ function run(system: System<unknown, unknown>, ...args: readonly unknown[]): Promise<unknown> {
997
+ // Options come last, so a run not asking for changes reads one value more.
998
+ const { length } = args;
999
+ if (length === 0 || !asksForChanges(args[length - 1])) return runWith(system, args[0]);
1000
+ const definition = definitionOf(system);
1001
+ let input = false;
1002
+ try {
1003
+ input = definition !== undefined && takesInput(definition);
1004
+ }
1005
+ catch {
1006
+ // A composite whose schedule can't be made fails its run, as `runWith` finds.
1007
+ return runWith(system, args[0]);
1008
+ }
1009
+ // A system taking input whose input is all it was given runs with it.
1010
+ if (definition === undefined || (input && args.length < 2)) return runWith(system, args[0]);
1011
+ return runChanging(definition, input ? args[0] : undefined);
1012
+ }
1013
+
959
1014
  const world: CreatedWorld = {
960
1015
  // A run gives what its system's body returned, `R` by the system's type.
961
- run: <I, R>(system: System<I, R>, ...input: Input<NoInfer<I>>) => runWith(system, input[0]) as Promise<R>,
1016
+ run,
962
1017
  share: port => {
963
1018
  if (disposed) return Promise.reject(new Error("The world is disposed."));
964
1019
  if (!state.memory.shared) return Promise.reject(new Error("Without cross-origin isolation memory can't be shared, so no thread can join."));
@@ -4,7 +4,7 @@ import { Rule } from "../../interface/rule/rule.js";
4
4
  import { System } from "../../interface/system/system.js";
5
5
  import { createWorld } from "../index.js";
6
6
  import { readResource } from "./reads.test-helper.js";
7
- import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../index.js";
7
+ import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4, type Resource } from "../../index.js";
8
8
 
9
9
  const pool = Symbol();
10
10
  const names = Symbol();
@@ -14,6 +14,8 @@ const index = Symbol();
14
14
  const turn = Symbol();
15
15
  const least = Symbol();
16
16
  const all = Symbol();
17
+ const written = Symbol();
18
+ const keys = Symbol();
17
19
 
18
20
  /** A pool declared through an alias of ReadonlyMap. */
19
21
  type Pool = ReadonlyMap<string, number>;
@@ -30,6 +32,8 @@ declare module "@adobe/ecs" {
30
32
  [turn]: { type: Quat };
31
33
  [least]: { type: F32; reduce: "min" };
32
34
  [all]: { type: boolean; reduce: "&" };
35
+ [written]: { type: ReadonlyMap<Resource.Key, unknown> };
36
+ [keys]: { type: ReadonlySet<symbol> };
33
37
  }
34
38
  }
35
39
 
@@ -49,6 +53,25 @@ describe("a resource declared without a default", () => {
49
53
  });
50
54
  });
51
55
 
56
+ describe("a ReadonlyMap or ReadonlySet of what isn't data", () => {
57
+ it("starts at an empty one of each world's own, which a system replaces whole, keys and values kept as they are", async () => {
58
+ await using first = createWorld();
59
+ await using second = createWorld();
60
+ const [firstWritten, secondWritten] = [await readResource(first, written), await readResource(second, written)];
61
+ expect([firstWritten, secondWritten, await readResource(first, keys)]).toEqual([new Map(), new Map(), new Set()]);
62
+ expect(firstWritten).not.toBe(secondWritten);
63
+ const value = { at: 1 };
64
+ await first.run(System.create(({ resources }) => {
65
+ resources[written] = new Map([[count, value]]);
66
+ resources[keys] = new Set([count]);
67
+ }));
68
+ const read = await readResource(first, written);
69
+ expect([read.get(count), await readResource(first, keys)]).toEqual([value, new Set([count])]);
70
+ expect(read.get(count)).toBe(value);
71
+ expect(await readResource(second, written)).toBe(secondWritten);
72
+ });
73
+ });
74
+
52
75
  describe("a derived component declared without a default", () => {
53
76
  it("starts every row of a world at one empty Map of its own, since values are replaced whole", async () => {
54
77
  const spawn = System.create(({ commands }) => {