@oasys/oecs 0.5.3 → 0.5.4

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 (185) hide show
  1. package/CHANGELOG.md +194 -28
  2. package/README.md +198 -145
  3. package/dist/core/ecs/access_check.d.cts +3 -3
  4. package/dist/core/ecs/access_check.d.ts +3 -3
  5. package/dist/core/ecs/archetype.d.cts +103 -43
  6. package/dist/core/ecs/archetype.d.ts +103 -43
  7. package/dist/core/ecs/archetype.d.ts.map +1 -1
  8. package/dist/core/ecs/archetype_graph.d.cts +8 -8
  9. package/dist/core/ecs/archetype_graph.d.ts +8 -8
  10. package/dist/core/ecs/archetype_graph.d.ts.map +1 -1
  11. package/dist/core/ecs/builtin_relations.d.cts +1 -1
  12. package/dist/core/ecs/builtin_relations.d.ts +1 -1
  13. package/dist/core/ecs/command_log.d.cts +4 -4
  14. package/dist/core/ecs/command_log.d.ts +4 -4
  15. package/dist/core/ecs/command_log.d.ts.map +1 -1
  16. package/dist/core/ecs/component.d.cts +4 -4
  17. package/dist/core/ecs/component.d.ts +4 -4
  18. package/dist/core/ecs/deferred_commands.d.cts +1 -1
  19. package/dist/core/ecs/deferred_commands.d.ts +1 -1
  20. package/dist/core/ecs/deferred_commands.d.ts.map +1 -1
  21. package/dist/core/ecs/ecs.d.cts +85 -43
  22. package/dist/core/ecs/ecs.d.ts +85 -43
  23. package/dist/core/ecs/ecs.d.ts.map +1 -1
  24. package/dist/core/ecs/ecs_memory.d.cts +10 -10
  25. package/dist/core/ecs/ecs_memory.d.ts +10 -10
  26. package/dist/core/ecs/ecs_memory.d.ts.map +1 -1
  27. package/dist/core/ecs/entity_allocator.d.cts +6 -6
  28. package/dist/core/ecs/entity_allocator.d.ts +6 -6
  29. package/dist/core/ecs/entity_allocator.d.ts.map +1 -1
  30. package/dist/core/ecs/event.d.cts +2 -2
  31. package/dist/core/ecs/event.d.ts +2 -2
  32. package/dist/core/ecs/event_registry.d.cts +1 -1
  33. package/dist/core/ecs/event_registry.d.ts +1 -1
  34. package/dist/core/ecs/event_registry.d.ts.map +1 -1
  35. package/dist/core/ecs/facades.d.cts +15 -15
  36. package/dist/core/ecs/facades.d.ts +15 -15
  37. package/dist/core/ecs/facades.d.ts.map +1 -1
  38. package/dist/core/ecs/frame_trace.d.cts +2 -2
  39. package/dist/core/ecs/frame_trace.d.ts +2 -2
  40. package/dist/core/ecs/host_commands.d.cts +12 -12
  41. package/dist/core/ecs/host_commands.d.ts +12 -12
  42. package/dist/core/ecs/host_commands.d.ts.map +1 -1
  43. package/dist/core/ecs/index.d.cts +1 -1
  44. package/dist/core/ecs/index.d.ts +1 -1
  45. package/dist/core/ecs/index.d.ts.map +1 -1
  46. package/dist/core/ecs/observer.d.cts +9 -9
  47. package/dist/core/ecs/observer.d.ts +9 -9
  48. package/dist/core/ecs/observer.d.ts.map +1 -1
  49. package/dist/core/ecs/query.d.cts +91 -70
  50. package/dist/core/ecs/query.d.ts +91 -70
  51. package/dist/core/ecs/query.d.ts.map +1 -1
  52. package/dist/core/ecs/ref.d.cts +53 -1
  53. package/dist/core/ecs/ref.d.ts +53 -1
  54. package/dist/core/ecs/ref.d.ts.map +1 -1
  55. package/dist/core/ecs/relation.d.cts +15 -15
  56. package/dist/core/ecs/relation.d.ts +15 -15
  57. package/dist/core/ecs/relation.d.ts.map +1 -1
  58. package/dist/core/ecs/relation_service.d.cts +14 -14
  59. package/dist/core/ecs/relation_service.d.ts +14 -14
  60. package/dist/core/ecs/relation_service.d.ts.map +1 -1
  61. package/dist/core/ecs/resource.d.cts +1 -1
  62. package/dist/core/ecs/resource.d.ts +1 -1
  63. package/dist/core/ecs/resource_registry.d.cts +2 -2
  64. package/dist/core/ecs/resource_registry.d.ts +2 -2
  65. package/dist/core/ecs/resume.d.cts +3 -3
  66. package/dist/core/ecs/resume.d.ts +3 -3
  67. package/dist/core/ecs/resume.d.ts.map +1 -1
  68. package/dist/core/ecs/schedule.d.cts +22 -9
  69. package/dist/core/ecs/schedule.d.ts +22 -9
  70. package/dist/core/ecs/schedule.d.ts.map +1 -1
  71. package/dist/core/ecs/snapshot_service.d.cts +3 -3
  72. package/dist/core/ecs/snapshot_service.d.ts +3 -3
  73. package/dist/core/ecs/sparse_store.d.cts +6 -6
  74. package/dist/core/ecs/sparse_store.d.ts +6 -6
  75. package/dist/core/ecs/sparse_store.d.ts.map +1 -1
  76. package/dist/core/ecs/store.d.cts +189 -110
  77. package/dist/core/ecs/store.d.ts +189 -110
  78. package/dist/core/ecs/store.d.ts.map +1 -1
  79. package/dist/core/ecs/store_layout_listener.d.cts +2 -2
  80. package/dist/core/ecs/store_layout_listener.d.ts +2 -2
  81. package/dist/core/ecs/system.d.cts +8 -8
  82. package/dist/core/ecs/system.d.ts +8 -8
  83. package/dist/core/ecs/system.d.ts.map +1 -1
  84. package/dist/core/ecs/utils/error.d.cts +7 -0
  85. package/dist/core/ecs/utils/error.d.ts +7 -0
  86. package/dist/core/ecs/utils/error.d.ts.map +1 -1
  87. package/dist/core/store/action_ring.d.cts +7 -8
  88. package/dist/core/store/action_ring.d.ts +7 -8
  89. package/dist/core/store/action_ring.d.ts.map +1 -1
  90. package/dist/core/store/allocator.d.cts +26 -28
  91. package/dist/core/store/allocator.d.ts +26 -28
  92. package/dist/core/store/allocator.d.ts.map +1 -1
  93. package/dist/core/store/buffer_backed_column.d.cts +5 -6
  94. package/dist/core/store/buffer_backed_column.d.ts +5 -6
  95. package/dist/core/store/buffer_backed_column.d.ts.map +1 -1
  96. package/dist/core/store/column_store.d.cts +22 -22
  97. package/dist/core/store/column_store.d.ts +22 -22
  98. package/dist/core/store/column_store.d.ts.map +1 -1
  99. package/dist/core/store/command_dispatch.d.cts +1 -1
  100. package/dist/core/store/command_dispatch.d.ts +1 -1
  101. package/dist/core/store/command_ring.d.cts +6 -7
  102. package/dist/core/store/command_ring.d.ts +6 -7
  103. package/dist/core/store/command_ring.d.ts.map +1 -1
  104. package/dist/core/store/descriptor.d.cts +2 -2
  105. package/dist/core/store/descriptor.d.ts +2 -2
  106. package/dist/core/store/entity_index.d.cts +3 -3
  107. package/dist/core/store/entity_index.d.ts +3 -3
  108. package/dist/core/store/event_ring.d.cts +7 -7
  109. package/dist/core/store/event_ring.d.ts +7 -7
  110. package/dist/core/store/extend.d.cts +2 -2
  111. package/dist/core/store/extend.d.ts +2 -2
  112. package/dist/core/store/grow.d.cts +1 -1
  113. package/dist/core/store/grow.d.ts +1 -1
  114. package/dist/core/store/grow.d.ts.map +1 -1
  115. package/dist/core/store/header.d.cts +9 -9
  116. package/dist/core/store/header.d.ts +9 -9
  117. package/dist/core/store/header.d.ts.map +1 -1
  118. package/dist/core/store/layout_ops.d.cts +7 -7
  119. package/dist/core/store/layout_ops.d.ts +7 -7
  120. package/dist/core/store/state_hash.d.cts +3 -3
  121. package/dist/core/store/state_hash.d.ts +3 -3
  122. package/dist/extensions/editor/editor.d.cts +4 -4
  123. package/dist/extensions/editor/editor.d.ts +4 -4
  124. package/dist/extensions/editor/editor.d.ts.map +1 -1
  125. package/dist/extensions/editor/field_handle.d.cts +1 -1
  126. package/dist/extensions/editor/field_handle.d.ts +1 -1
  127. package/dist/extensions/editor/index.d.cts +2 -2
  128. package/dist/extensions/editor/index.d.ts +2 -2
  129. package/dist/extensions/editor/index.development.js +4 -4
  130. package/dist/extensions/editor/index.js +4 -4
  131. package/dist/extensions/reactive/ecs_sync.d.cts +4 -4
  132. package/dist/extensions/reactive/ecs_sync.d.ts +4 -4
  133. package/dist/extensions/reactive/index.d.cts +4 -4
  134. package/dist/extensions/reactive/index.d.ts +4 -4
  135. package/dist/extensions/reactive/index.development.js +3 -3
  136. package/dist/extensions/reactive/index.js +3 -3
  137. package/dist/extensions/solid/index.d.cts +1 -1
  138. package/dist/extensions/solid/index.d.ts +1 -1
  139. package/dist/extensions/solid/index.development.js +1 -1
  140. package/dist/extensions/solid/index.js +1 -1
  141. package/dist/extensions/solid/kernel_solid.d.cts +1 -1
  142. package/dist/extensions/solid/kernel_solid.d.ts +1 -1
  143. package/dist/extensions/solid/kernel_solid.d.ts.map +1 -1
  144. package/dist/{host_commands-3jyFfWNg.js → host_commands-BlR3oZ3x.js} +472 -392
  145. package/dist/host_commands-C2cpU3yC.cjs +1 -0
  146. package/dist/index.cjs +1 -1
  147. package/dist/index.d.cts +1 -1
  148. package/dist/index.d.ts +1 -1
  149. package/dist/index.d.ts.map +1 -1
  150. package/dist/index.development.cjs +1 -1
  151. package/dist/index.development.js +3344 -2965
  152. package/dist/index.js +1494 -1132
  153. package/dist/{internal-C9jjL90H.js → internal-BoxWl4dG.js} +409 -329
  154. package/dist/internal-CGBEwfeB.cjs +2 -0
  155. package/dist/internal.cjs +1 -1
  156. package/dist/internal.development.cjs +1 -1
  157. package/dist/internal.development.js +1 -1
  158. package/dist/internal.js +1 -1
  159. package/dist/primitives.development.js +1 -1
  160. package/dist/primitives.js +1 -1
  161. package/dist/reactive/index.d.cts +2 -2
  162. package/dist/reactive/index.d.ts +2 -2
  163. package/dist/reactive/index.development.js +1 -1
  164. package/dist/reactive/index.js +1 -1
  165. package/dist/reactive/kernel.d.cts +5 -5
  166. package/dist/reactive/kernel.d.ts +5 -5
  167. package/dist/reactive/shallow.d.cts +1 -1
  168. package/dist/reactive/shallow.d.ts +1 -1
  169. package/dist/{shallow-DfnAm9vG.js → shallow-Bs39EPh1.js} +1 -1
  170. package/dist/{shallow-9S1pY_Iw.js → shallow-CiFC70Dz.js} +1 -1
  171. package/dist/{shared-DQKK0i-E.js → shared-B4v0L1cw.js} +4 -4
  172. package/dist/shared-DU78k0pU.cjs +1 -0
  173. package/dist/shared.cjs +1 -1
  174. package/dist/shared.development.cjs +1 -1
  175. package/dist/shared.development.js +1 -1
  176. package/dist/shared.js +1 -1
  177. package/dist/type_primitives/typed_arrays/typed_arrays.d.cts +2 -2
  178. package/dist/type_primitives/typed_arrays/typed_arrays.d.ts +2 -2
  179. package/dist/{typed_arrays-BcXcnzB4.js → typed_arrays-DIVwVy5_.js} +1 -1
  180. package/dist/version.d.cts +2 -2
  181. package/dist/version.d.ts +2 -2
  182. package/package.json +1 -1
  183. package/dist/host_commands-DNf0f6ko.cjs +0 -1
  184. package/dist/internal-RZN14uMw.cjs +0 -2
  185. package/dist/shared-BMXh9hxm.cjs +0 -1
package/CHANGELOG.md CHANGED
@@ -5,28 +5,193 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.4] — 2026-07-31
9
+
10
+ ### Added — `cursor` and `cursorRead`, the accessor for a sweep by id
11
+
12
+ `ecs.cursor(def)` and `ecs.cursorRead(def)`, with the two equivalents on `ctx`, give a single-entity
13
+ accessor that you can **point again**. You create it one time, and then you aim it with
14
+ `at(entity)`.
15
+
16
+ ```ts
17
+ const p = ctx.cursor(Pos);
18
+ for (let i = 0; i < ids.length; i++) {
19
+ p.at(ids[i]);
20
+ p.x += p.y * dt;
21
+ }
22
+ ```
23
+
24
+ The engine creates a ref for each entity. Over a list of ids, the loop then discards each ref that
25
+ it created. A cursor lifts that creation out of the loop: `at` writes the archetype, the offset, and
26
+ the row, and nothing else. A cursor also resolves the position of each field when you create it, so
27
+ a read does not look up a field name.
28
+
29
+ A cursor is **safer than a ref that you hold**, and not more dangerous. `at` resolves the archetype
30
+ and the row again on each call. So a structural change between two `at` calls cannot make the cursor
31
+ read a different entity. A cursor also follows an entity that changes archetype, which a ref cannot
32
+ do. Only the window between one `at` call and the reads that follow it must be free of structural
33
+ change.
34
+
35
+ In a development build, a cursor makes its declared-access check on **each `at`**, and not only when
36
+ you create it. `cursor` makes a write check, and `cursorRead` makes a read check. The check is on
37
+ `at` because you keep a cursor, and a cursor can therefore outlive the system that made it. A cursor
38
+ that you make outside a system, or in a different system, is checked against the system that
39
+ **uses** it. An `at` outside every system makes no check, because no system can hold the fault. A
40
+ production build removes this check, as it removes each of the other development guards.
41
+
42
+ A cursor obeys the rules of the family: the definition comes first, the mutable name has no suffix,
43
+ and the read-only name has the `Read` suffix. There is one constraint. A component with a field
44
+ named `at` collides with the method of the cursor. Creation of the cursor rejects that component,
45
+ and the message says so.
46
+
47
+ See [refs and cursors](docs/api/refs.md#cursors--many-entities-by-id).
48
+
49
+ ### Added — `ECS_ERROR.ARCHETYPE_ROW_INVARIANT`
50
+
51
+ This error reports that the row bookkeeping of an archetype does not agree with its backing columns.
52
+ There are three causes. A reserve did not give the capacity that the engine asked for. A restore
53
+ gave a partition boundary that is out of range. Or a cached row plane points at a buffer that is no
54
+ longer current. The error is for development builds only. It reports a failure of an internal
55
+ invariant, and not a mistake by the caller. That is what makes it different from
56
+ `STORE_CAP_EXCEEDED`, which is the allocator that refuses a legitimate grow. Two development
57
+ assertions that reported the general `COMPONENT_NOT_REGISTERED` now use it.
58
+
59
+ ### Changed — structural churn, system dispatch, and fragmented iteration
60
+
61
+ No signature changed on a function that exists, and no result changed. Each entry below is a change
62
+ to the internal mechanism. What moved:
63
+
64
+ - **The row plane of an archetype.** Row placement went through the `ColumnBacking` API (`push`,
65
+ `swapRemove`, and `pop`). That API costs three things **for each column and for each row**: a call
66
+ to the `.buf` accessor, a comparison against the capacity, and a load and store of `_len`. The
67
+ actual work is one move of an element. But `Archetype.length` is already the row count of every
68
+ column. So the archetype now indexes cached raw views (`_bufs[i][row]`), and it moves `length` one
69
+ time. The probe for an overflow on an append becomes one comparison against a cached capacity.
70
+ - **`eachChunk` no longer refreshes a column group on each call.** `cols.mut(def)` and
71
+ `cols.read(def)` used one cached object for each (archetype, component) pair. But they wrote one
72
+ property for each field on *every* call, and a fragmented pass makes that call one time for each
73
+ chunk. Only `_syncRowPlane` can change the identity of the buffer of a column. So `_syncRowPlane`
74
+ now points the cached groups at the current buffers, and the accessors make no test for a stale
75
+ buffer.
76
+ - **`readField` indexes the row plane.** It reads `_bufs[i][row]`, and not
77
+ `_flatColumns[i].buf[row]`. This removes a `.buf` accessor whose concrete type is different for a
78
+ heap column and for a `SharedArrayBuffer` column.
79
+ - **The last-run ticks of the schedule.** `systemLastRun` was a `Map<SystemDescriptor, number>`, and
80
+ a phase read it and wrote it one time for each system. It is now a packed array that a slot, local
81
+ to the schedule, indexes. The slots travel in the cached phase plan, next to the sorted
82
+ descriptors. `hasFixedSystems()` holds the node list of `FIXED_UPDATE` directly, and it makes no
83
+ lookup by key for each frame.
84
+ - **One probe of the edge for a single add or remove.** `addComponent` made four lookups before it
85
+ touched a row: `mask.has`, `archResolveAdd`, which read both again, `archGet`, and then a second
86
+ `getEdge` for the transition map. An `edge.add` value that is not null means exactly "this
87
+ archetype does not hold the component, and the destination is resolved". So one probe of the holey
88
+ `edges` array answers all of it. The first-sight case and the overwrite-in-place case move to a
89
+ cold tail. `removeComponent` is the mirror image.
90
+ - **One resolve of liveness and index, in place of two derivations.** `Store.hasComponent` called
91
+ `isAlive`, which derived the packed entity index. It then derived that index a second time to
92
+ reach `entityArchetype`. The read of the generations also went through a call into the allocator.
93
+ Both now fold into one `_liveIndex`. `getEntityArchetype` and `getEntityRow` become one
94
+ `resolveEntity`.
95
+ - **`ecs.query(...)` allocates nothing when the cache holds the query.** The caller copied its
96
+ scratch mask before it gave that mask to the resolver, and the resolver copies each mask that it
97
+ keeps. That copy made a `BitSet` and a backing `number[]` for each call, for nothing. The contract
98
+ for the borrowed mask is now written at the resolver.
99
+ - **`clearEvents` returns immediately when the frame emitted no event.** A write of `length` on an
100
+ array is a property store, and V8 does not remove that store for an array that is already empty.
101
+ This ran one time for each `update()` call, and most phases emit no event.
102
+
103
+ ### Changed — diagnostic vocabulary catch-up (the deferred snake_case remnants)
104
+
105
+ - **BREAKING (diagnostics):** `ECS.memoryPlan.source` now reports `"maxBytes"` instead of
106
+ `"max_bytes"` for the arm with the explicit byte cap. The name now agrees with the option key
107
+ that it names. Every other arm already agreed: `budget`, `heap`, `shared`, `wasm`, `allocator`,
108
+ and `default`.
109
+ - The `INVALID_MEMORY_OPTIONS` messages and the `memoryPlan.derivation` trace now name the options
110
+ by their real camelCase keys (`columnCapacity`, `entityIndex`, `budget.bytesPerEntity`,
111
+ `wasm.maximumPages`, `wasm.initialPages`, and `capBytesHint`), and not by the snake_case
112
+ spellings from before 0.4.
113
+
114
+ ### Fixed — row-plane and schedule-slot correctness under the new caches
115
+
116
+ - **A grow that throws no longer leaves the row plane on a buffer that the engine released.** The
117
+ reserve grows the entity-id array on the heap before it asks the store to grow the columns. So
118
+ when a refusal of the `SharedArrayBuffer` cap threw out of the grow handler, the cached entity-id
119
+ view pointed at a buffer that the engine had released, and the array itself had moved. The world
120
+ must survive a refusal of the cap, because that refusal is the basis of the fail-closed `spawn`
121
+ and `spawnMany` contract. But a later append that fitted the stale capacity wrote its entity id
122
+ into the released buffer. The next re-sync then put in the buffer that never received that row. A
123
+ re-sync occurs on a successful grow, and on the `refreshViews` call that each new archetype
124
+ causes. The id then read back as `0`, and the swap-remove that followed corrupted the row pointer
125
+ of a *different live entity*. The engine now derives the plane again on the path that throws.
126
+ - **A shortfall of capacity in the entity-id array alone no longer causes a reallocation of the
127
+ full store.** The cached row capacity is the smaller of two values: the capacity of the entity-id
128
+ array, and the capacity of every column. But the *decision to grow* belongs to the column term
129
+ alone. A decision on the smaller value sent a shortfall to the store when only the entity-id array
130
+ had one, and that array had already grown. The store then calculated a capacity that did not
131
+ change, and it found no column to resize. But it still did a full snapshot, create, and restore of
132
+ the full column store, plus a `refreshViews` call on each archetype. It did all of that to resize
133
+ nothing. This is reachable after a restore from a snapshot, because a restore grows the entity-id
134
+ array to the *number* of restored rows, and not to its capacity.
135
+ - **A recycled `systemLastRun` slot can no longer be the slot of two systems.** A phase copies the
136
+ slot array of its plan into a local. So a system that you remove from inside that phase still runs
137
+ from the snapshot, and it still writes its last-run tick as it ends. Before this fix, the engine
138
+ could give that freed slot to a system that you added during the same drive. The write of the
139
+ removed system then landed on the tick of the new system. That write moved the `changed()` window
140
+ of the new system, and it gave no signal. The engine now recycles a slot only outside a running
141
+ drive. Between drives the slots recycle as before, so the array stays bounded.
142
+
143
+ ### Note — the lookup of a field name stays as it is
144
+
145
+ `getField` resolves a field name through the `_fieldIndex[cid][field]` table. The engine builds that
146
+ table with `Object.create(null)`, which puts it in dictionary mode. The investigation covered three
147
+ replacements: a `{}` literal, a `Map`, and interning of the names with a perfect hash for each
148
+ component. None of the three is better than the table that exists in a world that has many
149
+ components with different field names. The table stays, and a comment next to it records the
150
+ investigation. To remove the lookup, the caller must hold the ordinal of the field, and not its name.
151
+ `cursor` does exactly that for a sweep. To do the measurement again, use
152
+ `bench/vs/probe-fieldshape.mjs` and `bench/vs/probe-lookupcost.mjs`.
153
+
154
+ ### Docs
155
+
156
+ - `ARCHITECTURE.md` carries the 0.5.4 stamp. It now describes the row plane of an archetype, the
157
+ packed last-run slots of the schedule, and the `ARCHETYPE_ROW_INVARIANT` assertion.
158
+ - `ARCHITECTURE.md` gives **no line numbers**. A reference names its source file only, because a
159
+ line number becomes incorrect as the source changes. To find a claim, search for the name of the
160
+ symbol next to the reference.
161
+ - The documentation gives no benchmark figures. Each entry describes what changed.
162
+ - `ARCHITECTURE.md`, `README.md`, `api/memory.md`, `api/index.md`, and `BEST_PRACTICES.md` no
163
+ longer describe the default heap backing as a growable or resizable `ArrayBuffer`. It is fixed at
164
+ the cap, and has been since 0.5.3.
165
+ - `api/refs.md` documents cursors, and `api/errors.md` documents
166
+ `ARCHETYPE_ROW_INVARIANT`. The error count in `api/errors.md` is now 48.
167
+
168
+ ### Packaging
169
+
170
+ - `bench/` is no longer part of the JSR package. npm ships from a list of the files to include
171
+ (`files: ["dist", "CHANGELOG.md"]`), so npm was never affected. But JSR publishes the source tree
172
+ against a list of the files to exclude, and the local bench and oracle harnesses would have
173
+ shipped as soon as git tracked them.
174
+
8
175
  ## [0.5.3] — 2026-07-09
9
176
 
10
- ### Fixed — heap columns back on V8's fast element-access path (~5× iteration)
177
+ ### Fixed — heap columns are again on the fast element-access path of V8
11
178
 
12
179
  - The pure-TS **heap profile** (`heapArraybufferAllocator` — the default backing)
13
180
  now reserves its store as a **fixed, non-resizable `ArrayBuffer`** at the full
14
- cap instead of a growable one resized via `.resize()`. V8 has no fast
15
- element-access path for TypedArray views over a **resizable/growable**
16
- `ArrayBuffer` every `col[i]` re-checks the mutable length — so iterating a
17
- column was ~4× slower than over a fixed buffer (measured on V8 13.6, an
18
- isolated `col[i] *= 2` loop: ~0.37G vs ~1.6G element-accesses/s). This
19
- silently regressed every iteration-bound system ~5× from 0.3.x (a
20
- cross-library bench had oecs at ~85k op/s on the `packed_5` scenario vs
21
- ~407k at 0.3.1); 0.5.3 restores it (~420k, measured against 0.5.2 in a
22
- same-toolchain A/B: 85.9k 422.7k op/s).
23
- - The fixed buffer faults pages in lazily, so RSS tracks real use, not the
24
- reservation (a 256 MiB default cap on a 1000-entity world stays a few MiB
25
- resident — measured ~4 MiB RSS, within ~1 MiB of the old resizable buffer).
181
+ cap. Before this release it used a growable buffer, and it made that buffer
182
+ larger with `.resize()`. V8 has no fast element-access path for a TypedArray
183
+ view over a **resizable or growable** `ArrayBuffer`, because each `col[i]`
184
+ reads the mutable length again. Thus a loop over a column was much slower than
185
+ the same loop over a fixed buffer. This made each iteration-bound system slower
186
+ from 0.3.x, and it gave no signal. Version 0.5.3 corrects the fault, and a
187
+ cross-library bench gives the throughput of 0.3.1 again.
188
+ - The fixed buffer faults pages in lazily. Thus RSS follows the real use, and not
189
+ the reservation: a world with few entities keeps a small resident set at the
190
+ 256 MiB default cap, which is equivalent to the old resizable buffer.
26
191
  Growth remains in place: the store relocates columns within the pre-reserved
27
192
  buffer, so the buffer identity never changes and every existing view stays
28
193
  valid —
29
- `isInPlace: true` and ADR-0008's entity-index-hoist-across-grow invariant hold
194
+ `isInPlace: true` and the entity-index-hoist-across-grow invariant hold
30
195
  unchanged. The store keys its tail cursor off the header `capacity` (the
31
196
  logical high-water) rather than `buffer.byteLength` (now always the cap).
32
197
  - Only the heap backing changed. The `growable_sab` / `wasm_memory` backings
@@ -165,7 +330,8 @@ rename/removal map:
165
330
  | `WorldRestoreError` / `WORLD_SNAPSHOT_VERSION` | `ECSRestoreError` / `ECS_SNAPSHOT_VERSION` |
166
331
 
167
332
  - **Host `despawn` is immediate** — `ecs.despawn(e); ecs.isAlive(e)` is `false` on the next
168
- line, closing the audit's M1 finding (host `addComponent` immediate but destroy buffered).
333
+ line. This removes the inconsistency: host `addComponent` was immediate, but destroy was
334
+ buffered.
169
335
  **Observer note:** like every immediate op, host `despawn` fires no *structural* observers —
170
336
  `onRemove` no longer sees host-despawned entities (it did at 0.4, when host destroy was
171
337
  deferred). Observer-driven consumers, including the `reactive-sync` map bridges, only see
@@ -177,7 +343,7 @@ rename/removal map:
177
343
  `ctx.commands` equivalent. Mid-system these ops can move rows a running query is walking and
178
344
  are invisible to observers; previously only `despawn` was guarded wholesale (the others were
179
345
  caught only when they touched the archetype being iterated). Cross-world host mutation from
180
- another world's system (#785) is unaffected — the guard is scoped to the mutated world.
346
+ another world's system is unaffected — the guard is scoped to the mutated world.
181
347
  - **The bare deferred duplicates on `ctx` are removed** — `ctx.addComponent`,
182
348
  `ctx.removeComponent`, `ctx.disable`, `ctx.enable` join the already-removed
183
349
  `ctx.createEntity` / `ctx.destroyEntity`. `ctx.commands` is now the *only* deferred surface,
@@ -186,7 +352,7 @@ rename/removal map:
186
352
  `ctx.addComponent` carried, so compile-checked complete attaches survive the move.
187
353
  `ctx.isDisabled` stays (immediate read), as do the immediate sparse/relation ops.
188
354
  - **`sourcesOf` canonicalized to `(entity, def)`** on `ecs.relations` and `SystemContext` —
189
- it was the one arg-order outlier on the relation surface (M3).
355
+ it was the one arg-order outlier on the relation surface.
190
356
  - **The package root is now a curated, explicit export list** — `export *` no longer flattens the
191
357
  whole core barrel, so future barrel additions cannot silently widen the public API. A checked-in
192
358
  public-API snapshot test makes any surface change an explicit diff in review.
@@ -200,14 +366,14 @@ rename/removal map:
200
366
  ### Added
201
367
 
202
368
  - **`addComponent` bundle overload** — `ecs.addComponent(e, Pos({ x: 1 }))` accepts a bundle
203
- with the usual zero-fill semantics (M2); the explicit `(e, def, values)` form stays
369
+ with the usual zero-fill semantics; the explicit `(e, def, values)` form stays
204
370
  complete-values, so a typo'd or missing field is still a compile error.
205
371
  - **`spawnMany` typed template + shared overrides** — bulk spawn takes the same typed
206
372
  `Template<Defs>` as `spawn` plus one optional `TemplateOverrides<Defs>` object applied to
207
373
  every row (contiguous batches use one `fill` per overridden column).
208
374
  - **JSDoc `@example` on the core surface** — `registerComponent`, `spawn`, `addComponent`,
209
375
  `query`, `registerSystem`, `startup`, `update`, `ctx.emit` / `ctx.read`,
210
- `events.register`, `resources.register` now carry hover-visible examples (M23).
376
+ `events.register`, `resources.register` now carry hover-visible examples.
211
377
  - **Component debug names** — `registerComponent(schema, { name: "Pos" })` (and the sparse
212
378
  sibling) records a diagnostic label, so access-violation and liveness errors read
213
379
  `'Pos' (component 5)` instead of leaving you to count registration order
@@ -258,13 +424,13 @@ rename/removal map:
258
424
  ops on an archetype a live dense walk is standing in now throw in dev, *before* any mutation
259
425
  lands (the transition path checks ahead of the destination append, so no dual-residency
260
426
  half-state). Collect ids during the walk and mutate after it. Mutating archetypes the walk is
261
- *not* currently visiting stays legal — the #431 fresh-snapshot machinery still covers those.
427
+ *not* currently visiting stays legal — the fresh-snapshot machinery still covers those.
262
428
  - **Cross-world despawn false positive** — `worldB.despawn(e)` from inside world A's system no
263
429
  longer trips the in-system despawn guard (the accessCheck span is process-global; the guard now
264
- also requires *this* world to be mid-schedule). Driving a second world from a system (#785)
430
+ also requires *this* world to be mid-schedule). Driving a second world from a system
265
431
  mutates it host-style, which is safe — B is not iterating. Unnamed systems in the guard message
266
432
  now render as `system_<id>` instead of `'?'`.
267
- - **Frame trace records every deferred command (ADR-0030)** — the removed bare `ctx.*` deferred
433
+ - **Frame trace records every deferred command** — the removed bare `ctx.*` deferred
268
434
  forms bypassed the `commandQueued` trace hook, so host-command-seam adds/removes/toggles (and
269
435
  any system using the bare forms) were invisible to an attached `FrameTraceSink` while their
270
436
  spawns/despawns were visible. With `ctx.commands` as the only deferred surface every queued
@@ -272,7 +438,7 @@ rename/removal map:
272
438
  (previously only the spawn itself).
273
439
  - **Stale deferred-attach docs** — `host_commands.ts` / the host-write-seam page claimed the
274
440
  deferred add path does not zero-fill omitted fields (NaN readback); every attach path
275
- zero-fills since #716 (`writeFields`'s `?? 0`). The complete-values requirement on
441
+ zero-fills (`writeFields`'s `?? 0`). The complete-values requirement on
276
442
  `SpawnEntry` is documented as what it is — explicit intent in a reified, replayable record —
277
443
  and the observer docs now scope "immediate ops fire no observers" to *structural* observers
278
444
  (`onSet` is derived change detection and sees host `setField` writes).
@@ -309,11 +475,11 @@ rename/removal map:
309
475
  `runEveryNTicks` validation throws `ECSError` (`INVALID_RUN_CONDITION`).
310
476
  - **Docs standardized on the `ecs` receiver** — README, GETTING_STARTED, BEST_PRACTICES, the
311
477
  api reference, and every in-source JSDoc example now spell `const ecs = new ECS()`
312
- (M22; with the `World*` names renamed to `ECS*`, "world" survives only as prose). The
478
+ (with the `World*` names renamed to `ECS*`, "world" survives only as prose). The
313
479
  host-write-seam docs now explain *why* `queue.spawn` takes complete-value `spawnEntry`s
314
- rather than zero-filling bundles (M4: commands are a reified, replayable record — complete
480
+ rather than zero-filling bundles: commands are a reified, replayable record — complete
315
481
  values are explicit intent legible to replay, not a correctness need; the deferred add path
316
- zero-fills omitted fields since #716).
482
+ zero-fills omitted fields.
317
483
  - **JSR publish no longer ships `__tests__` helper files** (`casing_codemod.ts`,
318
484
  `test_helpers.ts` — including a `node:fs` import subject to JSR type-checking).
319
485
 
@@ -506,8 +672,8 @@ Performance-only patch release. Two targeted allocation-elimination changes on h
506
672
 
507
673
  ### Performance
508
674
 
509
- - **Cache multi-component transition maps on `Archetype`.** `add_components` / `remove_components` on already-populated entities previously allocated a fresh `Int16Array` per call via `build_transition_map`. A per-archetype `batch_transition_maps: Map<ArchetypeID, Int16Array>` now caches the map on first use. Single-component paths unchanged. Measured: **+12–15%** throughput on `add_components` (already-populated) at 10k / 100k / 1M; **−35–42%** peak heap and **−49–61%** peak RSS on the same workload. ([#9](https://github.com/oasys-works/oecs/pull/9))
510
- - **Per-Query composition cache for single-component composition shapes.** `q.and(X)`, `q.not(X)`, `q.any_of(X)`, and `q.changed(X)` previously allocated a BitSet copy, a defs slice (and, for `.changed`, a new `ChangedQuery`) on every call, even though the resolver already cached the resulting `Query` object. Single-component calls now short-circuit through a per-parent-`Query` Map and skip the allocation path entirely. Multi-component compositions fall through unchanged. Measured: **~6×** throughput on a 4-shape compose loop at 10k / 100k / 1M; **−40–56%** peak heap and **essentially zero RSS growth** during the workload. ([#10](https://github.com/oasys-works/oecs/pull/10))
675
+ - **Cache multi-component transition maps on `Archetype`.** `add_components` / `remove_components` on already-populated entities previously allocated a fresh `Int16Array` per call via `build_transition_map`. A per-archetype `batch_transition_maps: Map<ArchetypeID, Int16Array>` now caches the map on first use. Single-component paths unchanged. Measured on the same workload: a higher throughput of `add_components` on an already-populated entity, a much smaller peak heap, and a much smaller peak RSS. ([#9](https://github.com/oasys-works/oecs/pull/9))
676
+ - **Per-Query composition cache for single-component composition shapes.** `q.and(X)`, `q.not(X)`, `q.any_of(X)`, and `q.changed(X)` previously allocated a BitSet copy, a defs slice (and, for `.changed`, a new `ChangedQuery`) on every call, even though the resolver already cached the resulting `Query` object. Single-component calls now short-circuit through a per-parent-`Query` Map and skip the allocation path entirely. Multi-component compositions fall through unchanged. Measured on a compose loop with four shapes: a much higher throughput, a much smaller peak heap, and almost no growth of RSS during the workload. ([#10](https://github.com/oasys-works/oecs/pull/10))
511
677
 
512
678
  ## [0.3.0] — 2026-04-21
513
679