From 4d8c0492d60720fe113bce212e0b1fb290e06ad3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 00:10:33 -0700 Subject: [PATCH 001/127] ecs: order on System.render, one paint-ordered table across archetypes Contract change (authorized): System.render's declaration takes `order?: Index<{ readonly sorted: true; readonly key: readonly Component.Key[] }>`. With it, the body's `tables` holds exactly one table whose `count` is every matching row across archetypes (has/not apply) and whose `bind` makes instance i the i-th of them in the index's order. Shader accessors keep their names and `fn name(row: u32)` signatures; `row` is the position in the order and `ecs_count()` the instance count. Gpu.wgsl emits, for an ordered system, the record `struct EcsTable { ecs_rows, ecs_order, ecs_tables, ecs_shift }` and an `ecs_address` helper; unordered output is unchanged. A non-sorted or unknown index is a type error and throws at creation; a run whose matching archetype holds more rows than 32 bits name beside its slot fails in its report. Implementation: an order block of one u32 per instance (slot above the record's shift, row below) and a tables block of each slot's column word addresses, both heap blocks through uploadIndexes. The order is listed and written only when the index's `changed` tick or a new `moved` tick (an entry relocated with its key unchanged) moves, or the matches grow; the tables block only when an address changes; the record only when a word does. An unchanged run writes nothing and allocates nothing. Numbers, chromium: gpu ordered render unchanged 50K 0.030-0.036 ms a run (unordered two tables 0.045-0.049); gpu ordered render one key 50K 0.268-0.294 ms (listing 50K entries 0.17 ms, 200 KB write 0.07 ms). Tests: render-order.browser.test.ts (two archetypes interleaved in depth, has/not, despawn row move, spawn, key change, a hidden entity shown, one table when nothing matches, writes only on change, ordered WGSL compiles), system.type-test.ts (sorted, hash, unknown and non-index orders), a creation check in create.test.ts. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/followups.md | 6 - .../src/implementation/world/index-sorted.ts | 14 +- .../ecs/src/implementation/world/indexes.ts | 9 +- packages/ecs/src/implementation/world/perf.md | 4 + .../world/render-order.browser.test.ts | 216 ++++++++++++++++++ .../src/implementation/world/render-order.ts | 150 ++++++++++++ .../ecs/src/implementation/world/runner.ts | 19 ++ packages/ecs/src/interface/gpu/accessors.ts | 32 ++- packages/ecs/src/interface/gpu/wgsl.ts | 13 +- .../ecs/src/interface/indexes/create.test.ts | 5 + packages/ecs/src/interface/system/render.ts | 18 +- .../src/interface/system/system.type-test.ts | 27 +++ packages/ecs/src/perftest/render-order.ts | 91 ++++++++ .../src/perftest/suite.perf-browser-test.ts | 2 + 15 files changed, 580 insertions(+), 28 deletions(-) create mode 100644 packages/ecs/src/implementation/world/render-order.browser.test.ts create mode 100644 packages/ecs/src/implementation/world/render-order.ts create mode 100644 packages/ecs/src/perftest/render-order.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index da7d33e8..0f729ee2 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -56,7 +56,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor. - **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device. - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. - - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. + - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype; with `order`, a sorted index, one table of every matching row in its order, whose accessors resolve an instance's position through an order block of slot and row to its archetype's columns. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. - **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back. - **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU. - **Ordering:** a queue write lands before recorded but unsubmitted work, so recorded work is submitted before each upload. Each run's encoder is submitted at its end, before the sync point; `run` resolves then, not when the GPU finishes. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 566e8c1e..a0067d87 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -35,12 +35,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU row order. Not needed until a consumer's profile says so. -- **Paint order across render tables:** a render system draws each matching archetype as its own table in row order, - so 2D content of several kinds (shapes, images, paths) can't be blended back to front without the consumer keeping - its own sorted instance list. Proposal: `order: index` on `System.render`, giving one table spanning its archetypes - whose `bind` sets a buffer of the index's rows in sorted order with each archetype's column base addresses; every - column already lives in one heap, so accessors resolve a sorted instance to its row. Not needed while consumers draw - every kind with one shader from their own list. - **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then a world option or resource. diff --git a/packages/ecs/src/implementation/world/index-sorted.ts b/packages/ecs/src/implementation/world/index-sorted.ts index 6cfee390..b0a3dcfc 100644 --- a/packages/ecs/src/implementation/world/index-sorted.ts +++ b/packages/ecs/src/implementation/world/index-sorted.ts @@ -585,12 +585,16 @@ const split = (table: SortedTable, k: number, tick: number): void => { reindexFrom(table, k, table.blocks); }; -/** Updates where entity index `entity` is: its archetype and row, at its entry. */ -export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): void => { +/** Updates where entity index `entity` is: its archetype and row, at its entry. Returns whether either changed. */ +export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): boolean => { const base = entryOf(table, entity); - if (base < 0) return; - table.entries[base + table.words + 1] = archetype; - table.entries[base + table.words + 2] = row; + if (base < 0) return false; + const at = base + table.words; + const { entries } = table; + if (entries[at + 1] === archetype && entries[at + 2] === row) return false; + entries[at + 1] = archetype; + entries[at + 2] = row; + return true; }; /** diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index df866c16..82c804e2 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -51,6 +51,11 @@ export type WorldIndex = { * it was built; 0 before. */ changed: number; + /** + * For a sorted index, the tick of the last refresh at which an entry moved to another archetype or row with its key + * and place unchanged, or it was built; 0 before. + */ + moved: number; /** By watched component: how many lookups watch it, and the tick its ticks were read up to. */ readonly watchers: Map; readonly scanned: Map; @@ -124,6 +129,7 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn position: -1, refreshed: 0, changed: 0, + moved: 0, watchers: new Map(), scanned: new Map(), hash: sorted ? null : createHashTable(state.memory, words), @@ -434,6 +440,7 @@ const finishRefresh = async (state: State, index: WorldIndex, end: number, tick: /** Builds an index again: in phases, across the pool when there is one, for a large hash index keyed by structs; else here. */ const rebuildAny = (state: State, index: WorldIndex, tick: number, failures: Failure[], pool: Pool | null): Promise | null => { index.changed = tick; + index.moved = tick; const phased = rebuildPhased(state, index, tick, failures, pool); if (phased !== false) return phased; rebuild(state, index, tick, failures); @@ -597,7 +604,7 @@ const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count const fresh = index.objects && unknownValue(index, key); if (fresh) encodeRow(index, archetype, row, key, 0, true); if (old >= 0 && !fresh && sameStoredKey(sorted, old, key, 0)) { - relocateSorted(sorted, entity, archetypeId, row); + if (relocateSorted(sorted, entity, archetypeId, row)) index.moved = tick; continue; } if (objects && !fresh) holdWords(index, key, 0, true); diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index ac28b9a1..78e66f7e 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -142,6 +142,10 @@ GPU copies uploading only the chunks CPU writes changed, against `a4eef10e`, chr - **An upload writes each run of adjacent chunks stamped since its last upload** or holding rows a sync point appended or disturbed, clamped to the row count; a column with GPU writes the CPU hasn't read back writes only those rows. A whole-column stamp (`columnChanged`) uploads nothing: only GPU kernels make one, and their writes are on the GPU already. - **A WASM kernel's column addresses are kept per load as well as per archetype version:** a kernel loaded again to stamp a component that already kept ticks went on passing the muted column's address, 0, and wrote its stamps over the entity table there. +Render systems with `order`, against `9daaa3f8`, chromium, three runs each: new `gpu ordered render unchanged 50K`, 50K shapes and images, two archetypes interleaved in depth, drawn as one table in a sorted index's order with nothing changed, 0.030–0.036 ms a run, against 0.045–0.049 ms for the same 50K drawn without `order` as two tables, which writes both tables' records each run. New `gpu ordered render one key 50K`, a target system moving one item to another depth and then the ordered draw, 0.268–0.294 ms a run, against 0.042–0.050 ms unordered: listing the 50K entries in order costs 0.167–0.175 ms (3.4 ns an entry) and writing the 200 KB block 0.067–0.070 ms, timed inside the run. +- **The order block is listed and written only when the index's entries changed or moved rows,** by the index's `changed` tick and a `moved` tick its refresh stamps when it relocates an entry with its key unchanged, or when the matches grew; the tables block only when a column's address changed; the record only when one of its four words did. An unchanged run writes nothing to the queue and allocates nothing: the table, its `bind`, the tables list and the context are reused. +- **An entry is one u32:** the matching archetype's slot in the high bits, as few as the matches need and at least one, and its row in the rest, so the shader masks by the record's shift. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts new file mode 100644 index 00000000..3bed86a2 --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -0,0 +1,216 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Gpu } from "../../interface/gpu/gpu.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const depth = Symbol("renderOrderTest/depth"); +const code = Symbol("renderOrderTest/code"); +const shape = Symbol("renderOrderTest/shape"); +const image = Symbol("renderOrderTest/image"); +const size = Symbol("renderOrderTest/size"); +const visible = Symbol("renderOrderTest/visible"); +const hidden = Symbol("renderOrderTest/hidden"); + +declare module "@adobe/ecs" { + export interface Components { + [depth]: { type: number }; + [code]: { type: number }; + [shape]: { type: true }; + [image]: { type: true }; + [size]: { type: readonly [number, number, number] }; + [visible]: { type: true }; + [hidden]: { type: true }; + } +} + +Component.register(depth, { type: { type: "number", precision: 1 } }); +Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); +Component.register(size, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +Component.register(visible, { type: { const: true } }); +Component.register(hidden, { type: { const: true } }); + +const byDepth = Index.create({ name: "renderOrderTest/byDepth", key: [depth], sorted: true }); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +const count = 2000; +const spawned: Entity[] = []; + +/** + * Shapes and images interleaved in depth, spawned in reverse depth order, so neither spawn nor row order is paint + * order. Every tenth is hidden or not visible, which the render system filters out, and plain depths match nothing. + */ +const spawn = System.create({ name: "renderOrderTest/spawn" }, ({ commands }) => { + for (let i = count - 1; i >= 0; i--) { + if (i % 10 === 3) spawned[i] = commands.spawn([depth, code, shape, visible, hidden])({ [depth]: i, [code]: i, [shape]: true, [visible]: true, [hidden]: true }); + else if (i % 10 === 7) spawned[i] = commands.spawn([depth, code, image])({ [depth]: i, [code]: i, [image]: true }); + else if (i % 2 === 0) spawned[i] = commands.spawn([depth, code, shape, visible])({ [depth]: i, [code]: i, [shape]: true, [visible]: true }); + else spawned[i] = commands.spawn([depth, code, image, size, visible])({ [depth]: i, [code]: i, [image]: true, [size]: [i, i, i], [visible]: true }); + } + commands.spawn([depth])({ [depth]: -1 }); +}); + +/** The codes a frame should draw, in paint order. */ +const drawn = (codes: Iterable): number[] => [...codes].filter(i => i % 10 !== 3 && i % 10 !== 7).sort((a, b) => a - b); + +describe("render systems with an order", () => { + it("draw every matching archetype as one table, in the index's order, through the same accessors", async () => { + const own = await device(); + const output = own.createBuffer({ size: (count + 16) * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + const tableCounts: number[][] = []; + const draw = System.render({ + name: "renderOrderTest/draw", + components: { has: [visible], not: [hidden] }, + order: byDepth, + shader: { accessors: { paintDepth: depth, paintCode: code } }, + }, ({ encoder, tables }) => { + tableCounts.push(tables.map(table => table.count)); + encoder.clearBuffer(output); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(1, outputGroup); + for (const table of tables) { + table.bind(pass); + pass.dispatchWorkgroups(Math.ceil(table.count / 64)); + } + pass.end(); + }); + const outputLayout = own.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }] }); + const outputGroup = own.createBindGroup({ layout: outputLayout, entries: [{ binding: 0, resource: { buffer: output } }] }); + const module = own.createShaderModule({ + code: `${Gpu.wgsl(draw)} +@group(1) @binding(0) var output: array; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) { + if (id.x >= ecs_count()) { return; } + output[id.x] = vec2u(u32(paintDepth(id.x)), paintCode(id.x)); +}`, + }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); + + /** What the last frame drew: depth and code per instance. */ + const read = async (): Promise => { + const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const encoder = own.createCommandEncoder(); + encoder.copyBufferToBuffer(output, 0, staging, 0, output.size); + own.queue.submit([encoder.finish()]); + await staging.mapAsync(GPUMapMode.READ); + const words = new Uint32Array(staging.getMappedRange().slice(0)); + staging.destroy(); + const instances = tableCounts.at(-1)?.[0] ?? 0; + return Array.from({ length: instances }, (_, i) => [words[2 * i], words[2 * i + 1]]); + }; + const frame = async (): Promise => expect((await runReported(world, draw)).failures).toEqual([]); + + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await frame(); + const codes = new Set(Array.from({ length: count }, (_, i) => i)); + let expected = drawn(codes); + expect(tableCounts.at(-1)).toEqual([expected.length]); + expect(await read()).toEqual(expected.map(i => [i, i])); + + // A despawn moves another shape into its row, a spawn adds an image between two others, and a key moves a shape + // to the back; a frame after each draws the new order. + const despawn = System.create({ name: "renderOrderTest/despawn" }, ({ commands }) => commands.despawn(spawned[count - 2])); + await world.run(despawn); + codes.delete(count - 2); + await frame(); + expected = drawn(codes); + expect(await read()).toEqual(expected.map(i => [i, i])); + + const addImage = System.create({ name: "renderOrderTest/addImage" }, ({ commands }) => { + commands.spawn([depth, code, image, size, visible])({ [depth]: 500.5, [code]: 5001, [image]: true, [size]: [0, 0, 0], [visible]: true }); + }); + await world.run(addImage); + await frame(); + const withImage = expected.flatMap(i => i === 500 ? [[i, i], [500, 5001]] : [[i, i]]); + expect(await read()).toEqual(withImage); + + const toBack = System.create({ name: "renderOrderTest/toBack", components: { read: [code], write: [depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await frame(); + const atBack = [...withImage.filter(([, c]) => c !== 10), [count + 5, 10]]; + expect(await read()).toEqual(atBack); + + // Showing a hidden shape adds it to the one table, in its place. + const show = System.create({ name: "renderOrderTest/show" }, ({ commands }) => commands.remove(spawned[13], hidden)); + await world.run(show); + await frame(); + const shown = atBack.flatMap(([d, c]) => d === 12 ? [[d, c], [13, 13]] : [[d, c]]); + expect(await read()).toEqual(shown); + expect(tableCounts.every(counts => counts.length === 1)).toBe(true); + await world.dispose(); + }); + + it("write their order only when the index's entries change or move", async () => { + const own = await device(); + const draw = System.render({ name: "renderOrderTest/writes", components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await world.run(draw); + const instances = drawn(Array.from({ length: count }, (_, i) => i)).length; + // Row uploads and block writes pass a size; record writes don't. + const sizes: number[] = []; + const { queue } = own; + const write = queue.writeBuffer.bind(queue); + let records = 0; + queue.writeBuffer = (buffer, offset, data, dataOffset, size) => { + if (size === undefined) records++; + else sizes.push(size); + write(buffer, offset, data, dataOffset, size); + }; + await world.run(draw); + expect([sizes, records]).toEqual([[], 0]); + // A new key writes its chunk of the column and the order, in elements; the record stays. + const toBack = System.create({ name: "renderOrderTest/toBackOnce", components: { read: [code], write: [depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await world.run(draw); + expect(sizes.slice(1)).toEqual([instances]); + expect(records).toBe(0); + sizes.length = 0; + await world.run(draw); + expect(sizes).toEqual([]); + await world.dispose(); + }); + + it("give a shader one table when nothing matches", async () => { + const own = await device(); + let tables: readonly System.RenderTable[] = []; + const draw = System.render({ name: "renderOrderTest/empty", components: { has: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + expect((await runReported(world, draw)).failures).toEqual([]); + expect(tables.map(table => table.count)).toEqual([0]); + await world.dispose(); + }); + + it("declare their ordered accessors, which compile", async () => { + const own = await device(); + const draw = System.render({ name: "renderOrderTest/declared", order: byDepth, shader: { group: 2, accessors: { paintDepth: depth, paintSize: size } } }, () => {}); + const code = Gpu.wgsl(draw); + expect(code).toContain("fn paintDepth(row: u32) -> f32"); + expect(code).toContain("fn paintSize(row: u32) -> vec3"); + expect(code).toContain("@group(2) @binding(1) var ecs_table: EcsTable;"); + // An instance is its position in the order, which the order block maps to a table and a row. + expect(code).toContain("struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }"); + const module = own.createShaderModule({ code: `${code}\n@compute @workgroup_size(1) fn main() { _ = paintDepth(0u); _ = paintSize(0u); _ = ecs_count(); }` }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + }); +}); diff --git a/packages/ecs/src/implementation/world/render-order.ts b/packages/ecs/src/implementation/world/render-order.ts new file mode 100644 index 00000000..607755d9 --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.ts @@ -0,0 +1,150 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RenderContext, RenderTable } from '../../interface/system/render.js'; +import type { Archetype } from './archetype.js'; +import type { GpuEngine } from './gpu.js'; +import type { Ordered } from './index-order.js'; +import { blockBase } from './index-sorted.js'; +import type { WorldIndex } from './indexes.js'; + +// A render system with `order` draws one table: instance `i` is the i-th matching entity in its sorted index's order. +// Its record is the instance count, the word addresses of its order block and tables block, and the bits a slot is +// shifted by. The order block holds, by position, a matched archetype's slot above `shift` bits and its row below +// them; the tables block holds, by slot, the word address of each accessor's column. The order block is built and +// written only when the index's entries changed or moved, or the matches grew; the tables block only when a column's +// address changed; the record only when one of its words did. + +/** The table of a render system's run and its context, both reused. */ +type MutableTable = { count: number; bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void }; +type MutableContext = { device: GPUDevice; encoder: GPUCommandEncoder; tables: readonly RenderTable[] }; + +export type OrderedRender = { + /** + * Uploads what the run's one table reads; returns null, or the reason it can't be drawn: more matched archetypes + * and rows than an entry's 32 bits hold. + */ + readonly prepare: (gpu: GpuEngine, matches: readonly Archetype[], tick: number) => string | null; + /** The body's context, once the work recorded before it is submitted. */ + readonly context: (gpu: GpuEngine) => RenderContext; +}; + +/** + * `columns` are the accessors' component ids, in accessor order; `group` the bind group the shader declares; `key` + * the prefix of the runner's GPU records. + */ +export const createOrderedRender = (index: WorldIndex, ordered: Ordered, columns: readonly number[], group: number, key: string): OrderedRender => { + const width = columns.length; + const orderKey = `${key}order`; + const tablesKey = `${key}tables`; + const recordKey = `${key}ordered`; + let entries = new Int32Array(64); + let instances = 0; + let tables = new Int32Array(Math.max(1, width)); + /** What the order block was last built from: the index's ticks, the matches, and the GPU it was written to. */ + let builtChanged = -1; + let builtMoved = -1; + let builtMatches = -1; + let builtGpu: GpuEngine | null = null; + let shift = 31; + const words = new Uint32Array(4); + let recorded = false; + const offsets = [0]; + let bound: GpuEngine | null = null; + const table: MutableTable = { + count: 0, + bind: pass => { + if (bound !== null) pass.setBindGroup(group, bound.renderGroup(), offsets); + }, + }; + const list: readonly RenderTable[] = [table]; + let context: MutableContext | null = null; + + /** Lists the index's entries of matched archetypes, in its order, as slot and row. */ + const build = (matches: readonly Archetype[]): void => { + const sorted = index.sorted; + instances = 0; + if (sorted === null) return; + if (entries.length < sorted.size) entries = new Int32Array(Math.max(sorted.size, 2 * entries.length)); + const owners = ordered.owners(matches); + const known = owners.length; + const slotShift = shift; + const { order, lengths, stride, words: keyWords, blocks } = sorted; + const sortedEntries = sorted.entries; + for (let k = 0; k < blocks; k++) { + const block = order[k]; + const base = blockBase(sorted, block) + keyWords; + for (let at = base, end = base + lengths[block] * stride; at < end; at += stride) { + const archetype = sortedEntries[at + 1]; + const owner = archetype < known ? owners[archetype] : -1; + if (owner < 0) continue; + entries[instances++] = (owner << slotShift) | sortedEntries[at + 2]; + } + } + }; + + const prepare = (gpu: GpuEngine, matches: readonly Archetype[], tick: number): string | null => { + const slots = matches.length; + if (gpu !== builtGpu) { + builtGpu = gpu; + builtChanged = -1; + recorded = false; + } + // The tables block: every column's address, uploading rows the CPU changed first. + if (tables.length < slots * width) { + const grown = new Int32Array(Math.max(slots * width, 2 * tables.length)); + grown.set(tables); + tables = grown; + } + let addressed = false; + let most = 0; + for (let slot = 0; slot < slots; slot++) { + const archetype = matches[slot]; + const { count } = archetype; + if (count > most) most = count; + for (let k = 0; k < width; k++) { + const storage = count === 0 ? undefined : archetype.storageById[columns[k]]; + const address = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + const at = slot * width + k; + if (tables[at] !== address) { + tables[at] = address; + addressed = true; + } + } + } + if (slots !== builtMatches) { + // At least one bit of slot, so a row's mask is never 32 bits. + const slotBits = Math.max(1, Math.ceil(Math.log2(slots))); + shift = 32 - slotBits; + builtMatches = slots; + builtChanged = -1; + } + if (most > 2 ** shift) return `with ${slots} matching archetypes an ordered table names at most ${2 ** shift} rows of each, and one holds ${most}`; + const rebuilt = index.changed !== builtChanged || index.moved !== builtMoved; + if (rebuilt) { + build(matches); + builtChanged = index.changed; + builtMoved = index.moved; + } + const orderAddress = gpu.uploadIndexes(orderKey, entries, instances, rebuilt); + const tablesAddress = gpu.uploadIndexes(tablesKey, tables, slots * width, addressed); + if (!recorded || words[0] !== instances || words[1] !== orderAddress || words[2] !== tablesAddress || words[3] !== shift) { + words[0] = instances; + words[1] = orderAddress; + words[2] = tablesAddress; + words[3] = shift; + offsets[0] = gpu.record(recordKey, words); + recorded = true; + } + bound = gpu; + table.count = instances; + return null; + }; + + const contextOf = (gpu: GpuEngine): RenderContext => { + if (context === null) return context = { device: gpu.device, encoder: gpu.encoder(), tables: list }; + context.device = gpu.device; + context.encoder = gpu.encoder(); + return context; + }; + + return { prepare, context: contextOf }; +}; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 4dc78bc2..c0ce8cd4 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -33,6 +33,7 @@ import { acquireLookups, type IndexLookups, type IndexUse } from './indexes.js'; import { createOrdered, type Ordered, type Passing } from './index-order.js'; import { blockBase, type SortedTable } from './index-sorted.js'; import type { Since } from './index-lookups.js'; +import { createOrderedRender } from './render-order.js'; /** * One unit of a system's work for a run: the commands it issued, or for a worker's chunk its segment of the worker's @@ -232,6 +233,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { }); const accessors = declaration.shader === undefined ? [] : accessorsOf(declaration.shader); if (typeof accessors === "string") return failPlacement(`can't render: ${accessors}`); + /** A render system's one table in its `order` index's order. */ + const orderedRender = leaf.kind !== "render" || indexLookups.ordered === null || ordered === null ? null + : createOrderedRender(indexLookups.ordered, ordered, accessors.map(accessor => idOf(state, accessor.component)), declaration.shader?.group ?? 0, `${leafNumber}:`); const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); const looks = [...new Set([...Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []), ...declaration.up?.read ?? []])].map(key => idOf(state, key)); @@ -831,6 +835,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { if (gpu === null) return failPlacement("needs a world with a gpu device"); if (typeof accessors === "string") return; holdings.gpu = gpu; + if (orderedRender !== null) return runOrderedRender(gpu, tick, orderedRender); const group = declaration.shader?.group ?? 0; const tables = matches.filter(archetype => archetype.count > 0).map(archetype => { words[0] = archetype.count; @@ -852,6 +857,20 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { } }; + /** A render system with `order`: one table, nothing allocated while nothing it reads changed. */ + const runOrderedRender = (gpu: GpuEngine, tick: number, render: NonNullable): void => { + const failure = render.prepare(gpu, matches, tick); + if (failure !== null) return failPlacement(`can't render: ${failure}`); + gpu.submit(); + try { + system.body(render.context(gpu), context.value); + } + catch (error) { + gpu.discard(); + throw error; + } + }; + const holdings: Holdings = { removed: false, released: false, partials: null, up: up ?? ordered, directories: kernel?.directories ?? [], indexes: indexLookups, gpu: null, prefix: `${leafNumber}:`, afterSync: partialCount > 0 ? new WeakRef(sizeSlots) : null, remote: null, diff --git a/packages/ecs/src/interface/gpu/accessors.ts b/packages/ecs/src/interface/gpu/accessors.ts index d628fad3..9d40716d 100644 --- a/packages/ecs/src/interface/gpu/accessors.ts +++ b/packages/ecs/src/interface/gpu/accessors.ts @@ -28,14 +28,15 @@ const reserved = new Set(("alias break case const const_assert continue continui /** * Accessors for a render system's shader, in `accessors` order, or the reason one can't be read on the GPU: scalars - * and tuples of two to four scalars of one type, laid out in 4-byte words. + * and tuples of two to four scalars of one type, laid out in 4-byte words. An `ordered` system's accessors take a + * row's position in its order, which `ecs_address` resolves to a table's column. */ -export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { +export const accessorsOf = (shader: Shader, ordered = false): readonly Accessor[] | string => { const accessors: Accessor[] = []; const entries = Object.entries(shader.accessors); // A table record is a row count, then a word address per accessor. if (entries.length > 63) return "it names more than 63 accessors"; - for (const [name, key] of entries) { + for (const [column, [name, key]] of entries.entries()) { const component = labelOf(key); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name) || name.startsWith("ecs_") || reserved.has(name)) return `"${name}" isn't usable as a WGSL function name`; const declaration = declarationOf(key); @@ -48,26 +49,41 @@ export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { return `"${component}" isn't laid out in 4-byte words of f32, i32, u32 or booleans; give it a "storage" or "vertex" layout or 4-byte scalars`; } const words = stride / 4; - const word = (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const word = ordered + ? (leaf: Leaf): string => `ecs_base + ${leaf.offset / 4}u` + : (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const base = ordered ? `let ecs_base = ecs_address(row, ${column}u, ${words}u); ` : ""; const [first] = leaves; if (leaves.length === 1 && first.path.length === 0) { - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { return ${readOf(first, word(first))}; }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { ${base}return ${readOf(first, word(first))}; }` }); continue; } const tuple = leaves.length >= 2 && leaves.length <= 4 && leaves.every((leaf, i) => leaf.path.length === 1 && leaf.path[0] === i && types[i] === types[0]); if (!tuple) return `"${component}" isn't a scalar or a tuple of two to four scalars of one type`; const items = leaves.map(leaf => readOf(leaf, word(leaf))).join(", "); - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { return vec${leaves.length}<${types[0]}>(${items}); }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { ${base}return vec${leaves.length}<${types[0]}>(${items}); }` }); } return accessors; }; /** The WGSL declarations of a render system's bind group and accessors. */ -export const declarationsOf = (group: number, accessors: readonly Accessor[]): string => [ +export const declarationsOf = (group: number, accessors: readonly Accessor[], ordered = false): string => [ `@group(${group}) @binding(0) var ecs_heap: array;`, - `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, + ordered + ? "struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }" + : `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, `@group(${group}) @binding(1) var ecs_table: EcsTable;`, `fn ecs_count() -> u32 { return ecs_table.ecs_rows; }`, + ...ordered ? [orderedAddress(accessors.length)] : [], ...accessors.map(accessor => accessor.code), "", ].join("\n"); + +/** + * The word address of a column's value at a position of an ordered system's order. The order block holds, by position, + * a table's slot above `ecs_shift` bits and its row below them; the tables block holds, by slot, a word address per + * accessor. + */ +const orderedAddress = (columns: number): string => + "fn ecs_address(row: u32, column: u32, words: u32) -> u32 { let ecs_entry = ecs_heap[ecs_table.ecs_order + row]; " + + `return ecs_heap[ecs_table.ecs_tables + (ecs_entry >> ecs_table.ecs_shift) * ${columns}u + column] + (ecs_entry & ((1u << ecs_table.ecs_shift) - 1u)) * words; }`; diff --git a/packages/ecs/src/interface/gpu/wgsl.ts b/packages/ecs/src/interface/gpu/wgsl.ts index b9f7a39c..8def4790 100644 --- a/packages/ecs/src/interface/gpu/wgsl.ts +++ b/packages/ecs/src/interface/gpu/wgsl.ts @@ -7,15 +7,18 @@ import { accessorsOf, declarationsOf } from './accessors.js'; /** * The WGSL a render system's shaders include: its bind group at `shader.group` (the heap and its table record), and a * function per accessor returning that component of a row, such as `fn position(row: u32) -> vec3`, plus - * `ecs_count()`. Depends only on registered schemas, so pipelines can be built before any world exists. + * `ecs_count()`. For a system with `order`, `row` is an instance's position in its order and `ecs_count()` the number + * of instances. Depends only on registered schemas, so pipelines can be built before any world exists. * Throws if the system isn't a render system or a component can't be read on the GPU. */ export const wgsl = (system: System): string => { const definition = definitionOf(system); - const shader = definition?.kind === "render" ? definition.declaration.shader : undefined; - if (shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); + const declaration = definition?.kind === "render" ? definition.declaration : undefined; + const shader = declaration?.shader; + if (declaration === undefined || shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); const { name } = system; - const accessors = accessorsOf(shader); + const ordered = declaration.order !== undefined; + const accessors = accessorsOf(shader, ordered); if (typeof accessors === "string") throw new Error(`Render system "${name}": ${accessors}.`); - return declarationsOf(shader.group ?? 0, accessors); + return declarationsOf(shader.group ?? 0, accessors, ordered); }; diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index c72b27c0..8ab3007d 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -143,4 +143,9 @@ describe("systems ordering their rows by an index", () => { expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, order: ranked })).toThrow(`its rows are ordered by an index keyed by "indexCreateTest/amount", which the system writes`); expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); }); + it("throw for a render system's order by what isn't a sorted index", () => { + const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [{ name: "bad", order, shader: { accessors: { amount } } }, () => {}]); + expect(() => untypedRender({ name: "fake" })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); + expect(() => untypedRender(Index.create({ name: "indexCreateTest/hashedForRender", key: [amount] }))).toThrow(`orders its rows by index "indexCreateTest/hashedForRender", which isn't sorted`); + }); }); diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index f031d58a..c0e82eb7 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; +import type { Index } from '../indexes/index-type.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; @@ -22,7 +23,10 @@ import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; */ export type Shader = { readonly group?: number; readonly accessors: { readonly [name: string]: StructComponent } }; -/** One group of matching rows, `0` to `count`. `bind` sets the world's bind group for it on a pass. */ +/** + * One group of matching rows, `0` to `count`: an archetype's, or with `order` every matching row in its order. `bind` + * sets the world's bind group for it on a pass. + */ export type RenderTable = { readonly count: number; readonly bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void; @@ -44,6 +48,11 @@ type RenderDeclaration = { readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** It runs only while `gpuDevice` is set, as if `is` listed it. */ readonly resources?: { readonly read?: readonly Resource.Key[]; readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + /** + * Draws every matching row as one table, in this sorted index's order: key order, then entity order. Its shaders' + * accessors then take a row's position in that order. + */ + readonly order?: Index<{ readonly sorted: true; readonly key: readonly Component.Key[] }>; readonly shader: Shader; readonly before?: readonly System[]; readonly after?: readonly System[]; @@ -56,7 +65,12 @@ type Rendered = D & { readonly components: { readon /** * Declares a render system: it runs once per run on `gpuDevice`'s thread, in schedule order, and records GPU work over * the GPU copy of the components its accessors name, which the world uploads first as needed. It runs only while the - * world has a `gpuDevice`. Throws as `create` does, and for an accessor its shader can't read on the GPU. + * world has a `gpuDevice`. Its body's `tables` hold one table per matching archetype, each in row order; with `order`, + * exactly one table of every matching row, across archetypes, in the index's order, so instance `i` is the i-th: a + * shader's accessors take that position as their `row`, with the same names and signatures. Throws as `create` does, + * for an `order` that isn't a sorted index, and for an accessor its shader can't read on the GPU. A run with `order` + * fails, as a failure in its report, when a matching archetype holds more rows than 32 bits name beside its slot: + * 2^31 with up to two matching archetypes, 2^30 with up to four, and so on. */ export const render = ( declaration: D & OneThread | ThreadOfResource, ThreadedBy>, diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index e000bb66..2e00dbc8 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -386,6 +386,33 @@ System.render({ name: "test/drawShared", shader: { accessors: { place: position drawAll(gpu, pass); pass.end(); }); +// `order` draws every matching archetype as one table, in a sorted index's order. +declare const byPosition: Index<{ readonly key: readonly [typeof position]; readonly sorted: true }>; +declare const hashedByPosition: Index<{ readonly key: readonly [typeof position] }>; +declare const someIndex: Index; +System.render({ name: "test/drawOrdered", components: { has: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { + const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); + drawAll(gpu, pass); + pass.end(); +}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error a render system is ordered by a sorted index + order: hashedByPosition, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error an index whose declaration isn't known isn't known to be sorted + order: someIndex, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + name: "test/drawOrdered", + // @ts-expect-error an order is an index + order: [position], + shader: { accessors: { place: position } }, +}, () => {}); // Names are optional: a declaration may leave it out, and a system that declares nothing is just its body. export const unnamed = System.create({ components: { write: [position] }, resources: { read: [time] } }, (row, { resources }) => { diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts new file mode 100644 index 00000000..204777cd --- /dev/null +++ b/packages/ecs/src/perftest/render-order.ts @@ -0,0 +1,91 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/world/create.js"; +import type { Benchmark } from "./benchmark.js"; + +// A 2D editor's paint order: shapes and images, two archetypes, interleaved in depth, drawn as one table in a sorted +// index's order. + +const paintDepth = Symbol("renderOrder/paintDepth"); +const shape = Symbol("renderOrder/shape"); +const image = Symbol("renderOrder/image"); +declare module "@adobe/ecs" { + export interface Components { + [paintDepth]: { type: number }; + [shape]: { type: true }; + [image]: { type: true }; + } +} +Component.register(paintDepth, { type: { type: "number", precision: 1 } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); + +const byDepth = Index.create({ name: "renderOrder/byDepth", key: [paintDepth], sorted: true }); + +const items = 50_000; +let spawned: Entity[] = []; +const spawn = System.create({ name: "renderOrder/spawn" }, ({ commands }) => { + spawned = []; + for (let i = 0; i < items; i++) { + spawned.push(i % 2 === 0 ? commands.spawn([paintDepth, shape])({ [paintDepth]: i, [shape]: true }) : commands.spawn([paintDepth, image])({ [paintDepth]: i, [image]: true })); + } +}); +/** Moves one item to a new depth, as reordering it does. */ +const restack = System.create({ + name: "renderOrder/restack", + components: { write: [paintDepth] }, + input: { type: "object", properties: { item: Entity.schema, depth: { type: "number" } } }, + target: "item", +}, (row, { input }) => { + row[paintDepth] = input.depth; +}); +/** Draws nothing: its run uploads what it reads and its order. */ +const draw = System.render({ name: "renderOrder/draw", order: byDepth, shader: { accessors: { paintDepth } }, after: [restack] }, () => {}); +const frame = System.create({ name: "renderOrder/frame", systems: [restack, draw] }); + +/** Timed per run by `timed`, which waits for the GPU. */ +export const renderOrderBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ + name: "gpu ordered render unchanged 50K", + operation: "A render system with an order over 50K shapes and images, two archetypes interleaved in depth, run with nothing changed: it uploads nothing and writes neither its order nor its record.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 64, basis: "A run reads at least one cache line of its system's state." }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + await world.run(draw); + return await timed(() => world.run(draw)); + } + finally { + await world.dispose(); + } + }, +}, { + name: "gpu ordered render one key 50K", + operation: "A target system moving one of 50K shapes and images to another depth, then a render system ordered by depth, which refreshes the index, lists the 50K in order and writes them, 200 KB.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 400_000, basis: "The order's 50K 4-byte entries are written on the CPU and again by the upload.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + const item = spawned[items / 2]; + let depth = 0.5; + await world.run(frame, { item, depth }); + // Between two others each time, so its place in the order moves. + return await timed(() => world.run(frame, { item, depth: depth = (depth + 1009) % items })); + } + finally { + await world.dispose(); + } + }, +}]; diff --git a/packages/ecs/src/perftest/suite.perf-browser-test.ts b/packages/ecs/src/perftest/suite.perf-browser-test.ts index a3ff5451..104f74af 100644 --- a/packages/ecs/src/perftest/suite.perf-browser-test.ts +++ b/packages/ecs/src/perftest/suite.perf-browser-test.ts @@ -18,6 +18,7 @@ import { indexBenchmarks } from "./indexes.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { observedGpuBenchmarks } from "./observed-gpu.js"; import { uploadBenchmarks } from "./uploads.js"; +import { renderOrderBenchmarks } from "./render-order.js"; /** Every worker's copy bandwidth together, through browser workers running the calibration script. */ const browserThreadsBandwidth = async (): Promise => { @@ -103,6 +104,7 @@ const benchmarks = (device: GPUDevice): readonly Benchmark[] => [ ...transferBenchmarks, ...observedGpuBenchmarks(device, run => gpuFastest(device, run)), ...uploadBenchmarks(device, run => gpuFastest(device, run)), + ...renderOrderBenchmarks(device, run => gpuFastest(device, run)), ...[[100_000, 10, "100K chains of 10"], [1_000, 1_000, "1K chains of 1,000"]].map(([roots, depth, chains]): Benchmark => ({ name: `up depth ${depth} gpu`, operation: upOperation(String(chains)), From 84e31c9e4730785be15369268980289f0b7b3e91 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 00:49:56 -0700 Subject: [PATCH 002/127] ecs: compiled code imports the runtime from the importer's copy of the package The kernel plugin built the runtime hook paths and the namespace module sets from its own import.meta.url, so a module importing a different copy of @adobe/ecs went unrecognized or bound to the plugin's copy. Copies are now found from the files an importer resolves into, by the nearest package.json named @adobe/ecs, cached per directory; the plugin's own copy keeps its own layout. Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/identify.test.ts | 4 +- .../src/implementation/compiler/identify.ts | 4 +- .../ecs/src/implementation/compiler/paths.ts | 174 +++++++++++++++--- .../implementation/compiler/plugin.test.ts | 38 +++- .../ecs/src/implementation/compiler/plugin.ts | 8 +- .../implementation/compiler/transform.test.ts | 12 +- .../src/implementation/compiler/transform.ts | 31 ++-- .../implementation/world/generated-code.md | 6 +- 8 files changed, 220 insertions(+), 57 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index ef71e9db..f6149e95 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -4,7 +4,7 @@ import { parseAst } from 'vite'; import { idOfKey, takeIdOfDeclaration } from '../../interface/registry/ids.js'; import { estreeOf } from './estree.js'; import { identify as identifyModule, moduleIdOf } from './identify.js'; -import { attachKernelPath } from './paths.js'; +import { ownCopy } from './paths.js'; import { decodeMappings } from './source-map.js'; const parse = (code: string) => estreeOf(parseAst(code)); @@ -104,7 +104,7 @@ describe("identify, for systems, rules and finalizers", () => { expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "name"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); }); test("identifies a kernel's call the kernel transform wrapped", async () => { - const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(attachKernelPath)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`); + const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`); expect(declared(result?.code)).toEqual([[`{ name: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); }); test("follows the namespace to the module that exports it, under any local name", async () => { diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index a2e20589..b51acc34 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -5,7 +5,7 @@ import { posix } from 'node:path'; import { idsName } from '../../interface/registry/ids.js'; import { children, isFunction } from './estree.js'; import { moduleScope } from './module-scope.js'; -import { attachKernelPath } from './paths.js'; +import { isAttachKernelModule } from './paths.js'; import { insertInto, type Insertion, type SourceMap } from './source-map.js'; export type Parse = (code: string) => Program; @@ -76,7 +76,7 @@ const localsOf = async (program: Program, resolve: ResolveNamespace): Promise<{ for (const specifier of statement.specifiers) { if (specifier.type !== "ImportSpecifier" || specifier.imported.type !== "Identifier") continue; const imported = specifier.imported.name; - if (statement.source.value === attachKernelPath && imported === "attachKernel") attach.add(specifier.local.name); + if (imported === "attachKernel" && isAttachKernelModule(statement.source.value)) attach.add(specifier.local.name); else if (isNamespace(imported) && await resolve(statement.source.value, imported)) namespaces.set(specifier.local.name, imported); } } diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 4dddc4af..10b9251f 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -1,44 +1,164 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { extname } from 'node:path'; +import { readFileSync, realpathSync } from 'node:fs'; +import { dirname, extname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -const here = fileURLToPath(import.meta.url); -const extension = extname(here); +const packageName = "@adobe/ecs"; const posix = (path: string): string => path.replace(/\\/g, "/"); -const packageRoot = posix(fileURLToPath(new URL("../../..", import.meta.url))).replace(/\/$/, ""); +const fileOf = (id: string): string => posix(id.split("?")[0] ?? id); -/** The runtime hook emitted code imports: the source file when running from `src`, the built one from `dist`. */ -export const attachKernelPath = posix(fileURLToPath(new URL(`../world/attach-kernel${extension}`, import.meta.url))); +/** + * The files of one copy of the package that compiled code imports. A copy is judged by the files an importer resolves + * into it, so emitted code always binds to the copy, and the registry, the importer itself uses. + */ +export type PackageCopy = { + /** The runtime hook kernels attach through. */ + readonly attachKernel: string; + /** The runtime hook for spawns and adds whose values pass positionally. */ + readonly preparedSpawn: string; + /** The runtime hook for index lookups whose keys pass positionally. */ + readonly preparedLookup: string; + /** The package root's module, for kernels that import the package when a build treats it as external. */ + readonly index: string; +}; -/** The runtime hook for spawns whose values pass positionally. */ -export const prepareSpawnPath = posix(fileURLToPath(new URL(`../world/prepared-spawn${extension}`, import.meta.url))); +/** The root of the copy of the package containing each directory, or null; found once per directory. */ +const roots = new Map(); -/** The runtime hook for index lookups whose keys pass positionally. */ -export const prepareLookupPath = posix(fileURLToPath(new URL(`../world/prepared-lookup${extension}`, import.meta.url))); +/** The name the package.json in a directory gives, or null when it has none that parses. */ +const packageNameAt = (directory: string): unknown => { + try { + const json: unknown = JSON.parse(readFileSync(`${directory}/package.json`, "utf8")); + return typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; + } + catch { + // No package.json here, or none that parses. + return null; + } +}; -const intrinsicModules: ReadonlySet = new Set(["src/interface/math/intrinsic.ts", "src/interface/math/intrinsic.js", "dist/interface/math/intrinsic.js"].map(path => `${packageRoot}/${path}`)); +const isPackageRoot = (directory: string): boolean => packageNameAt(directory) === packageName; -/** Whether a file is the module math functions declare their WGSL builtins with. */ -export const isIntrinsicModule = (file: string): boolean => intrinsicModules.has(posix(file)); +const rootOfDirectory = (directory: string): string | null => { + const known = roots.get(directory); + if (known !== undefined) return known; + const name = packageNameAt(directory); + const parent = dirname(directory); + // The nearest package.json owns the file, whichever package it names. + const root = name === packageName ? directory : name !== null || parent === directory ? null : rootOfDirectory(parent); + roots.set(directory, root); + return root; +}; -/** The package root's module, for kernels that import the package when a build treats it as external. */ -export const packageIndex = posix(fileURLToPath(new URL(`../../index${extension}`, import.meta.url))); +/** A file's path in the copy of the package containing it, as `src/…` or `dist/…`, or null outside every copy. */ +const pathInCopy = (file: string): { readonly root: string; readonly path: string } | null => { + if (!isAbsolute(file)) return null; + const root = rootOfDirectory(dirname(file)); + return root === null ? null : { root, path: file.slice(root.length + 1) }; +}; -const systemModules: ReadonlySet = new Set([ - "src/interface/system/system.ts", "src/interface/system/system.js", "dist/interface/system/system.js", - "src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js", -].map(path => `${packageRoot}/${path}`)); +const copies = new Map(); -/** Whether a resolved module id is this package's `System` namespace, directly or through the package root. */ -export const isSystemModule = (id: string): boolean => systemModules.has(posix(id.split("?")[0] ?? id)); +/** The copy at `root`, laid out as its `src` with `extension`, or as its built `dist`. */ +const copyAt = (root: string, source: boolean, extension: string): PackageCopy => { + const key = `${root}\0${source ? extension : ""}`; + const known = copies.get(key); + if (known !== undefined) return known; + const prefix = source ? `${root}/src` : `${root}/dist`; + const suffix = source ? extension : ".js"; + const copy: PackageCopy = { + attachKernel: `${prefix}/implementation/world/attach-kernel${suffix}`, + preparedSpawn: `${prefix}/implementation/world/prepared-spawn${suffix}`, + preparedLookup: `${prefix}/implementation/world/prepared-lookup${suffix}`, + index: `${prefix}/index${suffix}`, + }; + copies.set(key, copy); + return copy; +}; -const roots = ["src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js"]; +const here = posix(fileURLToPath(import.meta.url)); +const ownRoot = posix(fileURLToPath(new URL("../../..", import.meta.url))).replace(/\/$/, ""); + +/** The copy this compiler runs from: its sources when running from `src`, its `dist` otherwise. */ +export const ownCopy: PackageCopy = copyAt(ownRoot, here.startsWith(`${ownRoot}/src/`), extname(here)); + +/** + * The copy at `root`, in the layout of a file in it. This compiler's own copy keeps the compiler's layout, so modules + * importing its sources and its `dist` share the runtime the compiler runs with. + */ +const copyIn = (root: string, path: string, extension: string): PackageCopy => + root === ownRoot ? ownCopy : copyAt(root, path.startsWith("src/"), extension); + +/** The copy a file belongs to, or null outside every copy. */ +const copyOfFile = (file: string): PackageCopy | null => { + const found = pathInCopy(file); + return found === null ? null : copyIn(found.root, found.path, extname(file)); +}; + +/** The copy installed for each directory's modules, found once per directory. */ +const installed = new Map(); + +const installedFrom = (directory: string): PackageCopy | null => { + const known = installed.get(directory); + if (known !== undefined) return known; + let copy: PackageCopy | null = null; + const candidate = `${directory}/node_modules/${packageName}`; + if (isPackageRoot(candidate)) { + copy = copyIn(posix(realpathSync(candidate)), "dist/", ".js"); + } else { + const parent = dirname(directory); + copy = parent === directory ? null : installedFrom(parent); + } + installed.set(directory, copy); + return copy; +}; + +/** + * The copy an importer that can't resolve the package through the build uses: the copy it is part of, or else the one + * installed nearest it, or else this compiler's own. + */ +export const copyForImporter = (importer: string): PackageCopy => { + const file = fileOf(importer); + return copyOfFile(file) ?? installedFrom(dirname(file)) ?? ownCopy; +}; + +const layouts = (module: string, extensions: readonly string[] = [".ts", ".js"]): readonly string[] => + [...extensions.map(extension => `src/${module}${extension}`), `dist/${module}.js`]; + +const intrinsicModules: ReadonlySet = new Set(layouts("interface/math/intrinsic")); + +/** Whether a file is the module math functions declare their WGSL builtins with, in any copy of the package. */ +export const isIntrinsicModule = (file: string): boolean => { + const posixFile = posix(file); + if (!posixFile.includes("/intrinsic.")) return false; + const found = pathInCopy(posixFile); + return found !== null && intrinsicModules.has(found.path); +}; + +const attachKernelModules: ReadonlySet = new Set(layouts("implementation/world/attach-kernel")); + +/** Whether an import specifier is the runtime hook kernels attach through, in any copy of the package. */ +export const isAttachKernelModule = (specifier: string): boolean => { + if (!specifier.includes("/attach-kernel.")) return false; + const found = pathInCopy(fileOf(specifier)); + return found !== null && attachKernelModules.has(found.path); +}; + +const rootModules = [...layouts("interface/index"), ...layouts("index")]; const namespaceModules: { readonly [namespace: string]: ReadonlySet } = Object.fromEntries( - [["System", "system/system"], ["Rule", "rule/rule"], ["Finalizer", "finalizer/finalizer"], ["Index", "indexes/index-type"]].map(([namespace, module]) => [namespace, new Set( - [`src/interface/${module}.ts`, `src/interface/${module}.js`, `dist/interface/${module}.js`, ...roots].map(path => `${packageRoot}/${path}`), - )]), + [["System", "system/system"], ["Rule", "rule/rule"], ["Finalizer", "finalizer/finalizer"], ["Index", "indexes/index-type"]] + .map(([namespace, module]) => [namespace, new Set([...layouts(`interface/${module}`), ...rootModules])]), ); -/** Whether a resolved module id is the module of this package's `namespace`, directly or through the package root. */ -export const isNamespaceModule = (id: string, namespace: string): boolean => namespaceModules[namespace]?.has(posix(id.split("?")[0] ?? id)) ?? false; +/** + * The copy of the package whose module of `namespace` a resolved module id is, directly or through the package root, + * in the id's layout; null when the id is no such module. + */ +export const namespaceCopyOf = (id: string, namespace: string): PackageCopy | null => { + const modules = namespaceModules[namespace]; + if (modules === undefined) return null; + const file = fileOf(id); + const found = pathInCopy(file); + return found !== null && modules.has(found.path) ? copyIn(found.root, found.path, extname(file)) : null; +}; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 9dea11dc..850bc7d8 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -374,3 +374,39 @@ test("a build takes a name from a package external to it as one key, and registr expect(description.keys.map(String)).toEqual(["Symbol(heat)"]); await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`System "externalList" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); }); + +test("compiled code imports the runtime from the copy of the package its module imports, not the plugin's own", async () => { + // A project whose installed copy of the package is a directory of its own, as a lockfile's copy is beside a linked one. + const project = join(directory, "copied"); + const copy = join(project, "node_modules/@adobe/ecs"); + const own = fileURLToPath(new URL("../../..", import.meta.url)); + mkdirSync(copy, { recursive: true }); + cpSync(join(own, "package.json"), join(copy, "package.json")); + cpSync(join(own, "dist"), join(copy, "dist"), { recursive: true, filter: file => !/\.(map|d\.ts)$|\.test\./.test(file) }); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "copy-app", type: "module" })); + const module = join(project, "copied.js"); + writeFileSync(module, `import { System, Component } from "@adobe/ecs"; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +export const kernel = System.create({ name: "copied", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const spawner = System.create({ name: "copiedSpawner", components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); +`); + const server = await createServer({ + configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], + appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, + }); + try { + const code = (await server.transformRequest(module))?.code ?? ""; + const imported = [...code.matchAll(/^import .* from "([^"]+)";$/gm)].map(match => match[1] ?? ""); + // The server serves files under its root by their path from it: here, the project's copy. + expect(imported.filter(path => path.includes("/implementation/world/"))).toEqual([ + "/node_modules/@adobe/ecs/dist/implementation/world/attach-kernel.js", + "/node_modules/@adobe/ecs/dist/implementation/world/prepared-spawn.js", + ]); + expect(code).not.toContain(own); + // The copy's System namespace is recognized, so its declarations take their ids. + expect(code).toContain(`"copy-app/copied.js#kernel"`); + } finally { + await server.close(); + } +}); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 928f760b..5cb1515d 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,7 +9,7 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageNameOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { isNamespaceModule, isSystemModule, packageIndex } from './paths.js'; +import { copyForImporter, namespaceCopyOf } from './paths.js'; import { locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; @@ -56,7 +56,7 @@ const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ const resolved = await resolver().resolve(specifier, importer, { skipSelf: true }); if (resolved === null) return { error: `${specifier} can't be resolved from ${importer}` }; if (resolved.external) { - return specifier === packageName ? packageIndex : { error: `${specifier} is external to the build, so kernels can't inline from it`, unreadable: true }; + return specifier === packageName ? copyForImporter(importer).index : { error: `${specifier} is external to the build, so kernels can't inline from it`, unreadable: true }; } const id = resolved.id.split("?")[0] ?? resolved.id; return id.includes("\0") || !isAbsolute(id) ? { error: `${specifier} isn't a file` } : id; @@ -191,14 +191,14 @@ export const kernelPlugin = (): Plugin => { const parse = (input: string) => estreeOf(this.parse(input)); const identify = (input: string) => identifyModule(input, moduleIdOf(id, root, packageNameOf), parse, async (source, namespace) => { const resolved = await this.resolve(source, id); - return resolved !== null && (resolved.external ? source === packageName : isNamespaceModule(resolved.id, namespace)); + return resolved !== null && (resolved.external ? source === packageName : namespaceCopyOf(resolved.id, namespace) !== null); }); const ssr = options?.ssr === true; const linker = linkerFor(ssr, this); const hashes: string[] = []; const transformed = await transformKernels(code, id, parse, async source => { const resolved = await this.resolve(source, id); - return resolved !== null && isSystemModule(resolved.id); + return resolved === null ? null : namespaceCopyOf(resolved.id, "System"); }, (index, identity) => { const hash = kernelHash(identity); hashes[index] = hash; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index bd257aca..d3833020 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -8,7 +8,7 @@ import { parseAst } from 'vite'; import { chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; import { transformKernels } from './transform.js'; import { createLinker, type LinkHost } from './link.js'; -import { packageIndex } from './paths.js'; +import { ownCopy } from './paths.js'; import { locateThroughMap } from './source-map.js'; import { estreeOf } from './estree.js'; import { Mat4x4, Quat, Vec3 } from '../../interface/math/index.js'; @@ -31,7 +31,7 @@ const target = "target"; /** Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package. */ const host = (files: { readonly [file: string]: string }): LinkHost => ({ resolve: async (specifier, importer) => { - if (specifier === "ecs") return packageIndex; + if (specifier === "ecs") return ownCopy.index; if (specifier === "external") return { error: "external is external to the build", unreadable: true }; if (!specifier.startsWith(".")) return { error: `${specifier} can't be resolved from ${importer}` }; const path = resolve(dirname(importer), specifier); @@ -48,7 +48,7 @@ const host = (files: { readonly [file: string]: string }): LinkHost => ({ }); const transform = (source: string, files: { readonly [file: string]: string } = {}) => transformKernels(`${header}${source}`, "/test.js", parse, - async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`), createLinker(host(files), parse)); + async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files), parse)); type Compiled = KernelDescription & KernelModule; @@ -834,7 +834,7 @@ describe("subset", () => { test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { expect(await transform(`const s = System.create({ name: "a", components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); - expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => false, () => "")).toBeNull(); + expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); @@ -879,7 +879,7 @@ describe("terminal bodies", () => { expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); const kernel = await transform(`const kernel = System.create({ name: "k", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); - const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => true, () => ""); + const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); expect(finalizer).toBeNull(); }); }); @@ -1383,7 +1383,7 @@ describe("declared access", () => { }); test("finalizers are checked as main-thread bodies, in files that import only Finalizer", async () => { - const finalizing = (source: string) => transformKernels(source, "/finalizer.js", parse, async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`)); + const finalizing = (source: string) => transformKernels(source, "/finalizer.js", parse, async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`)); const failed = async (source: string) => { const result = await finalizing(source); return result !== null && "failure" in result ? result.failure.message : "compiled"; diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 1bb93bb9..544f4b8c 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -5,7 +5,7 @@ import { children, isFunction } from './estree.js'; import { fail, failureOf, type Failure } from './failure.js'; import { isPending, type Link, type Linker } from './link.js'; import { createModule, locateIn, type Module } from './module-scope.js'; -import { attachKernelPath, prepareLookupPath, prepareSpawnPath } from './paths.js'; +import type { PackageCopy } from './paths.js'; import { spawnSites } from './spawns.js'; import { kernelOf } from './signature.js'; import { usedBy } from './body-access.js'; @@ -16,8 +16,11 @@ import { lookupSites } from './lookups.js'; import { terminalBodiesName } from '../world/terminal.js'; export type Parse = (code: string) => Program; -/** Whether an import specifier, resolved from the module being transformed, is the package's, which exports `System` and `Finalizer`. */ -export type ResolveSystemImport = (source: string) => Promise; +/** + * The copy of the package an import specifier, resolved from the module being transformed, is a module of that exports + * `System` and `Finalizer`; null when it's no such module. Compiled code imports the runtime from that copy. + */ +export type ResolveSystemImport = (source: string) => Promise; /** * The JS expression for kernel `index`'s module url. `identity` is everything the description and module are * compiled to (the description without its url, plus the module source), so it can version the url. @@ -34,17 +37,21 @@ export type TransformResult = type Edit = { readonly start: number; readonly end: number; readonly text: string }; /** The local names of the package's `System` and `Finalizer` namespaces, by the namespace each names. */ -const systemLocals = async (program: Program, resolveSystemImport: ResolveSystemImport): Promise> => { +/** The module's local names of `System` and `Finalizer`, and the copy of the package the first of them comes from. */ +const systemLocals = async (program: Program, resolveSystemImport: ResolveSystemImport): Promise<{ readonly locals: ReadonlyMap; readonly copy: PackageCopy | null }> => { const locals = new Map(); + let copy: PackageCopy | null = null; for (const statement of program.body) { if (statement.type !== "ImportDeclaration" || typeof statement.source.value !== "string") continue; const specifiers = statement.specifiers.flatMap(specifier => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && (specifier.imported.name === "System" || specifier.imported.name === "Finalizer") ? [{ local: specifier.local.name, imported: specifier.imported.name }] : []); - if (specifiers.length > 0 && await resolveSystemImport(statement.source.value)) { + const resolved = specifiers.length > 0 ? await resolveSystemImport(statement.source.value) : null; + if (resolved !== null) { + copy ??= resolved; for (const { local, imported } of specifiers) locals.set(local, imported === "System" ? "System" : "Finalizer"); } } - return locals; + return { locals, copy }; }; type Created = "system" | "finalizer" | "changes"; @@ -128,8 +135,8 @@ export const transformKernels = async ( ): Promise => { if ((!code.includes("System") && !code.includes("Finalizer")) || id.includes("\0")) return null; const program = parse(code); - const locals = await systemLocals(program, resolveSystemImport); - if (locals.size === 0) return null; + const { locals, copy } = await systemLocals(program, resolveSystemImport); + if (copy === null) return null; const file = id.split("?")[0] ?? id; const module = createModule(file, program, locateIn(code)); linker?.add(module); @@ -230,10 +237,10 @@ export const transformKernels = async ( let result = code; for (const edit of [...edits].sort((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); const imports = [ - ...kernels.length > 0 ? [`import { attachKernel as ${attach} } from ${JSON.stringify(attachKernelPath)};`] : [], - ...spawning ? [`import { prepareSpawn as ${prepare} } from ${JSON.stringify(prepareSpawnPath)};`] : [], - ...adding ? [`import { prepareAdd as ${prepareAddName} } from ${JSON.stringify(prepareSpawnPath)};`] : [], - ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(prepareLookupPath)};`] : [], + ...kernels.length > 0 ? [`import { attachKernel as ${attach} } from ${JSON.stringify(copy.attachKernel)};`] : [], + ...spawning ? [`import { prepareSpawn as ${prepare} } from ${JSON.stringify(copy.preparedSpawn)};`] : [], + ...adding ? [`import { prepareAdd as ${prepareAddName} } from ${JSON.stringify(copy.preparedSpawn)};`] : [], + ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(copy.preparedLookup)};`] : [], ]; const marking = terminals ? [`function ${terminal}(body) { const set = Symbol.for(${JSON.stringify(terminalBodiesName)}); (globalThis[set] || (globalThis[set] = new WeakSet())).add(body); return body; }`] : []; return { code: [...imports, result, ...prepared, ...marking].join("\n"), kernels, dependencies: [...dependencies] }; diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md index bf079d67..80a58acb 100644 --- a/packages/ecs/src/implementation/world/generated-code.md +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -21,7 +21,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { transformKernels } from '../implementation/compiler/transform.js'; import { createLinker } from '../implementation/compiler/link.js'; -import { packageIndex } from '../implementation/compiler/paths.js'; +import { ownCopy } from '../implementation/compiler/paths.js'; import { estreeOf } from '../implementation/compiler/estree.js'; import { generateWgsl } from '../implementation/world/wgsl.js'; import { generateWasm, type WasmColumn } from '../implementation/world/wasm.js'; @@ -36,7 +36,7 @@ const move = System.create({ name: "move", kernel: "gpu", components: { read: [v // Resolves the package to its source and relative imports to files, stripping types as the plugin does. const linker = createLinker({ resolve: async (specifier, importer) => { - if (specifier === "@adobe/ecs") return packageIndex; + if (specifier === "@adobe/ecs") return ownCopy.index; const path = resolve(dirname(importer), specifier); return existsSync(path.replace(/\.js$/, ".ts")) ? path.replace(/\.js$/, ".ts") : path; }, @@ -45,7 +45,7 @@ const move = System.create({ name: "move", kernel: "gpu", components: { read: [v return { code: file.endsWith(".ts") ? (await (await import("vite")).transformWithOxc(code, file, { lang: "ts" })).code : code }; }, }, parse); - const result = await transformKernels(source, "/move.js", parse, async s => s === "@adobe/ecs", i => JSON.stringify(`kernel:${i}`), linker); + const result = await transformKernels(source, "/move.js", parse, async s => s === "@adobe/ecs" ? ownCopy : null, i => JSON.stringify(`kernel:${i}`), linker); if (result === null || "failure" in result) throw new Error(JSON.stringify(result)); console.log(result.code); console.log(result.kernels[0]); From ef0b2e754efee19f81d0f982240f1e0a43a8f1be Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:31 -0700 Subject: [PATCH 003/127] ecs: build `up` trees and orders in bulk after a load New benchmark `editor load tick 50K`: a 2D editor's first tick after spawning a 50,201-node tree, with two filtered `up` row systems, a hash index on the parent and a sorted paint-order index. 32 ms before. - A filtered run after its tree was built again runs the whole order, rather than marking every row and scanning for the marks: the tick 32 -> 25.1 ms. - The tree's build reads each archetype's parent column once: the opacity `up` 5.35 -> 4.5 ms. - The build sets every level in one walk per entity: the opacity `up` 4.77 -> 4.49 ms. - An order build's scratch is shared by every `up` of a thread: the tick 20.6 -> 20.0 ms. `up load` 222 -> 184 ms. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/runner.ts | 25 +--- .../ecs/src/implementation/world/up.test.ts | 27 ++++ packages/ecs/src/implementation/world/up.ts | 132 +++++++++++++---- packages/ecs/src/perftest/benchmarks.ts | 2 + packages/ecs/src/perftest/editor.ts | 139 ++++++++++++++++++ 5 files changed, 277 insertions(+), 48 deletions(-) create mode 100644 packages/ecs/src/perftest/editor.ts diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index c0ce8cd4..f0509343 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -611,28 +611,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runUp = (since: number, values: readonly unknown[], args: unknown, tick: number, workers: Workers | null, index: number): Promise | null => { if (up === null) return null; const slot = partialCount === 0 ? 0 : takeSlots(1); - let next: () => readonly Run[] | null; - let clear = (): void => {}; - if (since < 0) { - const order = up.levels(matches); - let depth = 0; - next = () => { - if (depth >= order.levels) return null; - const runs: Run[] = []; - for (let group = order.depthGroups[depth]; group < order.depthGroups[depth + 1]; group++) { - if (order.groupEnds[group] === order.groupStarts[group]) continue; - runs.push({ owner: order.groupOwners[group], parent: order.groupParents[group], rows: order.rows, parents: order.parents, start: order.groupStarts[group], end: order.groupEnds[group] }); - } - depth++; - return runs; - }; - } - else { - const pass = up.filtered(matches, addedIds, changedIds, since); - if (pass === null) return null; - next = pass.next; - clear = pass.clear; - } + const pass = since < 0 ? up.wholePass(up.levels(matches)) : up.filtered(matches, addedIds, changedIds, since); + if (pass === null) return null; + const { next, clear } = pass; const runLocally = (run: Run): void => runUpRows(matches[run.owner], run.parent, run.rows, run.parents, run.start, run.end, values, args, tick, slot); const parallel = workers !== null && kernel !== null && kernel.wasm !== null && kernel.compiled() !== null && partialCount === 0 && kernel.lookupParameters().length + indexParameters.length + 3 + (kernel.description.upColumns?.length ?? 0) + kernel.wasm.scalars.length <= maximumScalars; diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index e021ed25..0d4ebac7 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -101,6 +101,12 @@ const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { const close = System.create({ name: "upTest/close" }, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); +/** Spawns `count` leaves under the grandchild, more than the journal keeps for a world this small. */ +const bulk: Entity[] = []; +const bulkUnder = System.create({ name: "upTest/bulkUnder", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + bulk.length = 0; + for (let i = 0; i < input.count; i++) bulk.push(commands.spawn([label, local, world, parent])({ [label]: "bulk", [local]: 1, [world]: 0, [parent]: entities.grandchild })); +}); const worlds = async (value: (entity: Entity) => Promise): Promise<{ [name: string]: number | null }> => Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); @@ -198,6 +204,27 @@ describe("up", () => { expect(await read(ecs, entities.grandchild, world)).toBe(100); }); + it("runs every row after a spawn outruns the journal, with change filters, then only the rows that changed", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChanged); + await ecs.run(bulkUnder, { count: 5000 }); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran.length).toBe(4 + 5000); + expect(ran.slice(0, 4).sort()).toEqual(["child", "grandchild", "other", "root"]); + expect(await readEach(ecs, bulk.slice(0, 3), world)).toEqual([112, 112, 112]); + ran.length = 0; + await ecs.run(bump, { entity: entities.grandchild }); + await ecs.run(propagateChanged); + expect(ran.length).toBe(1 + 5000); + expect(await readEach(ecs, [entities.grandchild, bulk[4999]], world)).toEqual([13, 14]); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran).toEqual([]); + }); + it("finds a cycle a reparent closes after the tree was built, and runs its rows again once it opens", async () => { await using ecs = createWorld({ [workers]: 0 }); await ecs.run(spawn); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts index 369a0221..da846c3a 100644 --- a/packages/ecs/src/implementation/world/up.ts +++ b/packages/ecs/src/implementation/world/up.ts @@ -44,6 +44,29 @@ const extend = (array: Int32Array, length: number, value: number): return grown; }; +// Scratch of an order build, shared by every `up` of a thread, since a build runs start to end without yielding: a +// second system reuses the first one's arrays rather than allocating its own. + +/** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ +let rowEntities = new Int32Array(0); +let depths = new Int32Array(0); +let rowParents = new Int32Array(0); +let keys = new Int32Array(0); +let owners = new Int32Array(0); +/** The matched rows' indexes, by depth, and where each depth ends. */ +let sortedRows = new Int32Array(0); +let counts = new Int32Array(0); +/** By index in `matches`: the index of its first row among the matched rows. */ +let bases = new Int32Array(0); +/** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ +let builtRows = new Int32Array(0); +let builtParents = new Int32Array(0); +let builtEntities = new Int32Array(0); +let groupEnds = new Int32Array(0); +let groupDepths = new Int32Array(0); +let groupOwners = new Int32Array(0); +let groupParents = new Int32Array(0); + /** * Orders an `up` system's rows by depth through its `through` reference. A row's depth is one more than its parent's * when its parent is among the matched rows, and 0 otherwise, so rows of one depth never read each other's writes. @@ -254,15 +277,67 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { for (const archetype of matches) count += archetype.count; const all = new Int32Array(count); let at = 0; + const levels = levelOf; for (const archetype of matches) { - for (let row = 0; row < archetype.count; row++) { - const index = archetype.entities[row] >>> 0; - levelOf[index] = 1; + const rows = archetype.entities; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = rows[row] >>> 0; + levels[index] = 1; all[at++] = index; } } - for (let k = 0; k < count; k++) link(all[k], parentAt(all[k])); - settle(all, count); + // Each archetype's parent column is found once, as `parentAt` finds it per entity. + const { generation, archetype: archetypeOf, length: entityCount } = state.entities; + const parents = parentOf; + at = 0; + for (const archetype of matches) { + const storage: Storage | undefined = archetype.storageById[through]; + const layout = storage?.layout; + const leaf = layout?.kind === "linear" ? layout.leaves[0] : undefined; + if (storage === undefined || leaf === undefined) { + for (let row = 0; row < archetype.count; row++) parents[all[at++]] = -1; + continue; + } + const view = storage.views[leaf.view]; + const stride = leaf.unitStride, offset = leaf.unitOffset; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = all[at++]; + const parent = view[row * stride + offset]; + const target = parent >>> 0; + link(index, parent > 0 && target < entityCount && archetypeOf[target] >= 0 && generation[target] === generationOf(parent) ? target : -1); + } + } + settleAll(all, count); + }; + + /** + * Sets the level of every matched entity in `all`, the whole tree: each walks up to a root or to an entity already + * set, then sets the levels on its way back down, so each entity is walked once. A walk meeting itself is a cycle, + * and it and everything below it on the walk is cyclic. + */ + const settleAll = (all: Int32Array, count: number): void => { + const walking = ++pass; + const done = ++pass; + stack = extend(stack, count, 0); + const levels = levelOf, parents = parentOf, seen = stamps, path = stack; + for (let k = 0; k < count; k++) { + let current = all[k]; + if (seen[current] === done) continue; + let height = 0; + for (; current >= 0 && levels[current] !== unmatched; current = parents[current]) { + const stamp = seen[current]; + if (stamp === done || stamp === walking) break; + seen[current] = walking; + path[height++] = current; + } + let level = current < 0 || levels[current] === unmatched ? 0 : seen[current] === done ? levels[current] : cyclic; + for (let h = height - 1; h >= 0; h--) { + const node = path[h]; + if (level !== cyclic) level++; + levels[node] = level; + seen[node] = done; + } + } }; /** Brings the tree up to date with the journal. */ @@ -326,26 +401,6 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { settle(pending, pending.length); }; - /** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ - let rowEntities = new Int32Array(0); - let depths = new Int32Array(0); - let rowParents = new Int32Array(0); - let keys = new Int32Array(0); - let owners = new Int32Array(0); - /** The matched rows' indexes, by depth, and where each depth ends. */ - let sortedRows = new Int32Array(0); - let counts = new Int32Array(0); - /** By index in `matches`: the index of its first row among the matched rows. */ - let bases = new Int32Array(0); - /** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ - let builtRows = new Int32Array(0); - let builtParents = new Int32Array(0); - let builtEntities = new Int32Array(0); - let groupEnds = new Int32Array(0); - let groupDepths = new Int32Array(0); - let groupOwners = new Int32Array(0); - let groupParents = new Int32Array(0); - /** The matched rows in depth order from the roots. Rows on or under a parent cycle are in none. */ const levels = (matches: readonly Archetype[]): Order => { update(matches); @@ -566,6 +621,13 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { */ const filtered = (matches: readonly Archetype[], added: readonly number[], changed: readonly number[], since: number): Pass | null => { update(matches); + // Every row passes after a rebuild, so the pass is the whole order, with nothing to mark. + if (rebuilt) { + touched = []; + rebuilt = false; + const ordered = levels(matches); + return ordered.levels === 0 ? null : wholePass(ordered); + } const length = state.entities.length; if (dirty.length < length) dirty = new Uint8Array(Math.max(length, 2 * dirty.length)); for (const bucket of buckets) if (bucket !== undefined) bucket.length = 0; @@ -672,6 +734,22 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { return { next, clear }; }; + /** Every row of `ordered`, a depth at a time. */ + const wholePass = (ordered: Order): Pass => { + let depth = 0; + const next = (): readonly Run[] | null => { + if (depth >= ordered.levels) return null; + const runs: Run[] = []; + for (let group = ordered.depthGroups[depth]; group < ordered.depthGroups[depth + 1]; group++) { + if (ordered.groupEnds[group] === ordered.groupStarts[group]) continue; + runs.push({ owner: ordered.groupOwners[group], parent: ordered.groupParents[group], rows: ordered.rows, parents: ordered.parents, start: ordered.groupStarts[group], end: ordered.groupEnds[group] }); + } + depth++; + return runs; + }; + return { next, clear: noClear }; + }; + /** Frees what it holds in the arena and stops its reading of the journal, once nothing runs it again. */ const release = (): void => { state.journal.readers--; @@ -681,5 +759,7 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { list = parentList = new Int32Array(0); }; - return { levels, filtered, release }; + return { levels, filtered, wholePass, release }; }; + +const noClear = (): void => {}; diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index e0cea84b..100d4edf 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -21,6 +21,7 @@ import { readBenchmarks } from "./reads.js"; import { observedBenchmarks } from "./observed.js"; import { systemBenchmarks } from "./systems.js"; import { indexBenchmarks } from "./indexes.js"; +import { editorBenchmarks } from "./editor.js"; import { stringBenchmarks } from "./strings.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { createWorld } from "../implementation/index.js"; @@ -687,6 +688,7 @@ export const benchmarks: readonly Benchmark[] = [ ...observedBenchmarks, ...systemBenchmarks, ...indexBenchmarks, + ...editorBenchmarks, ...stringBenchmarks, ...valueBenchmarks, structReplace("main", 0), diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts new file mode 100644 index 00000000..c5821ab8 --- /dev/null +++ b/packages/ecs/src/perftest/editor.ts @@ -0,0 +1,139 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// A 2D editor loading a document: a tree of groups and leaves, a sorted paint-order index, a hash index of children +// by parent, and `up` systems with change filters propagating transforms and opacity down the tree. + +const localX = Symbol("editor/localX"); +const localY = Symbol("editor/localY"); +const worldX = Symbol("editor/worldX"); +const worldY = Symbol("editor/worldY"); +const opacity = Symbol("editor/opacity"); +const worldOpacity = Symbol("editor/worldOpacity"); +const paintDepth = Symbol("editor/paintDepth"); +const parent = Symbol("editor/parent"); +const group = Symbol("editor/group"); +const shape = Symbol("editor/shape"); +const image = Symbol("editor/image"); +declare module "@adobe/ecs" { + export interface Components { + [localX]: { type: number }; + [localY]: { type: number }; + [worldX]: { type: number }; + [worldY]: { type: number }; + [opacity]: { type: number }; + [worldOpacity]: { type: number }; + [paintDepth]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [group]: { type: true }; + [shape]: { type: true }; + [image]: { type: true }; + } +} +Component.register(localX, { type: { type: "number" } }); +Component.register(localY, { type: { type: "number" } }); +Component.register(worldX, { type: { type: "number" } }); +Component.register(worldY, { type: { type: "number" } }); +Component.register(opacity, { type: { type: "number" } }); +Component.register(worldOpacity, { type: { type: "number" } }); +Component.register(paintDepth, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(group, { type: { const: true } }); +Component.register(shape, { type: { const: true } }); +Component.register(image, { type: { const: true } }); + +const node = [localX, localY, worldX, worldY, opacity, worldOpacity, paintDepth] as const; +const Document = Archetype.create([...node, group]); +const Group = Archetype.create([...node, parent, group]); +const Shape = Archetype.create([...node, parent, shape]); +const Image = Archetype.create([...node, parent, image]); + +const byPaintDepth = Index.create({ name: "editor/byPaintDepth", key: [paintDepth], sorted: true }); +const childrenOf = Index.create({ name: "editor/childrenOf", key: [parent] }); + +/** Groups under the document, subgroups in each, and leaves in each subgroup: 50,201 nodes. */ +const groups = 200; +const subgroups = 2; +const leaves = 124; +const nodes = 1 + groups * (1 + subgroups * (1 + leaves)); + +/** Spawns the document depth first, as a file lists it, its paint depth its place in that order. */ +const load = System.create({ name: "editor/load" }, ({ commands }) => { + let depth = 0; + const values = (x: number) => ({ [localX]: x, [localY]: 1, [worldX]: 0, [worldY]: 0, [opacity]: 0.99, [worldOpacity]: 1, [paintDepth]: depth++ }); + const document = commands.spawn(Document)(values(0)); + for (let g = 0; g < groups; g++) { + const outer = commands.spawn(Group)({ ...values(g), [parent]: document, [group]: true }); + for (let s = 0; s < subgroups; s++) { + const inner = commands.spawn(Group)({ ...values(s), [parent]: outer, [group]: true }); + for (let l = 0; l < leaves; l++) { + if (l % 2 === 0) commands.spawn(Shape)({ ...values(l), [parent]: inner, [shape]: true }); + else commands.spawn(Image)({ ...values(l), [parent]: inner, [image]: true }); + } + } + } +}); + +/** Each node's world position is its parent's plus its own. */ +const place = System.create({ + name: "editor/place", + components: { read: [localX, localY], write: [worldX, worldY], changed: [localX, localY] }, + up: { through: parent, read: [worldX, worldY] }, +}, (row, { up }) => { + row[worldX] = (up === null ? 0 : up[worldX]) + row[localX]; + row[worldY] = (up === null ? 0 : up[worldY]) + row[localY]; +}); +/** Each node's world opacity is its parent's times its own. */ +const fade = System.create({ + name: "editor/fade", + components: { read: [opacity], write: [worldOpacity], changed: [opacity] }, + up: { through: parent, read: [worldOpacity] }, +}, (row, { up }) => { + row[worldOpacity] = (up === null ? 1 : up[worldOpacity]) * row[opacity]; +}); +let checksum = 0; +/** Each group counts its children, as a layout does. */ +const layout = System.create({ name: "editor/layout", components: { has: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { + checksum += lookup.children(row.entity).length; +}); +/** Visits every node in paint order, as drawing does. */ +const paint = System.create({ name: "editor/paint", components: { read: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { + checksum = (checksum * 31 + row[worldX] * row[worldOpacity]) % 1000003; +}); +const frame = System.create({ name: "editor/frame", systems: [place, fade, layout, paint] }); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +export const editorBenchmarks: readonly Benchmark[] = [{ + name: "editor load tick 50K", + operation: "A 2D editor's first tick after loading a 50K-node document of groups and leaves: two `up` systems with change filters propagate transforms and opacity down the tree, a layout looks up children through a hash index on the parent, and painting runs in a sorted index's order, so every `up` order and index is built from scratch.", + per: "nodes", + n: nodes, + typical: [1_000, 100_000], + best: { bytes: 100, basis: "Each node reads its parent link, its 7 values and its parent's 3, writes 3, and enters two indexes and two orders." }, + run: async () => { + let best = Infinity; + for (let i = 0; i < 8; i++) { + const world = createWorld({ [workers]: 0 }); + await world.run(frame); + await world.run(load); + checksum = 0; + const start = performance.now(); + await world.run(frame); + best = Math.min(best, performance.now() - start); + check(checksum !== 0, "editor load tick: nothing ran"); + await world.dispose(); + } + return best; + }, +}]; + From 46fc3ff40ea9fe96652d4b1a6076ea5fc23b1170 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:38 -0700 Subject: [PATCH 004/127] ecs: skip a sorted index rebuild's sort when entries are in key order A load in document order lists a paint-order index's entries in key order already; one pass that stops at the first pair out of order checks it. `editor load tick 50K`'s paint system 5.6-5.8 -> 5.1-5.3 ms. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/indexes.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index 82c804e2..1d1cab2b 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -1067,6 +1067,8 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 order.set(ordered); return order; } + // Entries already in key order, as a load in document order lists them, need no sort. + if (inKeyOrder(keys, count, words)) return order; const histogram = built.histogram; for (let w = words - 1; w >= 0; w--) { for (let shift = 0; shift < 32; shift += 16) { @@ -1094,6 +1096,21 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 return order; }; +/** Whether each of `count` entries' keys, `words` each, is at least the one before, comparing words from the first. */ +const inKeyOrder = (keys: Uint32Array, count: number, words: number): boolean => { + for (let k = 1; k < count; k++) { + const previous = (k - 1) * words; + const next = k * words; + for (let w = 0; w < words; w++) { + const a = keys[previous + w]; + const b = keys[next + w]; + if (a < b) break; + if (a > b) return false; + } + } + return true; +}; + /** Stamps the buckets holding rows whose watched component changed since it was last read, with `tick`. */ const scanWatched = (state: State, index: WorldIndex, component: number, tick: number): void => { const since = index.scanned.get(component) ?? tick; From 955a31368efb6f15b7d257717e03b02d31cd433b Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:12:47 -0700 Subject: [PATCH 005/127] ecs: bind an ordered run's row in place when the archetype changes A main-thread system run in an index's order looked its shared row up again whenever the archetype changed, at every row when archetypes interleave in paint order. `editor load tick 50K`'s paint system 4.85 -> 4.5 ms. perf.md records the editor load work: `editor load tick 50K` 31.4-33.2 -> 20.0-20.4 ms and `up load` 221-223 -> 182-186 ms against 826c45ad, alternating, with the attempts not kept. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/world/perf.md | 13 +++++++++++++ packages/ecs/src/implementation/world/runner.ts | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 78e66f7e..550c7d2a 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -146,6 +146,14 @@ Render systems with `order`, against `9daaa3f8`, chromium, three runs each: new - **The order block is listed and written only when the index's entries changed or moved rows,** by the index's `changed` tick and a `moved` tick its refresh stamps when it relocates an entry with its key unchanged, or when the matches grew; the tables block only when a column's address changed; the record only when one of its four words did. An unchanged run writes nothing to the queue and allocates nothing: the table, its `bind`, the tables list and the context are reused. - **An entry is one u32:** the matching archetype's slot in the high bits, as few as the matches need and at least one, and its row in the rest, so the shader masks by the record's shift. +A 2D editor's first tick after loading a document, against `826c45ad`, node, alternating, three rounds: new `editor load tick 50K` 31.4–33.2 → 20.0–20.4 ms, and `up load` 221–223 → 182–186 ms; within noise: `up depth 10`, `up depth 1000`, `up after a spawn`, `up filtered after churn`, `up filtered after a reparent`, `up small tree in a big world`, `index rebuild 1M main`, `order run 100K`, `order filtered 100K 1%`, `children lookup`, `sorted range 1M`, `sorted page 100K`. The benchmark spawns 50,201 nodes depth first, a document, 200 groups, two subgroups in each and 124 shapes and images in each subgroup, after a first tick over the empty world, then times the next tick: two `up` row systems with `changed` filters writing a position and an opacity, a layout looking up each group's children through a hash index on the parent, and a paint system run in a sorted index's paint order. Before, each system's part of the tick, run alone after the load: the position `up` 13.1 ms, the opacity `up` 10.4, the layout 2.45 (its index's build), the paint 5.43 (its index's build and 50K ordered rows). Of the opacity `up`'s profile, building the tree 22%, marking every row as passing the filters (`changedRows`, `mark`, clearing the marks) 25%, building the order 19%, listing the marked rows from it 10%, and the body 20%. After: 6.4, 4.4, 2.15 and 4.4 ms. +- **A filtered run after its tree was built again runs the whole order,** as an unfiltered one does, since every row passes then: the tick 32 → 25.1 ms, the position `up` 13.1 → 8.5 and the opacity `up` 10.4 → 7.1. Marking 50K rows, each in a bucket of its depth, and scanning the order for the marks cost as much as the run. A later sparse run is cold for it: `up one root changed` 28 → 33 µs a run over the benchmark's 100 rounds, the marking path no longer warmed by a first full pass, and 14 µs either way after 1,000 rounds. +- **The tree's build reads each archetype's parent column once,** rather than finding the archetype, column and layout for every entity: the opacity `up` 5.35 → 4.5 ms. +- **The build sets every level in one walk per entity,** up to a root or an entity already set and back down, rather than settling the entities as journaled changes do, which walk each one's children again: the opacity `up` 4.77 → 4.49 ms. +- **An order build's scratch is shared by every `up` of a thread:** the second system reuses the first one's arrays rather than allocating its own, 13 of them 50K entries each: the tick 20.6 → 20.0 ms, medians of five rounds. +- **A sorted index's rebuild skips the sort when its entries are in key order already,** as a load in document order lists them, checked in one pass that stops at the first pair out of order: the paint 5.6–5.8 → 5.1–5.3 ms. +- **A main-thread system run in an index's order binds its row in place when the archetype changes,** rather than looking the shared row up again, which happens at every row when archetypes interleave in the order: the paint 4.85 → 4.5 ms. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -341,11 +349,16 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **Reading a patch's keys, hashes and buckets ahead in batches of 64,** so their misses overlap: `index refresh 1M 1%` 165 → 173 ns and `rekey 1M` 14.7 → 23.7 ms. A move in cache costs the same, so there was little latency to hide. - **A list's pool in three banks, its first, last and middle positions,** for hash lists too: `grid neighbors 100K main` 3.63 → 3.84 µs a row from choosing the bank in `at`. Only sorted lists keep a second pool for their last positions. - **Making a pool's rows for every position a list holds:** a 100K-row sorted list made 100K rows for a page of 20; rows are made ahead for the first and last 256 positions only. +- **The tree's build linking parents over locals,** rather than through `link`: the opacity `up` 4.44–4.65 against 4.55–4.76 ms, about 2%, within noise. +- **An index rebuild listing its entities in one loop over locals,** rather than a closure per entity: `index rebuild 1M main` 28.3–30 against 28.7–34.7 ms, and the editor's layout 2.33–2.84 against 2.35–2.56 ms, no gain. +- **Binding a row without making a slot object per component:** the editor's paint 5.02–5.06 against 4.99–5.07 ms, no gain; V8 removes the allocation. +- **Presizing a hash index's table for a rebuild,** measured rather than tried: the editor's 601 parents rehash and place their buckets in 0.2 of 34 ms of profile, since a table is sized for the buckets it last held. Worth revisiting for a first build of an index with as many keys as entities. ## Next - `grid neighbors 100K main`, 3.66 µs a row against about 1.6 µs for the kernel on one worker: each neighbor costs about 10 ns in loads of the list's state, the pool and the row's slots; rows whose values carry their index and views as their own fields would save a few of them. - Index refreshes, 141 ns a moved entity: two ordered bucket edits and a probe, in cache. +- `editor load tick 50K`, 20 ms: each `up` system builds its own tree and order, about 1.3 ms each over 50K rows, though systems with the same `through` and matches build the same ones; sharing them would need each system's own journal position, touched rows and order patches. Of the rest, the `up` bodies through accessor rows take about 2 ms a system, the 50K ordered paint rows about 2 ms, and each index's rebuild about 1.5 ms, 28 ns an entity listing and encoding its key. - `loop tick add remove`, a new system each tick: about 2 µs more a tick since indexes (above), in `System.create` and `add`, as more garbage; the paths a run of an existing system takes measure as before. - Index rebuilds across the pool: `index rebuild 1M workers` 6.7 ms, nine workers 4.3× one, every phase scattering or gathering by partition; an estimated 2–3 ms if the scatters reached memory bandwidth. Rebuilds are rare, a refresh moving more than an eighth of an index. diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index f0509343..3f4f7c5a 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -572,7 +572,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owner = archetype < owners.length ? owners[archetype] : -1; if (owner < 0) continue; if (owner !== current || row === null || value === null) { - row = bindRow(matches[owner]); + // The row is found once; after, it's bound in place, as often as every row when archetypes interleave. + if (row === null) row = bindRow(matches[owner]); + else row.bind(matches[owner]); value = row.value; current = owner; } From 8373dfb85ce6da7d58831ed2b6c1f4104859e196 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 01:16:13 -0700 Subject: [PATCH 006/127] ecs: followups: descending sorted indexes Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a0067d87..5a627749 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -35,6 +35,9 @@ Open items awaiting a decision or work. Remove an item once it's decided or done per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU row order. Not needed until a consumer's profile says so. +- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front + maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on + sorted indexes. Not needed until a consumer's profile or readability says so. - **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then a world option or resource. From 38b10b2b03832a4c6178bdbb7a7c211ef86e4f68 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 20:33:22 -0700 Subject: [PATCH 007/127] ecs: version 0.1.1 Co-Authored-By: Claude Opus 5.5 --- packages/ecs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/package.json b/packages/ecs/package.json index 8c605e03..834ad950 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs", - "version": "0.1.0", + "version": "0.1.1", "description": "Adobe ECS", "license": "MIT", "type": "module", From cbb21ab31da098bbdce19ae5a5a0e43d5627334b Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 20:40:25 -0700 Subject: [PATCH 008/127] ecs: one open PR at a time Co-Authored-By: Claude Opus 5.5 --- packages/ecs/CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md index 91009ef6..dd3f86a7 100644 --- a/packages/ecs/CLAUDE.md +++ b/packages/ecs/CLAUDE.md @@ -44,6 +44,11 @@ when a measured win conflicts with a style or design preference, take the win an row as low as it can be, and allocate nothing on those paths: reuse arrays, buffers and objects, share read-only empties, and avoid closures, spreads and awaits there. Measure changes with `pnpm perf` against the recorded baseline. +## Pull requests + +Each PR costs a review, a merge and a publish, so keep one open PR at a time. Add new work and fixes to it as more +commits; never open stacked or parallel PRs. Keep it rebased on main, with this package's version one above main's. + ## Memory Decisions about working on this package live in `memory.md`; add new ones there. From b5e2ea4947b13c6d13c39514bf408d4c3c006381 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 21:00:57 -0700 Subject: [PATCH 009/127] ecs: README for the transfers sample Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/samples/transfers/README.md | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/ecs/src/samples/transfers/README.md diff --git a/packages/ecs/src/samples/transfers/README.md b/packages/ecs/src/samples/transfers/README.md new file mode 100644 index 00000000..61a8ec04 --- /dev/null +++ b/packages/ecs/src/samples/transfers/README.md @@ -0,0 +1,35 @@ +# Transfers sample + +Shows how values that can't be copied between threads reach the thread that uses them. A canvas and decoded textures +are moved, not copied, to the world's worker, which draws sprites with them every frame. Components and resources +holding such values are registered with `transfer: true`, and a run's input carries them to the world's thread. + +`pnpm sample`, then open `/transfers/`. Numbered discs appear one at a time, 48 in all, and bounce around the canvas; +the overlay counts the sprites and frames. + +## Threads + +- **Main** (`main.ts`) owns the page. It starts both workers, hands each a port to the world, and joins the world. It + moves its `` to the world's thread with `transferControlToOffscreen()`, as the input of a run of + `attachSurface`, and adds `show`, an observer system that writes the counts into the overlay after each frame that + changes them. +- **The world's worker** (`world.ts`) creates the world, which ticks every frame, and shares it on the ports main + sends. The canvas becomes its `surface` resource. +- **The loader's worker** (`loader.ts`) joins the world and makes one image every 120 ms: it draws a disc of its own + hue, encodes it as a PNG, decodes it to an `ImageBitmap` and runs `addSprite` with it. The bitmap is detached in the + loader once the run is posted. + +## Data and systems + +`sprites.ts` holds them, and every thread imports it: + +- `texture`, a component registered with `transfer: true`, holds a sprite's `ImageBitmap`; `position` and `velocity` + are `Vec2`s, in pixels and pixels per second. +- `surface`, a resource registered with `transfer: true`, holds the `OffscreenCanvas`; `stats` counts sprites and + frames. +- `attachSurface` and `addSprite` take what main and the loader move in their input. `frame`, the composite the world + adds, clears the canvas, moves each sprite, bouncing off the edges, and draws each one's texture where it is. + +## Tests + +`build.test.ts`: the sample builds as an application, with the world's worker and the loader's. From 57af1b1bd5e5b7eef32d48304a720a3e9da5d5e3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 21:10:36 -0700 Subject: [PATCH 010/127] ecs: dev serves the samples, with a sample browser, and opens no browser Co-Authored-By: Claude Opus 5.5 --- packages/ecs/CLAUDE.md | 3 +- packages/ecs/package.json | 2 +- packages/ecs/src/implementation/world/perf.md | 2 +- packages/ecs/src/samples/browser.test.ts | 19 +++++++++++ packages/ecs/src/samples/browser.ts | 20 +++++++++++ packages/ecs/src/samples/index.html | 34 +++++++++++++++++++ packages/ecs/src/samples/todo/README.md | 2 +- packages/ecs/src/samples/transfers/README.md | 2 +- packages/ecs/vite.sample.config.ts | 6 ++-- 9 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 packages/ecs/src/samples/browser.test.ts create mode 100644 packages/ecs/src/samples/browser.ts create mode 100644 packages/ecs/src/samples/index.html diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md index dd3f86a7..658ac6a8 100644 --- a/packages/ecs/CLAUDE.md +++ b/packages/ecs/CLAUDE.md @@ -25,7 +25,8 @@ Humans own the interface and agents own the implementation: agents don't add to It's a project of its own: nothing in it imports from outside it. Each type exists once, there. - **`src/implementation`** holds `createWorld` and everything behind it, the kernel compiler included. It imports types and the registry's read side from `src/interface`. -- **`src/samples`** holds one folder per sample, its page included; `pnpm sample` serves them. +- **`src/samples`** holds one folder per sample, its page included. `pnpm dev` (or `pnpm sample`, without + the type-check watch) serves them, with a sample browser at the root that has a tab for each. - `src/index.ts` exports `src/interface`, and `World` as a value too, whose `create` and `join` are the implementation's, so `import { World } from "@adobe/ecs"` gives consumers the type, `World.create` and `World.join`, the only entry points. Samples and the guide use them; tests and benchmarks may take `createWorld` from diff --git a/packages/ecs/package.json b/packages/ecs/package.json index 834ad950..be1bf7b7 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -30,7 +30,7 @@ ], "scripts": { "build": "cp ../../LICENSE . && tsc -b", - "dev": "tsc -b -w --preserveWatchOutput", + "dev": "tsc -b -w --preserveWatchOutput & vite --config vite.sample.config.ts", "typecheck": "tsc -b", "test": "pnpm exec playwright install chromium && vitest --run", "test:perf": "pnpm exec playwright install chromium && vitest --run --config vitest.perf.config.ts", diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 550c7d2a..0b9bd6cc 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -224,7 +224,7 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ ## GPU particles -The sample (`pnpm sample`, which opens `/particles/`) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): +The sample (`pnpm dev`, the GPU Particles tab, or `/particles/` alone) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): | Per frame, ms | draw only | CPU move + upload | GPU move | |---|---|---|---| diff --git a/packages/ecs/src/samples/browser.test.ts b/packages/ecs/src/samples/browser.test.ts new file mode 100644 index 00000000..8c698a8d --- /dev/null +++ b/packages/ecs/src/samples/browser.test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const samples = fileURLToPath(new URL(".", import.meta.url)); + +it("the sample browser has a tab for each sample folder, titled as its page", () => { + const page = readFileSync(`${samples}index.html`, "utf8"); + const tabs = [...page.matchAll(/([^<]+)<\/a>/g)].map(([, name, title]) => ({ name, title })); + const folders = readdirSync(samples, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && existsSync(`${samples}${entry.name}/index.html`)) + .map(entry => { + const title = /([^<]+)<\/title>/.exec(readFileSync(`${samples}${entry.name}/index.html`, "utf8"))?.[1]; + return { name: entry.name, title }; + }); + expect(tabs).toEqual(folders); +}); diff --git a/packages/ecs/src/samples/browser.ts b/packages/ecs/src/samples/browser.ts new file mode 100644 index 00000000..b99c475a --- /dev/null +++ b/packages/ecs/src/samples/browser.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The sample browser: a tab per sample, the one the URL's hash names shown in the frame, the first when it names none. + +const tabs = [...document.querySelectorAll<HTMLAnchorElement>('nav a[href^="#"]')]; +const frame = document.querySelector("iframe"); +const alone = document.querySelector<HTMLAnchorElement>("#alone"); + +const show = (): void => { + const chosen = tabs.find(tab => tab.hash === location.hash) ?? tabs[0]; + if (chosen === undefined || frame === null) return; + const page = `./${chosen.hash.slice(1)}/`; + for (const tab of tabs) if (tab === chosen) tab.setAttribute("aria-current", "page"); else tab.removeAttribute("aria-current"); + if (frame.getAttribute("src") !== page) frame.setAttribute("src", page); + if (alone !== null) alone.href = page; + document.title = `${chosen.textContent ?? ""} · ECS Samples`; +}; + +addEventListener("hashchange", show); +show(); diff --git a/packages/ecs/src/samples/index.html b/packages/ecs/src/samples/index.html new file mode 100644 index 00000000..973ce70d --- /dev/null +++ b/packages/ecs/src/samples/index.html @@ -0,0 +1,34 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>ECS Samples + + + + + + + + + diff --git a/packages/ecs/src/samples/todo/README.md b/packages/ecs/src/samples/todo/README.md index 04313d52..ceffdf52 100644 --- a/packages/ecs/src/samples/todo/README.md +++ b/packages/ecs/src/samples/todo/README.md @@ -3,7 +3,7 @@ A todo app whose world drives every change to the DOM through systems, while its elements know nothing about the ECS. It's the experiment for `ecs-dom.md`: vanilla custom elements, no framework, no persistence. -`pnpm sample`, then open `/todo/`. Add, toggle, edit (double-click), delete, move (drag, or Alt+↑ and Alt+↓), mark all +`pnpm dev`, then the Todos tab. Add, toggle, edit (double-click), delete, move (drag, or Alt+↑ and Alt+↓), mark all done or none, clear those done, filter, and generate 10 or 1,000 todos. ## How the DOM is driven diff --git a/packages/ecs/src/samples/transfers/README.md b/packages/ecs/src/samples/transfers/README.md index 61a8ec04..845a9ffc 100644 --- a/packages/ecs/src/samples/transfers/README.md +++ b/packages/ecs/src/samples/transfers/README.md @@ -4,7 +4,7 @@ Shows how values that can't be copied between threads reach the thread that uses are moved, not copied, to the world's worker, which draws sprites with them every frame. Components and resources holding such values are registered with `transfer: true`, and a run's input carries them to the world's thread. -`pnpm sample`, then open `/transfers/`. Numbered discs appear one at a time, 48 in all, and bounce around the canvas; +`pnpm dev`, then the Transferred Sprites tab. Numbered discs appear one at a time, 48 in all, and bounce around the canvas; the overlay counts the sprites and frames. ## Threads diff --git a/packages/ecs/vite.sample.config.ts b/packages/ecs/vite.sample.config.ts index 6610e675..f7c583d1 100644 --- a/packages/ecs/vite.sample.config.ts +++ b/packages/ecs/vite.sample.config.ts @@ -3,9 +3,11 @@ import { defineConfig } from "vite"; import { kernelPlugin } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; -/** Serves `src/samples`, one folder per sample, cross-origin isolated for shared memory: `pnpm sample` opens /particles/. */ +/** + * Serves `src/samples`, one folder per sample with the sample browser at the root, cross-origin isolated for shared + * memory. It opens no browser. + */ export default defineConfig({ root: "src/samples", plugins: [kernelPlugin(), isolation, sources], - server: { open: "/particles/" }, }); From 0b2b8d8ddfc7f6e917af756bbe37c714c007a63f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 22:53:01 -0700 Subject: [PATCH 011/127] ecs: a system's input is the type of its body's parameter after its context The declaration's input schema goes away: the plugin reads the schema the runtime needs from the parameter's TypeScript type, before types are stripped, and passes it to create. Checks on the input move to the body. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 20 +- packages/ecs/followups.md | 3 +- packages/ecs/src/exports.test.ts | 4 +- packages/ecs/src/guide/08-input.test.ts | 8 +- packages/ecs/src/guide/13-indexes.test.ts | 2 +- packages/ecs/src/guide/guide.md | 15 +- .../ecs/src/implementation/compiler/access.ts | 3 +- .../implementation/compiler/compile-kernel.ts | 5 +- .../compiler/input-schemas.test.ts | 136 ++++++ .../implementation/compiler/input-schemas.ts | 404 ++++++++++++++++++ .../ecs/src/implementation/compiler/paths.ts | 6 + .../implementation/compiler/plugin.test.ts | 37 ++ .../ecs/src/implementation/compiler/plugin.ts | 81 +++- .../src/implementation/compiler/signature.ts | 7 +- .../implementation/compiler/transform.test.ts | 12 +- .../world/async-loading.test.ts | 5 +- .../world/command-rings.fixture.ts | 7 +- .../world/composite-input.test.ts | 20 +- .../src/implementation/world/create.test.ts | 10 +- .../world/entity-lookup-workers.fixture.ts | 2 +- .../world/entity-lookups.test-helper.ts | 3 +- .../implementation/world/finalizers.test.ts | 2 +- .../world/index-changed.test.ts | 13 +- .../implementation/world/index-joined.test.ts | 4 +- .../world/index-kernels.test.ts | 4 +- .../world/index-keyless.test.ts | 2 +- .../implementation/world/index-order.test.ts | 2 +- .../world/index-workers.fixture.ts | 6 +- .../src/implementation/world/indexes.test.ts | 26 +- .../ecs/src/implementation/world/input.ts | 2 +- .../world/intrinsics.browser.test.ts | 38 +- .../ecs/src/implementation/world/join.test.ts | 8 +- packages/ecs/src/implementation/world/join.ts | 2 +- .../world/joining.test-helper.ts | 6 +- .../world/kernel-spawns.test.ts | 2 +- .../ecs/src/implementation/world/kernels.ts | 2 +- .../world/layout-directory.browser.test.ts | 4 +- .../world/layout-directory.fixture.ts | 4 +- .../world/lifetime.test-helper.ts | 2 +- .../ecs/src/implementation/world/loop.test.ts | 2 +- packages/ecs/src/implementation/world/loop.ts | 2 +- .../world/observe-cases.test.ts | 2 +- .../world/observe-factories.test.ts | 2 +- .../world/observe-joined-cases.test.ts | 6 +- .../world/observer-contained.test-helper.ts | 2 +- .../world/observer-contained.test.ts | 2 +- .../world/observer-joined.test-helper.ts | 6 +- .../world/observer-joined.test.ts | 2 +- .../implementation/world/pool-size.fixture.ts | 2 +- .../world/remote-finalizers.test-helper.ts | 12 +- .../world/remote-finalizers.test.ts | 2 +- .../implementation/world/remote-systems.ts | 2 +- .../src/implementation/world/remote.test.ts | 2 +- .../world/remove.test-helper.ts | 2 +- .../src/implementation/world/reports.test.ts | 2 +- packages/ecs/src/implementation/world/rows.ts | 10 +- .../src/implementation/world/rules.test.ts | 2 +- .../ecs/src/implementation/world/runner.ts | 25 +- .../src/implementation/world/schedule.test.ts | 2 +- .../ecs/src/implementation/world/sharing.ts | 2 +- .../src/implementation/world/strings.test.ts | 12 +- .../implementation/world/tails.test-helper.ts | 10 +- .../src/implementation/world/target.test.ts | 10 +- .../world/thread-systems.test-helper.ts | 6 +- .../implementation/world/thread-systems.ts | 35 +- .../src/implementation/world/ticks.test.ts | 2 +- .../world/transfers.test-helper.ts | 20 +- .../ecs/src/implementation/world/up.test.ts | 4 +- .../implementation/world/wasm-kernels.test.ts | 7 +- .../implementation/world/workers.fixture.ts | 8 +- .../src/interface/indexes/index.type-test.ts | 3 +- .../ecs/src/interface/registry/schedule.ts | 10 +- packages/ecs/src/interface/system/body.ts | 14 +- packages/ecs/src/interface/system/check.ts | 37 +- .../system/composite-input.type-test.ts | 25 +- .../ecs/src/interface/system/create.test.ts | 10 +- packages/ecs/src/interface/system/create.ts | 49 ++- .../ecs/src/interface/system/declaration.ts | 9 +- packages/ecs/src/interface/system/input.ts | 11 +- packages/ecs/src/interface/system/leaf.ts | 8 +- packages/ecs/src/interface/system/render.ts | 1 + .../src/interface/system/system.type-test.ts | 52 ++- packages/ecs/src/perftest/flat.ts | 4 +- packages/ecs/src/perftest/growth.ts | 2 +- packages/ecs/src/perftest/hierarchy.ts | 16 +- packages/ecs/src/perftest/indexes.ts | 2 +- packages/ecs/src/perftest/observed.ts | 2 +- packages/ecs/src/perftest/particles.ts | 2 +- packages/ecs/src/perftest/reads.ts | 2 +- packages/ecs/src/perftest/remote.ts | 4 +- packages/ecs/src/perftest/render-order.ts | 3 +- packages/ecs/src/perftest/strings.ts | 2 +- packages/ecs/src/perftest/structural.ts | 7 +- packages/ecs/src/perftest/suite.ts | 13 +- packages/ecs/src/perftest/transfers.ts | 16 +- packages/ecs/src/perftest/uploads.ts | 3 +- packages/ecs/src/perftest/values.ts | 2 +- .../src/samples/particles/gpu/particles.ts | 3 +- .../ecs/src/samples/particles/particles.ts | 3 +- .../samples/todo/features/completion/model.ts | 3 +- .../src/samples/todo/features/filter/model.ts | 5 +- .../samples/todo/features/generate/model.ts | 3 +- .../src/samples/todo/features/todos/model.ts | 20 +- packages/ecs/src/samples/transfers/sprites.ts | 14 +- packages/ecs/threads.md | 7 +- 105 files changed, 1065 insertions(+), 426 deletions(-) create mode 100644 packages/ecs/src/implementation/compiler/input-schemas.test.ts create mode 100644 packages/ecs/src/implementation/compiler/input-schemas.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 0f729ee2..3f474219 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -43,7 +43,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - - **The plugin is required for kernels.** Registering a kernel system that wasn't compiled throws. + - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `kernelPlugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name of the nearest `package.json` above the module that has one and the module's path in that package, without a query, so a workspace-linked package names no directory outside it; without a named package, a dependency's package name and path inside `node_modules`, or the path from the root; never an absolute path; `#default` names a default export. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** @@ -98,14 +98,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. export const move = System.create({ name: "player/move", - input: { type: "object", properties: { distance: { type: "number" } } }, components: { has: [player], write: [position] }, - }, (row, { input }) => { /* … */ }); + }, (row, context, input: { readonly distance: number }) => { /* … */ }); export const main = System.create({ name: "main", systems: [input, physics, follow, render] }); ``` - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during, name }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their `name` alone. Nothing runs when they're called. - - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup`, `reduce` and `input` each appear in the context under the same name. + - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup` and `reduce` each appear in the context under the same name. - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources. @@ -115,18 +114,19 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`kernel: "cpu"`** opts a system into being compiled, so it can run in chunks across workers; **`kernel: "gpu"`** narrows the subset so it can also run on the GPU. The term comes from CUDA, WebGPU and Taichi. - It's explicit rather than inferred, so a body that leaves the subset fails the build instead of silently running on the main thread. - Type-checked: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - - The build validator checks `input`, since `Schema.ToType` doesn't produce tuples. + - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`, which the plugin reads by name. - Without `kernel`, a system is plain JS on the main thread. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - - **`input`** is an object schema of named fields for a per-run, read-only value, and `run` requires it exactly when it's declared. - - A system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input. - - **`target`** names an input field holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. + - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. + - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, the package's `Entity` and math types, the platform's transferable and cloneable classes, and `unknown`. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. + - Its checks are on the body, after the body is inferred: a system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input; a `target` names an entity field of it; an observer system takes none. A check in the declaration's position can't see the input, which TypeScript infers only from the body, after the declaration. + - **`target`** names a field of the input holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. - **A composite's input** has every field of its members' inputs, and each member receives the fields it declares, so workers get only those. A field several members declare is one value for all of them, with the same schema in each: a different type is a compile-time error at each member that differs, and creating the composite throws naming both. A field optional in one and required in another is required. - `(system name, input)` is a replicable transaction. On a GPU, input maps to a uniform buffer. - A `Blob` input can only be stored or forwarded, because its contents can't be read synchronously. - - **Bodies are `(subject, context)`:** `(row, context)` for a system with `components` and for a finalizer, `(context)` for a system without `components`, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. + - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. - `row`: `{ readonly entity }`, plus `read` values (read-only) and `write` values (readable and writable). No component may be named `entity`. - - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `input`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. + - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `up`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - **Names are labels,** optional and not unique, so two libraries can both name a system `"linked"`; namespacing them (`"physics/integrate"`) keeps labels short. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 5a627749..242c1576 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -130,13 +130,14 @@ Open items awaiting a decision or work. Remove an item once it's decided or done record. - **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the thread's run (see perf.md "Next"). +- **`loop tick add remove` fails its leak check:** the world holds 3 schedules and 38,080 arena bytes after, against 4 + and 16,192 before, so `pnpm perf` stops there. It fails the same before the change to input types. ## Earlier open work - Index step 5 items. - Starting the pool for large index builds. - `order` on workers. -- Typed transfers (Phase 7). - The `Finalizer` namespace. - Chromium timer clamps. - The `transfer` cost. diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index 161675a2..cc204203 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -116,8 +116,8 @@ declare module "@adobe/ecs" { } Component.register(health, { type: { type: "number" } }); export const both = System.create({ components: { read: [health], write: [health] } }, () => {}); -const near = System.create({ input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); -const far = System.create({ input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); +const near = System.create({}, (_context, input: { readonly distance: number }) => { void input; }); +const far = System.create({}, (_context, input: { readonly distance: string }) => { void input; }); export const listed = System.create([near, far]); export const declared = System.create({ systems: [near, far], after: [both] }); `; diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts index 7b78cf13..e7bc7f05 100644 --- a/packages/ecs/src/guide/08-input.test.ts +++ b/packages/ecs/src/guide/08-input.test.ts @@ -15,20 +15,18 @@ Component.register(health, { type: { type: "number" } }); let player = Entity.none; const spawn = System.create(({ commands }) => { player = commands.spawn([health])({ [health]: 10 }); }); -// input is an object schema of named fields; each run passes a value of it. +// A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ components: { write: [health] }, - input: { type: "object", properties: { amount: { type: "number" } } }, -}, (row, { input }) => { +}, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ components: { write: [health] }, - input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, target: "entity", -}, (row, { input }) => { +}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts index dbc539f1..9c1d22f5 100644 --- a/packages/ecs/src/guide/13-indexes.test.ts +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -51,7 +51,7 @@ const list = System.create({ observer: true, components: { read: [player] }, ord // A unique index gives one row, or null. const byServerId = Index.create({ key: [serverId], unique: true }); let found: number | null = null; -const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { +const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { found = lookup.byId(input.id)?.entity ?? null; }); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 4554c0e3..e0f39521 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -35,7 +35,8 @@ How to use the ECS. Each excerpt comes from a test in this folder; the link unde ## Setup ```ts -// vite.config.ts: compiles kernels, checks each system's declaration against its body, and gives keys and systems ids. +// vite.config.ts: compiles kernels, checks each system's declaration against its body, reads the schema of each +// body's input from its type, and gives keys and systems ids. import { kernelPlugin } from "@adobe/ecs/compiler"; export default defineConfig({ plugins: [kernelPlugin()] }); ``` @@ -337,20 +338,18 @@ const step = System.create({ resources: { is: [map], not: [paused], write: [step ### 8. Input ```ts -// input is an object schema of named fields; each run passes a value of it. +// A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ components: { write: [health] }, - input: { type: "object", properties: { amount: { type: "number" } } }, -}, (row, { input }) => { +}, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ components: { write: [health] }, - input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, target: "entity", -}, (row, { input }) => { +}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); @@ -359,7 +358,7 @@ await world.run(hit, { amount: 3 }); await world.run(heal, { entity: player, amount: 1 }); ``` -[`08-input.test.ts`](08-input.test.ts#L19-L49) +[`08-input.test.ts`](08-input.test.ts#L18-L43) ### 9. Rules @@ -503,7 +502,7 @@ const list = System.create({ observer: true, components: { read: [player] }, ord // A unique index gives one row, or null. const byServerId = Index.create({ key: [serverId], unique: true }); let found: number | null = null; -const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { +const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { found = lookup.byId(input.id)?.entity ?? null; }); ``` diff --git a/packages/ecs/src/implementation/compiler/access.ts b/packages/ecs/src/implementation/compiler/access.ts index 6ae9f6b8..b2d6b0a7 100644 --- a/packages/ecs/src/implementation/compiler/access.ts +++ b/packages/ecs/src/implementation/compiler/access.ts @@ -50,9 +50,8 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona if (name === "lookup") return { kind: "lookups" }; if (name === "reduce") return { kind: "reducers" }; if (name === "commands") return { kind: "commands" }; - if (name === "input" && signature.input) return { kind: "dynamic", expression: { kind: "args", path: [] } }; if (name === "up" && scope.unit.up !== null) return { kind: "found", lookup: signature.lookups.length, expression: local(scope.unit.up) }; - return fail(node, "the context holds the declared resources, lookup, reduce, input and up, and commands"); + return fail(node, "the context holds the declared resources, lookup, reduce and up, and commands"); case "resources": { const index = key.kind === "binding" ? signature.resources.indexOf(key.name) : -1; if (key.kind !== "binding" || index < 0) return fail(node, `${key.kind === "binding" ? key.name : "resources"} is not in resources read`); diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 96637b0a..4c04ad05 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -19,7 +19,8 @@ import { rowLabel, statement } from './statement.js'; import { createUnit } from './unit.js'; import type { Value } from './value.js'; -const parameterValues: readonly Value[] = [{ kind: "row" }, { kind: "context" }]; +/** A kernel body takes its row, its context and its input, the run's arguments. */ +const parameterValues: readonly Value[] = [{ kind: "row" }, { kind: "context" }, { kind: "dynamic", expression: { kind: "args", path: [] } }]; /** * One compiled kernel: `description(url)` is the source of its `KernelDescription`, with keys as the declaration's @@ -46,7 +47,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: return fail(body, "a kernel body must be an inline function"); } if (body.async || body.generator) return fail(body, "a kernel body can't be async or a generator"); - if (body.params.length > 2) return fail(body, "a kernel body takes row and context"); + if (body.params.length > 3) return fail(body, "a kernel body takes its row, context and input"); const lists = createLists(module, link, [declaration, body]); const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target); const scope = rootScope(unit, false); diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts new file mode 100644 index 00000000..51a7b871 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -0,0 +1,136 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { passInputSchemas, type InputHost } from './input-schemas.js'; + +/** Files by path; `@adobe/ecs` is the package, which also exports `System`. */ +const hostOf = (files: { readonly [path: string]: string }): InputHost => ({ + parse: (code, file) => parseAst(code, { lang: "ts" }, file), + resolve: async specifier => specifier.startsWith("./") && `/src/${specifier.slice(2).replace(/\.js$/, ".ts")}` in files ? `/src/${specifier.slice(2).replace(/\.js$/, ".ts")}` : null, + load: async file => files[file] ?? "", + isPackage: async specifier => specifier === "@adobe/ecs", + isSystem: async specifier => specifier === "@adobe/ecs", +}); + +const header = `import { System, type Entity, type Vec2, type F32, type I32 } from "@adobe/ecs";\n`; + +/** The schemas the pass gives each system of `body`, in order. */ +const schemasOf = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = header + body; + const result = await passInputSchemas(code, "/src/main.ts", hostOf(files)); + if (result === null) return null; + if ("failure" in result) return { failure: result.failure.message }; + return [...result.code.matchAll(/, (\{.*\})\);?$/gm)].map(match => JSON.parse(match[1] ?? "null")); +}; + +describe("passInputSchemas", () => { + test("gives a system the schema of its body's parameter after its context", async () => { + expect(await schemasOf(`System.create({}, (_context, input: { readonly count: number; label: string; on: boolean; none: null }) => {});`)).toEqual([ + { type: "object", properties: { count: { type: "number" }, label: { type: "string" }, on: { type: "boolean" }, none: { type: "null" } } }, + ]); + }); + test("finds the input after the row when the declaration has components", async () => { + expect(await schemasOf(`System.create({ components: { read: [a] } }, (row, context, input: { dx: number }) => {});`)).toEqual([ + { type: "object", properties: { dx: { type: "number" } } }, + ]); + expect(await schemasOf(`System.create({ components: { read: [a] } }, (row, context) => {});`)).toBe(null); + }); + test("takes a declaration not written in place's input from a third parameter, or a second with a type", async () => { + expect(await schemasOf([ + `System.create(declaration, (row, context, input: { a: number }) => {});`, + `System.create(declaration, (context, input: { b: number }) => {});`, + `System.create(declaration, (row, context) => {});`, + ].join("\n"))).toEqual([{ type: "object", properties: { a: { type: "number" } } }, { type: "object", properties: { b: { type: "number" } } }]); + }); + test("passes nothing for a body without input", async () => { + expect(await schemasOf(`System.create({ resources: { read: [a] } }, ({ resources }) => {});\nSystem.create(context => {});`)).toBe(null); + }); + test("reads optional fields, literals, unions, arrays, tuples, sets and maps", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { + mode?: "add" | "remove"; + level: 1 | -1; + tags: readonly string[]; + ids: ReadonlyArray; + pair: [number, number]; + seen: ReadonlySet; + names: ReadonlyMap; + maybe: string | null; + }) => {});`)).toEqual([{ + type: "object", + properties: { + mode: { anyOf: [{ const: "add" }, { const: "remove" }] }, + level: { anyOf: [{ const: 1 }, { const: -1 }] }, + tags: { type: "array", items: { type: "string" } }, + ids: { type: "array", items: { type: "integer", format: "entity" } }, + pair: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + seen: { type: "set", items: { type: "string" } }, + names: { type: "map", keys: { type: "integer", format: "entity" }, values: { type: "string" } }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + required: ["level", "tags", "ids", "pair", "seen", "names", "maybe"], + }]); + }); + test("gives the package's types their schemas", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { entity: Entity; at: Vec2; cell: Vec2.I32; dt: F32; steps: I32 }) => {});`)).toEqual([{ + type: "object", + properties: { + entity: { type: "integer", format: "entity" }, + at: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 }, + cell: { type: "array", items: { type: "integer", minimum: -2147483648, maximum: 2147483647 }, minItems: 2, maxItems: 2 }, + dt: { type: "number", precision: 1 }, + steps: { type: "integer", minimum: -2147483648, maximum: 2147483647 }, + }, + }]); + }); + test("takes any value for a platform class or unknown", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { canvas: OffscreenCanvas; bitmap: ImageBitmap; anything: unknown }) => {});`)).toEqual([ + { type: "object", properties: { canvas: { allOf: [] }, bitmap: { allOf: [] }, anything: { allOf: [] } } }, + ]); + }); + test("follows aliases and interfaces, here and in modules imported", async () => { + const files = { + "/src/types.ts": `export type Move = { readonly by: number };\nexport interface Named { readonly name: string }\nexport { type Tagged } from "./more.js";\nexport * from "./all.js";`, + "/src/more.ts": `export interface Tagged extends Base { readonly tag: string }\ninterface Base { readonly base: boolean }`, + "/src/all.ts": `export type Spread = { readonly spread: number };`, + }; + expect(await schemasOf([ + `import type { Move, Named, Tagged, Spread } from "./types.js";`, + `type Local = Move & Named;`, + `System.create({}, (_c, input: Local) => {});`, + `System.create({}, (_c, input: Tagged) => {});`, + `System.create({}, (_c, input: Spread) => {});`, + ].join("\n"), files)).toEqual([ + { type: "object", properties: { by: { type: "number" }, name: { type: "string" } } }, + { type: "object", properties: { base: { type: "boolean" }, tag: { type: "string" } } }, + { type: "object", properties: { spread: { type: "number" } } }, + ]); + }); + test("names the module of a type it can't read", async () => { + const failed = await passInputSchemas(`${header}import type { Bad } from "./bad.js";\nSystem.create({}, (_c, input: Bad) => {});`, "/src/main.ts", hostOf({ "/src/bad.ts": `export type Bad = Pick;` })); + expect(failed !== null && "failure" in failed ? failed.failure.frames.map(frame => [frame.file, frame.line]) : failed).toEqual([["/src/bad.ts", 1]]); + expect(failed !== null && "failure" in failed ? failed.dependencies : failed).toEqual(["/src/bad.ts"]); + }); + test("fails, saying why, for what an input type can't be", async () => { + const failure = async (body: string) => { + const schemas = await schemasOf(body); + return typeof schemas === "object" && schemas !== null && "failure" in schemas ? schemas.failure : schemas; + }; + expect(await failure(`System.create({}, (_c, input) => {});`)).toContain("has a type"); + expect(await failure(`System.create({}, (_c, input: { a?: number | undefined }) => {});`)).toContain("optional"); + expect(await failure(`System.create({}, (_c, input: Pick) => {});`)).toContain("type arguments"); + expect(await failure(`type Tree = { readonly child: Tree };\nSystem.create({}, (_c, input: Tree) => {});`)).toContain("refers to itself"); + expect(await failure(`type Box = { readonly value: T };\nSystem.create({}, (_c, input: Box) => {});`)).toContain("generic"); + expect(await failure(`System.create({}, (_c, input: [number, string]) => {});`)).toContain("one type"); + expect(await failure(`System.create({}, (_c, input: number) => {});`)).toContain("object of named fields"); + expect(await failure(`System.create({}, (_c, input: { when: Date }) => {});`)).toContain("isn't a type an input can hold"); + expect(await failure(`System.create({}, (_c, ...rest: [{ a: number }]) => {});`)).toContain("rest parameter"); + }); + test("inserts each schema after the body, mapping the rest of the line back", async () => { + const result = await passInputSchemas(`${header}System.create({}, (_c, input: { a: number }) => {}); const after = 1;`, "/src/main.ts", hostOf({})); + expect(result !== null && "code" in result ? result.code.split("\n")[1] : result).toBe(`System.create({}, (_c, input: { a: number }) => {}, {"type":"object","properties":{"a":{"type":"number"}}}); const after = 1;`); + expect(result !== null && "map" in result ? result.map.mappings.length > 0 : result).toBe(true); + }); + test("leaves calls of another System", async () => { + expect(await passInputSchemas(`import { System } from "elsewhere";\nSystem.create({}, (_c, input: { a: number }) => {});`, "/src/main.ts", hostOf({}))).toBe(null); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/input-schemas.ts b/packages/ecs/src/implementation/compiler/input-schemas.ts new file mode 100644 index 00000000..14b14443 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/input-schemas.ts @@ -0,0 +1,404 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { parseAst } from 'vite'; +import { Entity } from '../../interface/entity/entity.js'; +import * as math from '../../interface/math/index.js'; +import type { Schema } from '../../interface/schema/schema.js'; +import type { Failure } from './failure.js'; +import { insertInto, type SourceMap } from './source-map.js'; + +/** A TypeScript module's syntax, types included. */ +export type TypedProgram = ReturnType; +type Statement = TypedProgram["body"][number]; +type Alias = Extract; +type Interface = Extract; +type Import = Extract; +type TypeNode = Alias["typeAnnotation"]; +type Member = Interface["body"]["body"][number]; +type Named = Extract["typeName"]; +/** Any node of a module, by the fields this pass reads. */ +type Node = { readonly type: string; readonly start: number; readonly end: number }; + +/** Parses a TypeScript module, types included. */ +export type ParseTyped = (code: string, file: string) => TypedProgram; +/** The file an import specifier resolves to from `importer`, or null when it's none this pass can read. */ +export type ResolveFile = (specifier: string, importer: string) => Promise; +/** A file's code. */ +export type LoadFile = (file: string) => Promise; +/** Whether an import specifier, resolved from `importer`, is one of the package's public modules. */ +export type IsPackage = (specifier: string, importer: string) => Promise; +/** Whether an import specifier, resolved from `importer`, is a module of the package exporting `System`. */ +export type IsSystem = (specifier: string, importer: string) => Promise; + +export type InputHost = { readonly parse: ParseTyped; readonly resolve: ResolveFile; readonly load: LoadFile; readonly isPackage: IsPackage; readonly isSystem: IsSystem }; + +/** Code with each system's input schema passed to its `create`, the files whose types it read, or why it failed. */ +export type InputResult = + | { readonly code: string; readonly map: SourceMap; readonly dependencies: readonly string[] } + | { readonly failure: Failure; readonly dependencies: readonly string[] }; + +/** Thrown inside the pass, and returned as its failure. */ +type Thrown = { readonly inputFailure: Failure }; +const isThrown = (value: unknown): value is Thrown => typeof value === "object" && value !== null && "inputFailure" in value; + +/** Classes of the platform an input may hold, which aren't data: values that can be transferred or cloned. */ +const platformClasses: ReadonlySet = new Set([ + "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", "ReadableStream", + "WritableStream", "TransformStream", "RTCDataChannel", "Blob", "File", "ImageData", +]); + +/** A value of any type: a class of the platform's, or `unknown`. */ +const anything: Schema = { allOf: [] }; + +const supported = "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the package's `Entity` and math types, a platform class or `unknown`"; + +/** The schema of the package's type at `path`, such as `["Vec2", "I32"]`: its namespace's `schema`. */ +const packageSchema = (path: readonly string[]): Schema | undefined => { + const [first, ...rest] = path; + let value: unknown = first === "Entity" ? Entity : first === undefined ? undefined : Reflect.get(math, first); + for (const name of rest) value = typeof value === "object" && value !== null ? Reflect.get(value, name) : undefined; + const schema: unknown = typeof value === "object" && value !== null ? Reflect.get(value, "schema") : undefined; + return isSchema(schema) ? schema : undefined; +}; + +// The package's own schemas are its exported constants. +const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null; + +/** A parsed module: its declarations of types by name, and its imports by local name. */ +type Module = { + readonly file: string; + readonly code: string; + readonly types: ReadonlyMap; + readonly imports: ReadonlyMap; + readonly exports: readonly Statement[]; +}; + +const declarationOf = (statement: Statement): Alias | Interface | null => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "TSTypeAliasDeclaration" || declared?.type === "TSInterfaceDeclaration" ? declared : null; +}; + +const importsOf = (statement: Import): [string, { readonly source: string; readonly imported: string | null }][] => + statement.specifiers.map(specifier => [specifier.local.name, { + source: statement.source.value, + // A namespace import names the module itself. + imported: specifier.type === "ImportSpecifier" ? (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) + : specifier.type === "ImportDefaultSpecifier" ? "default" : null, + }]); + +const moduleOf = (file: string, code: string, program: TypedProgram): Module => { + const types = new Map(); + const imports = new Map(); + for (const statement of program.body) { + const declared = declarationOf(statement); + if (declared !== null) types.set(declared.id.name, declared); + if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); + } + return { file, code, types, imports, exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") }; +}; + +/** The line and column of an offset, 1-based line, 0-based column, as a failure's position. */ +const positionOf = (code: string, offset: number): { readonly line: number; readonly column: number } => { + const before = code.slice(0, offset); + const line = before.split("\n").length; + return { line, column: offset - (before.lastIndexOf("\n") + 1) }; +}; + +/** The module being transformed, and where in it the parameter whose type is being converted is. */ +type Site = { readonly root: string; at: number }; + +/** Fails at `node`, or for a type of another module at the parameter, with a frame naming where in that module. */ +const failAt = (site: Site, module: Module, node: Node, message: string): never => { + const { line, column } = positionOf(module.code, node.start); + const inRoot = module.file === site.root; + const thrown: Thrown = { inputFailure: { message, position: inRoot ? node.start : site.at, frames: inRoot ? [] : [{ name: "input type", file: module.file, line, column }] } }; + throw thrown; +}; + +/** Converts input types to schemas, reading the modules they're declared in through `host`. */ +const createConverter = (host: InputHost, root: string) => { + const site: Site = { root, at: 0 }; + const fail = (module: Module, node: Node, message: string): never => failAt(site, module, node, message); + const modules = new Map>(); + const read = new Set(); + const load = (file: string): Promise => { + const found = modules.get(file); + if (found !== undefined) return found; + const loading = (async () => { + read.add(file); + const code = await host.load(file); + return moduleOf(file, code, host.parse(code, file)); + })(); + modules.set(file, loading); + return loading; + }; + + /** The declaration `name` exports, following re-exports; null when it exports no type by that name. */ + const exported = async (module: Module, name: string, seen: Set): Promise<{ readonly module: Module; readonly declaration: Alias | Interface } | "package" | null> => { + const key = `${module.file}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + for (const statement of module.exports) { + if (statement.type === "ExportNamedDeclaration") { + const declared = declarationOf(statement); + if (declared !== null && declared.id.name === name) return { module, declaration: declared }; + for (const specifier of statement.specifiers) { + const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; + if (exportedName !== name) continue; + const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; + if (statement.source !== null) return await fromSource(module, statement.source.value, localName, seen); + const local = module.types.get(localName); + if (local !== undefined) return { module, declaration: local }; + const imported = module.imports.get(localName); + if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, seen); + } + } + else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { + const found = await fromSource(module, statement.source.value, name, seen); + if (found !== null) return found; + } + } + const local = module.types.get(name); + return local === undefined ? null : { module, declaration: local }; + }; + + const fromSource = async (module: Module, source: string, name: string, seen: Set): Promise<{ readonly module: Module; readonly declaration: Alias | Interface } | "package" | null> => { + if (await host.isPackage(source, module.file)) return "package"; + const file = await host.resolve(source, module.file); + if (file === null) return null; + const target = await load(file); + return target === null ? null : await exported(target, name, seen); + }; + + const nameParts = (name: Named): readonly string[] => + name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...nameParts(name.left), name.right.name] : []; + + /** The schema of a type, read in `module`; `active` holds the declarations being converted, so recursion fails. */ + const convert = async (type: TypeNode, module: Module, active: ReadonlySet): Promise => { + switch (type.type) { + case "TSNumberKeyword": return { type: "number" }; + case "TSStringKeyword": return { type: "string" }; + case "TSBooleanKeyword": return { type: "boolean" }; + case "TSNullKeyword": return { type: "null" }; + case "TSUnknownKeyword": return anything; + case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active); + case "TSTypeOperator": + return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active) : fail(module, type, `\`${type.operator}\` isn't an input type: an input is ${supported}`); + case "TSLiteralType": { + const { literal } = type; + if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value }; + if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return { const: -literal.argument.value }; + return fail(module, type, "an input's literal type is a string, number or boolean"); + } + case "TSUnionType": { + const types = type.types.filter(member => member.type !== "TSUndefinedKeyword"); + if (types.length !== type.types.length) return fail(module, type, "an input field that may be missing is optional (`field?:`) rather than `| undefined`"); + const schemas = await Promise.all(types.map(member => convert(member, module, active))); + return { anyOf: schemas }; + } + case "TSArrayType": return { type: "array", items: await convert(type.elementType, module, active) }; + case "TSTupleType": { + const elements = type.elementTypes; + const first = elements[0]; + if (first === undefined) return fail(module, type, "an input's tuple has elements"); + const schemas = await Promise.all(elements.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" + ? fail(module, element, "an input's tuple has a fixed length, and its elements no names") : convert(element, module, active))); + const items = schemas[0]; + if (items === undefined || schemas.some(schema => JSON.stringify(schema) !== JSON.stringify(items))) return fail(module, type, "an input's tuple has elements of one type"); + return { type: "array", items, minItems: elements.length, maxItems: elements.length }; + } + case "TSTypeLiteral": return await objectOf(type.members, [], module, active, type); + case "TSIntersectionType": { + const parts = await Promise.all(type.types.map(member => convert(member, module, active))); + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const part of parts) { + if (!("type" in part) || part.type !== "object") return fail(module, type, "an input's intersection joins object types"); + for (const [field, schema] of Object.entries(part.properties)) properties[field] = schema; + if (part.required === undefined) required.push(...Object.keys(part.properties)); + else { + optional = true; + required.push(...part.required); + } + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + } + case "TSTypeReference": return await referenced(type, module, active); + default: return fail(module, type, `an input is ${supported}`); + } + }; + + const objectOf = async (members: readonly Member[], inherited: readonly Schema[], module: Module, active: ReadonlySet, at: Node): Promise => { + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const schema of inherited) { + if (!("type" in schema) || schema.type !== "object") return fail(module, at, "an input interface extends object types"); + for (const [field, property] of Object.entries(schema.properties)) properties[field] = property; + if (schema.required === undefined) required.push(...Object.keys(schema.properties)); + else { + optional = true; + required.push(...schema.required); + } + } + for (const member of members) { + if (member.type !== "TSPropertySignature" || member.computed) return fail(module, member, "an input's fields are named properties"); + const { key } = member; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : fail(module, member, "an input's fields are named properties"); + if (member.typeAnnotation === null) return fail(module, member, `input field "${field}" has a type`); + properties[field] = await convert(member.typeAnnotation.typeAnnotation, module, active); + if (member.optional) optional = true; + else required.push(field); + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + }; + + const declared = async (declaration: Alias | Interface, module: Module, active: ReadonlySet, at: Node): Promise => { + if (active.has(declaration)) return fail(module, at, `input type ${declaration.id.name} refers to itself`); + if (declaration.typeParameters !== null) return fail(module, at, `input type ${declaration.id.name} is generic, which an input type isn't`); + const inner = new Set(active).add(declaration); + if (declaration.type === "TSTypeAliasDeclaration") return await convert(declaration.typeAnnotation, module, inner); + const inherited = await Promise.all(declaration.extends.map(async heritage => { + const { expression } = heritage; + if (expression.type !== "Identifier" || heritage.typeArguments !== null) return fail(module, heritage, "an input interface extends named, non-generic types"); + return await named([expression.name], module, inner, heritage); + })); + return await objectOf(declaration.body.body, inherited, module, inner, declaration); + }; + + /** The schema of the type `path` names in `module`. */ + const named = async (path: readonly string[], module: Module, active: ReadonlySet, at: Node): Promise => { + const [first, ...rest] = path; + if (first === undefined) return fail(module, at, `an input is ${supported}`); + const local = module.types.get(first); + if (local !== undefined && rest.length === 0) return await declared(local, module, active, at); + const imported = module.imports.get(first); + if (imported !== undefined) { + if (await host.isPackage(imported.source, module.file)) { + const schema = packageSchema(imported.imported === null ? rest : [imported.imported, ...rest]); + return schema ?? fail(module, at, `${path.join(".")} is no type of the package an input can take: it takes Entity and the math types`); + } + if (imported.imported === null || rest.length > 0) return fail(module, at, `input type ${path.join(".")} is read through a namespace, which this pass doesn't follow; import it by name`); + const found = await fromSource(module, imported.source, imported.imported, new Set()); + if (found === "package") { + return packageSchema([imported.imported]) ?? fail(module, at, `${imported.imported} is no type of the package an input can take: it takes Entity and the math types`); + } + if (found === null) return fail(module, at, `input type ${first} isn't a type alias or interface its module exports, which this pass can read`); + return await declared(found.declaration, found.module, active, at); + } + if (rest.length === 0 && platformClasses.has(first)) return anything; + return fail(module, at, `${path.join(".")} isn't a type an input can hold: an input is ${supported}`); + }; + + const referenced = async (type: Extract, module: Module, active: ReadonlySet): Promise => { + const path = nameParts(type.typeName); + const parameters = type.typeArguments?.params ?? []; + const [only] = path; + if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) { + const [first, second] = parameters; + if ((only === "ReadonlyArray" || only === "Array") && parameters.length === 1 && first !== undefined) return { type: "array", items: await convert(first, module, active) }; + if ((only === "ReadonlySet" || only === "Set") && parameters.length === 1 && first !== undefined) return { type: "set", items: await convert(first, module, active) }; + if ((only === "ReadonlyMap" || only === "Map") && parameters.length === 2 && first !== undefined && second !== undefined) { + return { type: "map", keys: await convert(first, module, active), values: await convert(second, module, active) }; + } + } + if (parameters.length > 0) return fail(module, type, `input type ${path.join(".")} takes type arguments, which an input type doesn't`); + return await named(path, module, active, type); + }; + + return { convert, moduleOf, read, site, fail }; +}; + +type Expression = Extract["expression"]; +type Call = Extract; +type FunctionNode = Extract; + +/** Every node under `node`, in source order. */ +const each = (node: unknown, visit: (node: Node) => void): void => { + if (Array.isArray(node)) { + for (const item of node) each(item, visit); + return; + } + if (typeof node !== "object" || node === null || typeof Reflect.get(node, "type") !== "string") return; + if (isNode(node)) visit(node); + for (const key of Object.keys(node)) if (key !== "parent") each(Reflect.get(node, key), visit); +}; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number"; + +// Each call's arguments are its own expressions. +const isCall = (node: Node): node is Call => node.type === "CallExpression"; + +const unwrapped = (node: Call["arguments"][number]): Call["arguments"][number] => + node.type === "ParenthesizedExpression" ? unwrapped(node.expression) : node; + +const isFunction = (node: Call["arguments"][number]): node is FunctionNode => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; + +/** Whether a declaration written in place declares `components`; null when it isn't written in place. */ +const declaresComponents = (declaration: Call["arguments"][number]): boolean | null => { + if (declaration.type !== "ObjectExpression") return null; + return declaration.properties.some(property => property.type === "Property" && !property.computed + && ((property.key.type === "Identifier" && property.key.name === "components") || (property.key.type === "Literal" && property.key.value === "components"))); +}; + +/** + * Passes each `System.create(declaration, body)` the schema of its body's input, the type of its parameter after its + * context, as a third argument: `create` takes the schema from there. The type is read from its syntax, through aliases + * and interfaces of the module and those it imports, so it needs no type checker. Returns null when no system here + * takes input. + */ +export const passInputSchemas = async (code: string, file: string, host: InputHost): Promise => { + if (!code.includes("System") || !code.includes(".create(")) return null; + const converter = createConverter(host, file); + const { fail } = converter; + const program = host.parse(code, file); + const module = moduleOf(file, code, program); + const systems = new Set(); + for (const [local, entry] of module.imports) { + if (entry.imported === "System" && await host.isSystem(entry.source, file)) systems.add(local); + } + if (systems.size === 0) return null; + const calls: Call[] = []; + each(program.body, node => { + if (!isCall(node)) return; + const { callee } = node; + if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && systems.has(callee.object.name) + && callee.property.type === "Identifier" && callee.property.name === "create" && node.arguments.length === 2) calls.push(node); + }); + const insertions: { readonly at: number; readonly text: string }[] = []; + try { + for (const call of calls) { + const [declaration, second] = call.arguments; + if (declaration === undefined || second === undefined) continue; + const body = unwrapped(second); + if (!isFunction(body)) continue; + const components = declaresComponents(declaration); + // For a declaration not written in place, a third parameter is the input, as is a second one with a type: a + // context is rarely given one. + const before = components === null ? (body.params.length > 2 || annotationOf(body.params[1]) === null ? 2 : 1) : components ? 2 : 1; + const parameter = body.params[before]; + if (parameter === undefined) continue; + if (parameter.type === "RestElement") fail(module, parameter, "a body's input is one parameter, not a rest parameter"); + if (parameter.type === "AssignmentPattern") fail(module, parameter, "a body's input has no default"); + const annotation = annotationOf(parameter); + if (annotation === null) return fail(module, parameter, "a body's input parameter has a type, which its schema is made from"); + converter.site.at = parameter.start; + const schema = await converter.convert(annotation, module, new Set()); + if (!("type" in schema) || schema.type !== "object") fail(module, parameter, "a body's input is an object of named fields"); + insertions.push({ at: second.end, text: `, ${JSON.stringify(schema)}` }); + } + } catch (error) { + if (isThrown(error)) return { failure: error.inputFailure, dependencies: [...converter.read] }; + throw error; + } + if (insertions.length === 0) return null; + return { ...insertInto(code, insertions, file), dependencies: [...converter.read] }; +}; + +type Parameter = FunctionNode["params"][number]; + +const annotationOf = (parameter: Parameter | undefined): TypeNode | null => { + if (parameter === undefined || parameter.type === "TSParameterProperty") return null; + const annotation = parameter.type === "RestElement" ? parameter.typeAnnotation : parameter.typeAnnotation; + return annotation === null || annotation === undefined ? null : annotation.typeAnnotation; +}; diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 10b9251f..00c5a96a 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -162,3 +162,9 @@ export const namespaceCopyOf = (id: string, namespace: string): PackageCopy | nu const found = pathInCopy(file); return found !== null && modules.has(found.path) ? copyIn(found.root, found.path, extname(file)) : null; }; + +/** Whether a resolved module id is one of the package's public modules, in any copy: its root or its interface. */ +export const isInterfaceModule = (id: string): boolean => { + const found = pathInCopy(fileOf(id)); + return found !== null && (found.path.startsWith("src/interface/") || found.path.startsWith("dist/interface/") || rootModules.includes(found.path)); +}; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 850bc7d8..0727db46 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -44,6 +44,17 @@ import { score } from "./linked-keys.js"; const kernel = System.create({ name: "linked", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); export const description = kernelOf(kernel); `); +/** Systems taking input, of a type another TypeScript module declares, which the test edits. */ +const inputs = join(directory, "inputs.ts"); +const inputTypes = join(directory, "input-types.ts"); +const writeInputTypes = (field: string) => writeFileSync(inputTypes, `import type { Entity } from ${source("../../interface/entity/entity.ts")};\nexport type Push = { readonly entity: Entity; readonly ${field}: number };\n`); +writeInputTypes("by"); +writeFileSync(inputs, `import { System } from ${source("../../interface/system/system.ts")}; +import type { Push } from "./input-types.ts"; +const push = System.create({ name: "inputs/push" }, (_context, input: Push) => { void input; }); +const label = System.create({ name: "inputs/label" }, (_context, input: { readonly text?: string }) => { void input; }); +export const schemas = [push, label].map(system => Reflect.get(system, "input")); +`); /** A kernel spawning through a list another module declares, which the test edits. */ const listing = join(directory, "listing.js"); const listed = join(directory, "listed.js"); @@ -410,3 +421,29 @@ export const spawner = System.create({ name: "copiedSpawner", components: { read await server.close(); } }); + +test("serve passes each system the schema of its body's input, from types other modules declare, and again when they change", async () => { + try { + await serve(async server => { + expect((await server.ssrLoadModule(inputs)).schemas).toEqual([ + { type: "object", properties: { entity: { type: "integer", format: "entity" }, by: { type: "number" } } }, + { type: "object", properties: { text: { type: "string" } }, required: [] }, + ]); + writeInputTypes("to"); + server.watcher.emit("change", inputTypes); + const [push] = (await server.ssrLoadModule(inputs)).schemas; + expect(push).toEqual({ type: "object", properties: { entity: { type: "integer", format: "entity" }, to: { type: "number" } } }); + }); + } finally { + writeInputTypes("by"); + } +}); + +test("a build fails at a system's input whose type can't be one", async () => { + const entry = join(directory, "bad-input.ts"); + writeFileSync(entry, `import { System } from ${source("../../interface/system/system.ts")};\nexport const bad = System.create({}, (_context, input: { readonly when: Date }) => { void input; });\n`); + await expect(build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir: join(directory, "dist-bad-input"), minify: false, lib: { entry, formats: ["es"], fileName: "main" } }, + })).rejects.toThrow("Date isn't a type an input can hold"); +}); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 5cb1515d..af3b8398 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,7 +9,8 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageNameOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { copyForImporter, namespaceCopyOf } from './paths.js'; +import { passInputSchemas, type InputHost } from './input-schemas.js'; +import { copyForImporter, isInterfaceModule, namespaceCopyOf } from './paths.js'; import { locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; @@ -93,7 +94,81 @@ const inlineKernels = (code: string, kernels: readonly string[], hashes: readonl JSON.stringify(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`), ), code); -export const kernelPlugin = (): Plugin => { +const typed = /\.[cm]?tsx?$/; + +/** + * Passes each system's `create` the schema of its body's input, from the type of the body's parameter after its + * context (`input-schemas.ts`). It runs before TypeScript is stripped, which the type needs. + */ +const inputPlugin = (): Plugin => { + let repeated = false; + let server: ViteDevServer | undefined; + /** Modules whose input types each file declares, and the files each module's input types were read from. */ + const dependents = new Map>(); + const dependencies = new Map(); + const depend = (parent: string, files: readonly string[]): void => { + for (const file of dependencies.get(parent) ?? []) dependents.get(file)?.delete(parent); + dependencies.set(parent, files); + for (const file of files) { + const parents = dependents.get(file) ?? new Set(); + parents.add(parent); + dependents.set(file, parents); + } + }; + const plugin: Plugin = { + name: `${packageName}/input`, + enforce: "pre", + configResolved: config => { + repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; + }, + configureServer: devServer => { + server = devServer; + }, + // A module whose input types come from a changed file is transformed again, for its schemas. + watchChange(id) { + for (const parent of dependents.get(id) ?? []) { + for (const module of server?.moduleGraph.getModulesByFile(parent) ?? []) server?.moduleGraph.invalidateModule(module); + } + }, + async transform(code, id) { + const file = id.split("?")[0] ?? id; + if (repeated || id.includes("\0") || !typed.test(file)) return null; + const { parseAst } = await import("vite"); + const resolved = async (specifier: string, importer: string) => await this.resolve(specifier, importer, { skipSelf: true }); + const host: InputHost = { + parse: (source, path) => parseAst(source, { lang: path.endsWith("x") ? "tsx" : "ts" }, path), + resolve: async (specifier, importer) => { + const found = await resolved(specifier, importer); + if (found === null || found.external) return null; + const path = found.id.split("?")[0] ?? found.id; + return path.includes("\0") || !isAbsolute(path) ? null : path; + }, + load: file => readFile(file, "utf8"), + isPackage: async (specifier, importer) => { + const found = await resolved(specifier, importer); + return found !== null && (found.external ? specifier === packageName : isInterfaceModule(found.id)); + }, + isSystem: async (specifier, importer) => { + const found = await resolved(specifier, importer); + return found !== null && (found.external ? specifier === packageName : namespaceCopyOf(found.id, "System") !== null); + }, + }; + const result = await passInputSchemas(code, file, host); + const read = result === null ? [] : result.dependencies.filter(dependency => dependency !== file); + for (const dependency of read) this.addWatchFile(dependency); + depend(file, read); + if (result === null) return null; + if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + return { code: result.code, map: result.map }; + }, + }; + return plugin; +}; + +/** The kernel plugin's two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. */ +export const kernelPlugin = (): Plugin[] => [inputPlugin(), compilePlugin()]; + +const compilePlugin = (): Plugin => { let serve = true; let root = process.cwd(); /** Whether an earlier instance of the plugin is in the same bundle. */ @@ -144,7 +219,7 @@ export const kernelPlugin = (): Plugin => { name: `${packageName}/kernel`, enforce: "post", // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. - config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => [kernelPlugin()] } }), + config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => kernelPlugin() } }), configResolved: config => { serve = config.command === "serve"; root = config.root; diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 7dbcf071..c2ed0608 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -31,7 +31,6 @@ export type Signature = { readonly up: Up | null; /** Whether its rows run in an index's order, as a list, as `up`'s do. */ readonly ordered: boolean; - readonly input: boolean; /** Whether it filters on `added` or `changed` components, or on changed resources. */ readonly filtered: boolean; }; @@ -94,7 +93,6 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li let resources: readonly string[] = []; let reduced: readonly Reduced[] = []; let changedResources: readonly string[] = []; - let input = false; let up: Up | null = null; let ordered = false; const lookups: Lookup[] = []; @@ -145,9 +143,6 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li up = { through: through ?? fail(property, "up needs through"), read }; break; } - case "input": - input = true; - break; // The index is a value the world resolves; only that rows run as a list changes how it compiles. case "order": ordered = true; @@ -167,5 +162,5 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li if (both !== undefined) fail(declaration, `${both} is in both components read and write; write already makes it readable`); const rowFiltered = (components.get("added")?.length ?? 0) + (components.get("changed")?.length ?? 0) > 0; if (up !== null && ordered) fail(declaration, "a kernel can't use both up and order, which both decide the order of its rows"); - return { read: new Set(components.get("read")), write: new Set(write), resources, reduced, lookups, up, ordered, input, filtered: changedResources.length > 0 || rowFiltered }; + return { read: new Set(components.get("read")), write: new Set(write), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index d3833020..91791d21 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -579,7 +579,7 @@ describe("context", () => { }); test("optional lookup access, destructured context groups and input", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets }, input }) => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; @@ -587,9 +587,9 @@ describe("context", () => { }); test("context groups reached through the context parameter", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { const { resources } = context; - row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + context.input; + row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; expect(run(kernel, 2, { target: { "[]": [1, 2] } }, { lookups: [found], resources: [10], args: 100, program: false }).field("score")).toEqual([113, 110]); @@ -700,7 +700,7 @@ describe("context", () => { describe("program", () => { test("input and resource paths are scalars in the program", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "object" }, components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d }, input }) => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { const [, y] = d; row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); });`); @@ -849,7 +849,7 @@ describe("terminal bodies", () => { test.each([ ["no context", "(row)", "console.log(row);", `{ name: "a", components: { has: [mass] } }`], ["a context destructured without commands", "({ resources })", "console.log(resources);", `{ name: "a" }`], - ["a context used only through its other properties", "(context)", "console.log(context.input);", `{ name: "a", input: { type: "number" } }`], + ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ name: "a", resources: { read: [delta] } }`], ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ name: "a", components: { has: [mass] } }`], ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ name: "a", resources: { read: [delta] } }`], ])("mark a body with %s", async (_, parameters, body, declaration) => { @@ -1335,7 +1335,7 @@ describe("declared access", () => { expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { context.resources[score] = 1; }"))).toBe(unread); expect(await failure(system("resources: { read: [delta], write: [score] }", "({ resources }) => { resources[score] = 1; }"))).toBe(unread); expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { const { resources } = context; resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta] }, input: { type: \"number\" }", "({ input }) => { console.log(input); }"))).toBe(unread); + expect(await failure(system("resources: { read: [delta] }", "(_context, input) => { console.log(input); }"))).toBe(unread); expect(await failure(system("resources: { write: [score, mass] }", "({ resources }) => { resources[score] = 1; }"))).toBe("mass is in resources write but never written; remove it, or move it to resources read if it's only read"); expect(await failure(system("components: { has: [mass] }, resources: { read: [delta], write: [score] }", "(_, context) => { context.resources[score] = context.resources[delta]; }"))).toBe("compiled"); diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts index 7236d81a..e99347e5 100644 --- a/packages/ecs/src/implementation/world/async-loading.test.ts +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -67,9 +67,8 @@ const request = System.create({ // 3. A completed load arrives as a run of its own. A slot whose token still matches gets the mesh; a stale one releases it. const apply = System.create({ name: "asyncLoading/apply", - input: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, handle: { type: "integer" } } }, lookup: { slots: { read: [meshSlot] } }, -}, ({ input, lookup, commands }) => { +}, ({ lookup, commands }, input: { readonly entity: Entity; readonly token: number; readonly handle: number }) => { if (lookup.slots(input.entity)?.[meshSlot] === input.token) commands.add(input.entity, meshReady, input.handle); else loaded.delete(input.handle); }); @@ -81,7 +80,7 @@ const spawn = System.create({ name: "asyncLoading/spawn" }, ({ commands }) => { commands.spawn([meshSource])({ [meshSource]: "ship.glb" }); commands.spawn([meshSource])({ [meshSource]: "rock.glb" }); }); -const hide = System.create({ name: "asyncLoading/hide", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { +const hide = System.create({ name: "asyncLoading/hide" }, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, hidden); }); diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index 2fdfdb17..2f0a65e0 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -49,7 +49,7 @@ Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }] Component.register(maybe, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); Component.register(tally, { type: { anyOf: [{ type: "number" }, { type: "null" }] } }); -const seed = System.create({ name: "commandRingsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "commandRingsTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); @@ -60,9 +60,8 @@ const seed = System.create({ name: "commandRingsTest/seed", input: { type: "obje const burst = System.create({ name: "commandRingsTest/burst", kernel: "cpu", - input: { type: "object", properties: { shift: { type: "number" } } }, components: { read: [index] }, -}, (row, { input, commands }) => { +}, (row, { commands }, input: { readonly shift: number }) => { const i = row[index]; commands.add(row.entity, weight, -1); commands.add(row.entity, weight, i + input.shift); @@ -106,7 +105,7 @@ export const overflow = (count: number): Promise<{ readonly rows: readonly strin }; /** Rows targeting a live entity, but for the last, whose target never lived. */ -const targets = System.create({ name: "commandRingsTest/targets", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const targets = System.create({ name: "commandRingsTest/targets" }, ({ commands }, input: { readonly count: number }) => { const first = commands.spawn([index])({ [index]: 0 }); for (let i = 1; i < input.count; i++) commands.spawn([index, target])({ [index]: i, [target]: i === input.count - 1 ? Entity.none : first }); }); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index c41b86b0..2e7372ef 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -7,17 +7,14 @@ import { workers } from "../../interface/resources/workers.js"; const seen: { readonly system: string; readonly input: unknown }[] = []; const move = System.create({ name: "compositeInputTest/move", - input: { type: "object", properties: { distance: { type: "number" } } }, -}, ({ input }) => { seen.push({ system: "move", input: { ...input } }); }); +}, (_context, input: { readonly distance: number }) => { seen.push({ system: "move", input: { ...input } }); }); const step = System.create({ name: "compositeInputTest/step", - input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, -}, ({ input }) => { seen.push({ system: "step", input: { ...input } }); }); +}, (_context, input: { readonly distance: number; readonly dt: number }) => { seen.push({ system: "step", input: { ...input } }); }); const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); const label = System.create({ name: "compositeInputTest/label", - input: { type: "object", properties: { distance: { type: "string" } } }, -}, ({ input }) => { void input.distance; }); +}, (_context, input: { readonly distance: string }) => { void input.distance; }); describe("composite input", () => { it("passes each member the fields of the run's input that it declares", async () => { @@ -31,8 +28,13 @@ describe("composite input", () => { expect(() => System.create({ name: "compositeInputTest/conflict", systems: [move, label] })).toThrow('Systems "compositeInputTest/move" and "compositeInputTest/label" declare input field "distance" with different schemas.'); }); - it("rejects an input that isn't an object of named fields, for untyped declarations", () => { - // @ts-expect-error input is an object schema - expect(() => System.create({ name: "compositeInputTest/scalar", input: { type: "number" } }, () => {})).toThrow('System "compositeInputTest/scalar": input is an object schema of named fields.'); + it("rejects a body taking input without the schema the plugin gives from its type, as one not written in place has", () => { + const counting = (_context: unknown, input: { readonly count: number }): void => { void input.count; }; + expect(() => System.create({ name: "compositeInputTest/unbuilt" }, counting)).toThrow('System "compositeInputTest/unbuilt" takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with the kernel plugin.'); + }); + + it("rejects an input schema that isn't an object of named fields", () => { + const create: (...parameters: unknown[]) => unknown = System.create; + expect(() => create({ name: "compositeInputTest/scalar" }, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('System "compositeInputTest/scalar": input is an object schema of named fields.'); }); }); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index 113efae0..b9eaee3c 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -110,7 +110,7 @@ describe("World.create", () => { it("applies the net structural change of a sync point", async () => { let first = Entity.none; let second = Entity.none; - const edit = System.create({ name: "createTest/edit/net", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({ name: "createTest/edit/net" }, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") { first = commands.spawn([velocity])({ [velocity]: 1 }); second = commands.spawn([velocity])({ [velocity]: 2 }); @@ -134,7 +134,7 @@ describe("World.create", () => { it("reuses a despawned index only after a sync point, with a new generation", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/reuse", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({ name: "createTest/edit/reuse" }, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") spawned.push(commands.spawn([velocity])({ [velocity]: spawned.length })); else for (const entity of spawned) commands.despawn(entity); }); @@ -153,7 +153,7 @@ describe("World.create", () => { it("keeps rows consistent across swap-removes and archetype growth", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/rows", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({ name: "createTest/edit/rows" }, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 100; i++) spawned.push(commands.spawn([velocity, label])({ [velocity]: i, [label]: `${i}` })); else for (let i = 0; i < 100; i += 3) commands.remove(spawned[i], label); }); @@ -173,7 +173,7 @@ describe("World.create", () => { await world.run(spawnPlain); const [entity] = await select(world, { required: [velocity] }); expect(await read(world, entity, frozen)).toBe(null); - const freeze = System.create({ name: "createTest/freeze", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { + const freeze = System.create({ name: "createTest/freeze" }, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, frozen, true); }); await world.run(freeze, { entity }); @@ -297,7 +297,7 @@ describe("World.create", () => { it("sees a change moved into an older chunk by a swap-remove", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/swap", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({ name: "createTest/edit/swap" }, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 2100; i++) spawned.push(commands.spawn([velocity])({ [velocity]: i })); else commands.despawn(spawned[5]); }); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 4b81b6d7..8f744f1c 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -73,7 +73,7 @@ const seed = System.create({ name: "entityLookupWorkersTest/seed" }, ({ commands }); /** Writes some weights, marks and unmarks some targets, despawns some and spawns others: a different change each round. */ -const stir = System.create({ name: "entityLookupWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [weight] } }, (row, { input, commands }) => { +const stir = System.create({ name: "entityLookupWorkersTest/stir", components: { write: [weight] } }, (row, { commands }, input: { readonly round: number }) => { const w = row[weight]; if ((w + input.round) % 5 === 0) row[weight] = w + 1000; if ((w + input.round) % 11 === 0) commands.add(row.entity, mark, 2); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index e2da4b0a..54375d60 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -114,8 +114,7 @@ export const touch = System.create({ name: "entityLookupsTest/touch", components /** Changes the subject by command: `action` names how. */ export const edit = System.create({ name: "entityLookupsTest/edit", - input: { type: "object", properties: { action: { type: "string" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly action: string }) => { const { subject } = scene; switch (input.action) { case "spawn": diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index e5b700bd..a4f4e2d5 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -76,7 +76,7 @@ Component.register(reloaded, { type: { type: "number" } }); Rule.create({ name: "finalizersTest/slots", components: { has: [source] }, derive: [slot] }); let steps: ((commands: System.Commands) => void)[] = []; -const edit = System.create({ name: "finalizersTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { +const edit = System.create({ name: "finalizersTest/edit" }, ({ commands }, input: { readonly step: number }) => { steps[input.step]?.(commands); }); const step = (world: ReturnType) => (run: (commands: System.Commands) => void) => { diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index 8340898c..f3f11696 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -36,24 +36,23 @@ const inOrder = Index.create({ name: "indexChangedTest/inOrder", key: [group], s const weighted = Index.create({ name: "indexChangedTest/weighted", key: [], components: { has: [weight] } }); const one = { type: "object", properties: { entity: Entity.schema, value: { type: "number" } } } as const; -const entityOnly = { type: "object", properties: { entity: Entity.schema } } as const; let spawned: Entity[] = []; -const spawn = System.create({ name: "indexChangedTest/spawn", input: { type: "object", properties: { groups: { type: "array", items: { type: "integer" } } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "indexChangedTest/spawn" }, ({ commands }, input: { readonly groups: readonly number[] }) => { for (const value of input.groups) spawned.push(commands.spawn([group, weight, total])({ [group]: value, [weight]: 1, [total]: 0 })); }); -const setGroup = System.create({ name: "indexChangedTest/setGroup", input: one, target: "entity", components: { write: [group] } }, (row, { input }) => { +const setGroup = System.create({ name: "indexChangedTest/setGroup", target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[group] = input.value; }); -const setWeight = System.create({ name: "indexChangedTest/setWeight", input: one, target: "entity", components: { write: [weight] } }, (row, { input }) => { +const setWeight = System.create({ name: "indexChangedTest/setWeight", target: "entity", components: { write: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[weight] = input.value; }); -const despawn = System.create({ name: "indexChangedTest/despawn", input: entityOnly, target: "entity", components: { has: [group] } }, (row, { commands }) => { +const despawn = System.create({ name: "indexChangedTest/despawn", target: "entity", components: { has: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); -const dropWeight = System.create({ name: "indexChangedTest/dropWeight", input: entityOnly, target: "entity", components: { has: [weight] } }, (row, { commands }) => { +const dropWeight = System.create({ name: "indexChangedTest/dropWeight", target: "entity", components: { has: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.remove(row.entity, weight); }); -const pause = System.create({ name: "indexChangedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { +const pause = System.create({ name: "indexChangedTest/pause", resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 60bb57e1..5122897e 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -116,14 +116,14 @@ const seed = System.create({ name: "indexJoinedTest/seed" }, ({ commands }) => { }); /** Moves cells, changes weights and ranks, despawns some and spawns others. */ -const stir = System.create({ name: "indexJoinedTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial], write: [cell, weight] } }, (row, { input, commands }) => { +const stir = System.create({ name: "indexJoinedTest/stir", components: { read: [serial], write: [cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((row[serial] + input.round) % 9 === 0) row[cell] = [(x + 1) % 13, y]; if ((row[serial] + input.round) % 11 === 0) row[weight] = row[weight] + 1; if ((row[serial] + input.round) % 23 === 0) commands.despawn(row.entity); if (row[serial] % 50 === input.round) commands.spawn([cell, weight, serial, target, label])({ [cell]: [input.round, 3], [weight]: 2, [serial]: 1000 + row[serial], [target]: row[serial], [label]: blank }); }); -const rerank = System.create({ name: "indexJoinedTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { +const rerank = System.create({ name: "indexJoinedTest/rerank", components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 5 === 0) row[rank] = (row[rank] * 3 + 1) % 97; }); diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index 53cb5a70..980831ff 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -127,11 +127,11 @@ const watch = System.create({ if (lookup.near([row[cell][0], row[cell][1]]).changed) row[changes] = row[changes] + 1; }); -const spawnGrid = System.create({ name: "indexKernelsTest/spawnGrid", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnGrid = System.create({ name: "indexKernelsTest/spawnGrid" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 5, (k * 3) % 4], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); }); -const spawnLanes = System.create({ name: "indexKernelsTest/spawnLanes", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnLanes = System.create({ name: "indexKernelsTest/spawnLanes" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([lane, rank, ranked, count, seen])({ [lane]: k % 3, [rank]: (k * 37) % 101, [ranked]: true, [count]: 0, [seen]: Entity.none }); }); diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index fa71c8a7..552b45cb 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -76,7 +76,7 @@ const expected = async (world: CreatedWorld): Promise<{ readonly entities: reado all: [...await select(world, {})].sort(byIndex), }); -const spawn = System.create({ name: "indexKeylessTest/spawn", input: { type: "object", properties: { weights: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "indexKeylessTest/spawn" }, ({ commands }, input: { readonly weights: readonly number[] }) => { for (const value of input.weights) commands.spawn([weight, color])({ [weight]: value, [color]: 0 }); }); diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index 23df92ec..9fedb072 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -57,7 +57,7 @@ const nudge = System.create({ name: "indexOrderTest/nudge", components: { write: }); /** Entities in two archetypes, interleaved in the index's order, some without the weight the index requires. */ -const seed = System.create({ name: "indexOrderTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "indexOrderTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const values = { [lane]: k % 3, [rank]: (k * 37) % 101, [weight]: k }; if (k % 5 === 4) commands.spawn([lane, rank])({ [lane]: k % 3, [rank]: (k * 37) % 101 }); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index 8eb066e9..740ef7ad 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -137,15 +137,15 @@ const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => }); /** Moves some cells, changes some weights and ranks, despawns some serials and spawns more: a different input each time. */ -const stir = System.create({ name: "indexWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [cell, weight] } }, (row, { input }) => { +const stir = System.create({ name: "indexWorkersTest/stir", components: { write: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((x + y + input.round) % 11 === 0) row[cell] = [(x + 1) % 31, y]; if ((x * y + input.round) % 13 === 0) row[weight] = row[weight] + 1; }); -const rerank = System.create({ name: "indexWorkersTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { +const rerank = System.create({ name: "indexWorkersTest/rerank", components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; }); -const churn = System.create({ name: "indexWorkersTest/churn", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial] } }, (row, { input, commands }) => { +const churn = System.create({ name: "indexWorkersTest/churn", components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); if (row[serial] % 101 === input.round) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index 3638409c..fe6a9629 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -47,7 +47,7 @@ const byServerId = Index.create({ name: "indexesTest/byServerId", key: [serverId const byLabel = Index.create({ name: "indexesTest/byLabel", key: [label] }); const children = Index.create({ name: "indexesTest/children", key: [owner] }); -const spawnCells = System.create({ name: "indexesTest/spawnCells", input: { type: "object", properties: { cells: { type: "array", items: { type: "array", items: { type: "integer" } } } } } }, ({ input, commands }) => { +const spawnCells = System.create({ name: "indexesTest/spawnCells" }, ({ commands }, input: { readonly cells: readonly (readonly number[])[] }) => { input.cells.forEach(([x, y], k) => commands.spawn([cell, weight])({ [cell]: [x ?? 0, y ?? 0], [weight]: k })); }); @@ -56,9 +56,8 @@ let seen: { readonly entities: readonly Entity[]; readonly weights: readonly num const look = System.create({ name: "indexesTest/look", - input: { type: "object", properties: { x: { type: "integer" }, y: { type: "integer" } } }, lookup: { near: { index: grid, read: [weight] } }, -}, ({ input, lookup }) => { +}, ({ lookup }, input: { readonly x: number; readonly y: number }) => { const near = lookup.near([input.x, input.y]); const entities: Entity[] = []; const weights: number[] = []; @@ -141,7 +140,7 @@ describe("hash indexes on the main thread", () => { it("key strings by value, and entities by their generation too", async () => { await using world = createWorld({ [workers]: 0 }); let found: readonly number[] = []; - const named = System.create({ input: { type: "object", properties: { name: { type: "string" } } }, lookup: { named: { index: byLabel } } }, ({ input, lookup }) => { + const named = System.create({ lookup: { named: { index: byLabel } } }, ({ lookup }, input: { readonly name: string }) => { const list = lookup.named(input.name); found = Array.from({ length: list.length }, (_, i) => list.at(i).entity); }); @@ -157,7 +156,7 @@ describe("hash indexes on the main thread", () => { expect(found).toEqual([]); const parent = labeled[1]; let held: readonly Entity[] = []; - const kids = System.create({ input: { type: "object", properties: { of: { type: "number" } } }, lookup: { of: { index: children } } }, ({ input, lookup }) => { + const kids = System.create({ lookup: { of: { index: children } } }, ({ lookup }, input: { readonly of: number }) => { const list = lookup.of(Entity.none); void list; const of = lookup.of(input.of === 0 ? parent : Entity.none); @@ -177,7 +176,7 @@ describe("hash indexes on the main thread", () => { describe("unique indexes", () => { let known: Entity | null = null; - const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { known: { index: byServerId } } }, ({ input, lookup }) => { + const find = System.create({ lookup: { known: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { known = lookup.known(input.id)?.entity ?? null; }); it("give the row of a key or null, and report two entities with one key as an index failure, keeping the lower", async () => { @@ -346,9 +345,8 @@ const foldNear = System.create({ /** Moves each row whose weight is below `share` of 1000 to the cell `cell` gives it, so keys change during runs. */ const rekey = System.create({ - name: "indexesTest/rekey", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, - components: { read: [weight], write: [cell] }, before: [probe], -}, (row, { input }) => { + name: "indexesTest/rekey", components: { read: [weight], write: [cell] }, before: [probe], +}, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[cell] = cells[(row[weight] + input.seed) % cells.length]; }); @@ -423,13 +421,12 @@ const ranksOf = (list: { readonly length: number; readonly changed: boolean; at( }); describe("sorted indexes on the main thread", () => { - const spawnRanks = System.create({ input: { type: "object", properties: { ranks: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { + const spawnRanks = System.create({ }, ({ commands }, input: { readonly ranks: readonly number[] }) => { for (const value of input.ranks) commands.spawn([rank])({ [rank]: value }); }); const query = System.create({ - input: { type: "object", properties: { kind: { type: "string" }, value: { type: "number" }, other: { type: "number" } } }, lookup: { ranks: { index: byRank, read: [rank] } }, - }, ({ input, lookup }) => { + }, ({ lookup }, input: { readonly kind: string; readonly value: number; readonly other: number }) => { const { value, other } = input; switch (input.kind) { case "all": ranked = ranksOf(lookup.ranks()); break; @@ -572,9 +569,8 @@ const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { l }); }); const rerank = System.create({ - name: "indexesTest/rerank", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, - components: { read: [weight], write: [rank] }, before: [probeSorted], -}, (row, { input }) => { + name: "indexesTest/rerank", components: { read: [weight], write: [rank] }, before: [probeSorted], +}, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[rank] = (row[weight] * 31 + input.seed * 17) % 1000; }); diff --git a/packages/ecs/src/implementation/world/input.ts b/packages/ecs/src/implementation/world/input.ts index 67b65ac5..fab08996 100644 --- a/packages/ecs/src/implementation/world/input.ts +++ b/packages/ecs/src/implementation/world/input.ts @@ -3,7 +3,7 @@ import type { Leaf } from '../../interface/system/leaf.js'; /** The fields of a run's input that a leaf declares, so other members' fields aren't copied to its workers. */ export const inputFor = (leaf: Leaf): (input: unknown) => unknown => { - const { input } = leaf.declaration; + const { input } = leaf; if (input === undefined) return () => undefined; const fields = Object.keys(input.properties); return value => { diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts index e35812ef..b60844c3 100644 --- a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -189,9 +189,7 @@ const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => } }); -const operation = { type: "object", properties: { op: { type: "number" } } } as const; - -const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", input: operation, components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, { input: { op } }) => { +const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; const p = row[f2p]; @@ -236,7 +234,7 @@ const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", input else if (op === 38) row[f2o] = Vec2.faceforward(a, b, p); }); -const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: "gpu", input: operation, components: { read: [f2a, f2b], write: [fo] } }, (row, { input: { op } }) => { +const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: "gpu", components: { read: [f2a, f2b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; if (op === 0) row[fo] = Vec2.dot(a, b); @@ -244,7 +242,7 @@ const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: else if (op === 2) row[fo] = Vec2.length(a); }); -const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", input: operation, components: { read: [i2a, i2b], write: [i2o] } }, (row, { input: { op } }) => { +const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", components: { read: [i2a, i2b], write: [i2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i2a]; const b = row[i2b]; if (op === 0) row[i2o] = Vec2.I32.add(a, b); @@ -265,7 +263,7 @@ const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", input else if (op === 15) row[i2o] = Vec2.I32.clamp(a, [-1000, -1000], [1000, 1000]); }); -const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", input: operation, components: { read: [u2a, u2b], write: [u2o] } }, (row, { input: { op } }) => { +const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", components: { read: [u2a, u2b], write: [u2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u2a]; const b = row[u2b]; if (op === 0) row[u2o] = Vec2.U32.add(a, b); @@ -284,7 +282,7 @@ const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", input else if (op === 13) row[u2o] = Vec2.U32.clamp(a, [1000, 1000], [100000, 100000]); }); -const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", input: operation, components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, { input: { op } }) => { +const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; const p = row[f3p]; @@ -331,7 +329,7 @@ const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", input else if (op === 40) row[f3o] = Vec3.faceforward(a, b, p); }); -const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: "gpu", input: operation, components: { read: [f3a, f3b], write: [fo] } }, (row, { input: { op } }) => { +const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: "gpu", components: { read: [f3a, f3b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; if (op === 0) row[fo] = Vec3.dot(a, b); @@ -339,7 +337,7 @@ const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: else if (op === 2) row[fo] = Vec3.length(a); }); -const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", input: operation, components: { read: [i3a, i3b], write: [i3o] } }, (row, { input: { op } }) => { +const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", components: { read: [i3a, i3b], write: [i3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i3a]; const b = row[i3b]; if (op === 0) row[i3o] = Vec3.I32.add(a, b); @@ -361,7 +359,7 @@ const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", input else if (op === 16) row[i3o] = Vec3.I32.clamp(a, [-1000, -1000, -1000], [1000, 1000, 1000]); }); -const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", input: operation, components: { read: [u3a, u3b], write: [u3o] } }, (row, { input: { op } }) => { +const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", components: { read: [u3a, u3b], write: [u3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u3a]; const b = row[u3b]; if (op === 0) row[u3o] = Vec3.U32.add(a, b); @@ -381,7 +379,7 @@ const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", input else if (op === 14) row[u3o] = Vec3.U32.clamp(a, [1000, 1000, 1000], [100000, 100000, 100000]); }); -const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", input: operation, components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, { input: { op } }) => { +const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; const p = row[f4p]; @@ -427,7 +425,7 @@ const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", input else if (op === 39) row[f4o] = Vec4.faceforward(a, b, p); }); -const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: "gpu", input: operation, components: { read: [f4a, f4b], write: [fo] } }, (row, { input: { op } }) => { +const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: "gpu", components: { read: [f4a, f4b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; if (op === 0) row[fo] = Vec4.dot(a, b); @@ -435,7 +433,7 @@ const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: else if (op === 2) row[fo] = Vec4.length(a); }); -const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", input: operation, components: { read: [i4a, i4b], write: [i4o] } }, (row, { input: { op } }) => { +const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", components: { read: [i4a, i4b], write: [i4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i4a]; const b = row[i4b]; if (op === 0) row[i4o] = Vec4.I32.add(a, b); @@ -457,7 +455,7 @@ const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", input else if (op === 16) row[i4o] = Vec4.I32.clamp(a, [-1000, -1000, -1000, -1000], [1000, 1000, 1000, 1000]); }); -const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", input: operation, components: { read: [u4a, u4b], write: [u4o] } }, (row, { input: { op } }) => { +const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", components: { read: [u4a, u4b], write: [u4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u4a]; const b = row[u4b]; if (op === 0) row[u4o] = Vec4.U32.add(a, b); @@ -477,7 +475,7 @@ const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", input else if (op === 14) row[u4o] = Vec4.U32.clamp(a, [1000, 1000, 1000, 1000], [100000, 100000, 100000, 100000]); }); -const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", input: operation, components: { read: [ma, mb], write: [mo] } }, (row, { input: { op } }) => { +const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", components: { read: [ma, mb], write: [mo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[mb]; if (op === 0) row[mo] = Mat4x4.multiply(a, b); @@ -487,18 +485,18 @@ const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", inp else if (op === 4) row[mo] = Mat4x4.scale(a, 0.5); }); -const mat4x4Vector = System.create({ name: "intrinsicsTest/mat4x4Vector", kernel: "gpu", input: operation, components: { read: [ma, f4a], write: [f4o] } }, (row, { input: { op } }) => { +const mat4x4Vector = System.create({ name: "intrinsicsTest/mat4x4Vector", kernel: "gpu", components: { read: [ma, f4a], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[f4a]; if (op === 0) row[f4o] = Mat4x4.multiplyVec4(a, b); }); -const mat4x4Scalar = System.create({ name: "intrinsicsTest/mat4x4Scalar", kernel: "gpu", input: operation, components: { read: [ma], write: [fo] } }, (row, { input: { op } }) => { +const mat4x4Scalar = System.create({ name: "intrinsicsTest/mat4x4Scalar", kernel: "gpu", components: { read: [ma], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; if (op === 0) row[fo] = Mat4x4.determinant(a); }); -const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [qo] } }, (row, { input: { op } }) => { +const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", components: { read: [qa, qb], write: [qo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[qo] = Quat.add(a, b); @@ -507,14 +505,14 @@ const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", input: else if (op === 3) row[qo] = Quat.negate(a); }); -const quatScalar = System.create({ name: "intrinsicsTest/quatScalar", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [fo] } }, (row, { input: { op } }) => { +const quatScalar = System.create({ name: "intrinsicsTest/quatScalar", kernel: "gpu", components: { read: [qa, qb], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[fo] = Quat.dot(a, b); else if (op === 1) row[fo] = Quat.length(a); }); -const u32 = System.create({ name: "intrinsicsTest/u32", kernel: "gpu", input: operation, components: { read: [ua], write: [uo] } }, (row, { input: { op } }) => { +const u32 = System.create({ name: "intrinsicsTest/u32", kernel: "gpu", components: { read: [ua], write: [uo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ua]; if (op === 0) row[uo] = U32.countLeadingZeros(a); else if (op === 1) row[uo] = U32.countOneBits(a); diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index 86250b91..c6ad5721 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -41,17 +41,17 @@ Component.register(words, { type: { type: "set", items: { type: "string" } } }); Resource.register(counts, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); /** Adds its struct input to `total`, and its object input's names to `names`. */ -const add = System.create({ name: "joinTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +const add = System.create({ name: "joinTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const append = System.create({ name: "joinTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { +const append = System.create({ name: "joinTest/append", resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -const spawn = System.create({ name: "joinTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "joinTest/spawn" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); /** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ -const tally = System.create({ name: "joinTest/tally", input: { type: "object", properties: { words: { type: "set", items: { type: "string" } } } }, resources: { write: [counts] } }, ({ input, resources, commands }) => { +const tally = System.create({ name: "joinTest/tally", resources: { write: [counts] } }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { const next = new Map(resources[counts]); for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); resources[counts] = next; diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 49e39d8b..84838892 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -220,7 +220,7 @@ export const joinWorld = (port: Parameters[0], options: { readon const base = slotsWord + slot * slotWords; control[base + slotStatus] = pending; control[base + slotNumber] = number; - const schema = definition.kind === "composite" ? undefined : definition.declaration.input; + const schema = definition.kind === "composite" ? undefined : definition.input; const layout = fitsInline(schema, input) ? inputLayoutOf(definition, schema) : null; if (layout !== null && layout.kind === "linear") { layout.write(views[slot], 0, input); diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 6f5c9f0f..79ea705a 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -31,13 +31,13 @@ Resource.register(total, { type: { type: "number", default: 0 } }); Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); /** Appends a digit to `total`, so the order of runs shows in it. */ -export const add = System.create({ name: "joiningTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +export const add = System.create({ name: "joiningTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -export const append = System.create({ name: "joiningTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { +export const append = System.create({ name: "joiningTest/append", resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -export const spawn = System.create({ name: "joiningTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +export const spawn = System.create({ name: "joiningTest/spawn" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); export const fail = System.create({ name: "joiningTest/fail" }, () => { throw new Error("joiningTest failure"); }); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index e3bb8b3f..97820ed9 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -40,7 +40,7 @@ Component.register(position, { type: { type: "array", items: { type: "number", p const spawned = Archetype.create([value, made]); -const seed = System.create({ name: "kernelSpawnsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "kernelSpawnsTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index b415c4b5..a8174185 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -219,7 +219,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript upCache.set(archetype, { version: archetype.version, columns }); return columns; }; - const argsLayout = leaf.declaration.input === undefined ? null : layoutOf(leaf.declaration.input); + const argsLayout = leaf.input === undefined ? null : layoutOf(leaf.input); const scalarType = (scalar: WasmScalar): Type | null => scalar.source === "args" ? argsLayout === null ? null : leafType(argsLayout, scalar.path) : scalar.index < description.resources.length ? leafType(state.layouts[idOf(state, description.resources[scalar.index])], scalar.path) : null; diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index 9dc793c0..a2777a7a 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -53,7 +53,7 @@ Component.register(tag6, { type: { const: true } }); const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const fill = System.create({ name: "layoutDirectoryTest/fill" }, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); @@ -74,7 +74,7 @@ const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index a1fbd2fe..18cdc1e6 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -55,7 +55,7 @@ Component.register(tag6, { type: { const: true } }); const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const fill = System.create({ name: "layoutDirectoryTest/fill" }, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); @@ -76,7 +76,7 @@ const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ diff --git a/packages/ecs/src/implementation/world/lifetime.test-helper.ts b/packages/ecs/src/implementation/world/lifetime.test-helper.ts index 76d12135..eae89cf8 100644 --- a/packages/ecs/src/implementation/world/lifetime.test-helper.ts +++ b/packages/ecs/src/implementation/world/lifetime.test-helper.ts @@ -73,7 +73,7 @@ export const freshKernel = (system: System): System => { const description = kernelOf(system); if (leaf === undefined || leaf.kind === "composite" || description === undefined) throw new Error("Not a kernel system."); // What `System.create` makes of the same declaration and body, without the id a top-level binding takes. - const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, body: leaf.body }; + const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body }; claimSystem(copy, undefined); return attachKernel(copy, description); }; diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts index e078cc02..11532205 100644 --- a/packages/ecs/src/implementation/world/loop.test.ts +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -20,7 +20,7 @@ Component.register(count, { type: { type: "number" } }); const increment = requireDefinition(System.create({ name: "loopTest/increment", components: { write: [count] } }, row => { row[count] += 1; })); const reader = requireDefinition(System.create({ name: "loopTest/reader", components: { read: [count] } }, row => { void row[count]; })); const ordered = requireDefinition(System.create({ name: "loopTest/ordered", components: { read: [count] }, after: [System.create({ name: "loopTest/phase" }, () => {})] }, row => { void row[count]; })); -const takesInput = requireDefinition(System.create({ name: "loopTest/input", input: { type: "object", properties: { by: { type: "number" } } } }, () => {})); +const takesInput = requireDefinition(System.create({ name: "loopTest/input" }, (_context, input: { readonly by: number }) => {})); /** A host whose frames and timers run only when the test says, at the times it gives. */ const createHost = (settings: { cadence: Cadence; always: boolean; wake: number }) => { diff --git a/packages/ecs/src/implementation/world/loop.ts b/packages/ecs/src/implementation/world/loop.ts index 7e1ea679..3ab89871 100644 --- a/packages/ecs/src/implementation/world/loop.ts +++ b/packages/ecs/src/implementation/world/loop.ts @@ -105,7 +105,7 @@ const checkNoInput = (system: Definition, seen: Set = new Set()): vo if (seen.has(system)) return; seen.add(system); if (isLeaf(system)) { - if (system.declaration.input !== undefined) throw new Error(`System "${labelOfItem(system)}" takes input, which ticks don't pass; run it instead.`); + if (system.input !== undefined) throw new Error(`System "${labelOfItem(system)}" takes input, which ticks don't pass; run it instead.`); return; } for (const member of system.declaration.systems) checkNoInput(requireDefinition(member), seen); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 0bf0806f..2edd18f2 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -36,7 +36,7 @@ Resource.register(other, { type: { type: "number", default: 0 } }); Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); Component.register(weight, { type: { type: "number" } }); -const raise = System.create({ name: "observeCasesTest/raise", input: { type: "object", properties: { by: { type: "number" } } }, resources: { write: [level] } }, ({ input, resources }) => { +const raise = System.create({ name: "observeCasesTest/raise", resources: { write: [level] } }, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; resources[level] += input.by; }); diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index 4c8c1b70..42381980 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -32,7 +32,7 @@ Component.register(other, { type: { type: "number" } }); Resource.register(level, { type: { type: "number", default: 0 } }); let spawned: Entity[] = []; -const seed = System.create({ name: "observeFactoriesTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "observeFactoriesTest/seed" }, ({ commands }, input: { readonly count: number }) => { spawned = []; for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); }); diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index f790f2e4..be0c1629 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -52,13 +52,13 @@ Component.register(kept, { type: { type: "string" } }); Resource.register(total, { type: { type: "number", default: 0 } }); Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); -const add = System.create({ name: "observeJoinedTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +const add = System.create({ name: "observeJoinedTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const spawn = System.create({ name: "observeJoinedTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "observeJoinedTest/spawn" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); -const spawnPlaced = System.create({ name: "observeJoinedTest/spawnPlaced", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawnPlaced = System.create({ name: "observeJoinedTest/spawnPlaced" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([place, caption, widget, kept])({ [place]: input.x, [widget]: null, [kept]: `k${input.x}` }); }); const nullCaptions = System.create({ name: "observeJoinedTest/nullCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 2456e1fa..092123f4 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -29,7 +29,7 @@ export const tallies: number[] = []; export const bump = System.create({ name: "observerContainedTest/bump", resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); export const nothing = System.create({ name: "observerContainedTest/nothing" }, () => {}); -export const setHolding = System.create({ name: "observerContainedTest/setHolding", input: { type: "object", properties: { holding: { type: "number" } } }, resources: { write: [holding] } }, ({ input, resources }) => { +export const setHolding = System.create({ name: "observerContainedTest/setHolding", resources: { write: [holding] } }, ({ resources }, input: { readonly holding: number }) => { resources[holding] = input.holding; }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index 082edbd4..a2d69767 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -27,7 +27,7 @@ const widen = System.create({ name: "observerContainedTest/widen", resources: { resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; }); const step = System.create({ name: "observerContainedTest/step", resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); -const pause = System.create({ name: "observerContainedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { +const pause = System.create({ name: "observerContainedTest/pause", resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 2929cd36..04e00468 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -101,10 +101,10 @@ export const advance = System.create({ name: "observerJoinedWorkerTest/advance", row[word] = wordOf(value); }); export const ticked = System.create({ name: "observerJoinedWorkerTest/ticked", systems: [count, advance] }); -export const seed = System.create({ name: "observerJoinedWorkerTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seed = System.create({ name: "observerJoinedWorkerTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ name: "observerJoinedWorkerTest/setStall", input: { type: "object", properties: { pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { +export const setStall = System.create({ name: "observerJoinedWorkerTest/setStall", resources: { write: [stall] } }, ({ resources }, input: { readonly pause: number }) => { resources[stall] = input.pause; }); @@ -162,7 +162,7 @@ export const countHot = System.create({ name: "observerJoinedWorkerTest/countHot /** On the world's thread: the motto of a round, and the round, every motto of one size. */ export const mottoOf = (round: number): string => `motto ${round % 10}`; -export const setMotto = System.create({ name: "observerJoinedWorkerTest/setMotto", input: { type: "object", properties: { round: { type: "integer" } } }, resources: { write: [motto, mottoRound] } }, ({ input, resources }) => { +export const setMotto = System.create({ name: "observerJoinedWorkerTest/setMotto", resources: { write: [motto, mottoRound] } }, ({ resources }, input: { readonly round: number }) => { resources[motto] = mottoOf(input.round); resources[mottoRound] = input.round; }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 14b26e49..8d3dfd98 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -45,7 +45,7 @@ const allTagged = Index.create({ name: "observerJoinedTest/allTagged", key: [], const raise = System.create({ name: "observerJoinedTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); const nothing = System.create({ name: "observerJoinedTest/nothing" }, () => {}); -const spawn = System.create({ name: "observerJoinedTest/spawn", input: { type: "object", properties: { at: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "observerJoinedTest/spawn" }, ({ commands }, input: { readonly at: number }) => { commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); }); const spawnLabelled = System.create({ name: "observerJoinedTest/spawnLabelled" }, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index efa93469..0cc42c53 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -21,7 +21,7 @@ const seed = System.create({ name: "poolSizeTest/seed" }, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); }); const double = System.create({ name: "poolSizeTest/double", kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); -const resize = System.create({ name: "poolSizeTest/resize", input: { type: "object", properties: { size: { anyOf: [{ type: "integer" }, { type: "null" }] } } }, resources: { write: [workers] } }, ({ input, resources }) => { +const resize = System.create({ name: "poolSizeTest/resize", resources: { write: [workers] } }, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 7ebe0494..e57e11d3 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -73,16 +73,16 @@ export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", }); /** Spawns titled rows from the world's thread, with the thread's widget at null. */ -export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); }); /** From the world's thread: every title as another of the same size, so the records of titles let go are reused. */ -export const retitle = System.create({ name: "remoteFinalizersTest/retitle", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [title] } }, (row, { input }) => { +export const retitle = System.create({ name: "remoteFinalizersTest/retitle", components: { write: [title] } }, (row, _context, input: { readonly round: number }) => { row[title] = `r${input.round}`; }); /** Spawns from the world's thread: structs, the thread's objects at null and their start. */ -export const spawn = System.create({ name: "remoteFinalizersTest/spawn", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawn = System.create({ name: "remoteFinalizersTest/spawn" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, place, marked, widget, label])({ [serial]: k, [place]: [k, -k], [widget]: null }); }); /** On the thread: widgets written in place. */ @@ -96,7 +96,7 @@ export const relabel = System.create({ name: "remoteFinalizersTest/relabel", com if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); }); /** On the thread: its object resource. */ -export const retone = System.create({ name: "remoteFinalizersTest/retone", input: { type: "object", properties: { tone: { type: "string" } } }, resources: { write: [tone] } }, ({ input, resources }) => { +export const retone = System.create({ name: "remoteFinalizersTest/retone", resources: { write: [tone] } }, ({ resources }, input: { readonly tone: string }) => { resources[tone] = { text: input.tone }; }); /** From the world's thread: places move, so a stop's values are those before the sync point applies. */ @@ -104,11 +104,11 @@ export const shift = System.create({ name: "remoteFinalizersTest/shift", compone row[place] = [row[place][0] + 100, row[place][1]]; }); /** From the world's thread: despawns these serials. */ -export const despawn = System.create({ name: "remoteFinalizersTest/despawn", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial] } }, (row, { input, commands }) => { +export const despawn = System.create({ name: "remoteFinalizersTest/despawn", components: { read: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.despawn(row.entity); }); /** From the world's thread: removes widgets of these serials, a stop that despawns nothing. */ -export const strip = System.create({ name: "remoteFinalizersTest/strip", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial], has: [widget] } }, (row, { input, commands }) => { +export const strip = System.create({ name: "remoteFinalizersTest/strip", components: { read: [serial], has: [widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.remove(row.entity, widget); }); /** From the world's thread: every row moves archetype, flagged on one run and not on the next. */ diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index 95aff7c8..c9f21086 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -89,7 +89,7 @@ describe("finalizers on a joined thread", () => { let reusedEarly = 0; let reused = 0; // Spawns check each entity they reserve: an index whose last entity stopped is reused only once it was finalized. - const check = System.create(identify({ name: "remoteFinalizersTest/check", input: { type: "object", properties: { serial: { type: "integer" } } } }, "remoteFinalizersTest#check"), ({ input, commands }) => { + const check = System.create(identify({ name: "remoteFinalizersTest/check" }, "remoteFinalizersTest#check"), ({ commands }, input: { readonly serial: number }) => { const entity = commands.spawn([serial, place, marked, widget])({ [serial]: input.serial, [place]: [0, 0], [widget]: null }); const before = stopped.get(indexOf(entity)); if (before === undefined) return; diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts index eddcd11c..737b0b0f 100644 --- a/packages/ecs/src/implementation/world/remote-systems.ts +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -575,7 +575,7 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne watched: entityWatchesOf(declaration.lookup).map(key => idOf(state, key)), archetypes: state.archetypes, writes, - declaresInput: declaration.input !== undefined, + declaresInput: leaf.input !== undefined, lastRun: since, result: { flags: 0, segment: undefined, error: undefined }, tail: null, diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 71553f4a..e7aae3be 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -44,7 +44,7 @@ Component.register(kept, { type: { type: "string" } }); Component.register(handle, { thread: "remoteTest" }); Component.register(guarded, { thread: "remoteTestFinalized" }); -const spawn = System.create({ name: "remoteTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "remoteTest/spawn" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); }); const nullCaptions = System.create({ name: "remoteTest/nullCaptions", components: { has: [caption, point] } }, (row, { commands }) => { diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index 8185405f..e6f3e519 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -80,6 +80,6 @@ export const secondTail = System.create({ name: "removeTest/secondTail", resourc export const ticking = System.create({ name: "removeTest/ticking", after: [second] }, () => {}); /** Sets how long `first` and `firstTail` spin. */ -export const setPause = System.create({ name: "removeTest/setPause", input: { type: "object", properties: { milliseconds: { type: "number" } } }, resources: { write: [pause] } }, ({ input, resources }) => { +export const setPause = System.create({ name: "removeTest/setPause", resources: { write: [pause] } }, ({ resources }, input: { readonly milliseconds: number }) => { resources[pause] = input.milliseconds; }); diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index db328c33..ab80b52e 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -133,7 +133,7 @@ describe("reports", () => { await using world = inline(); logged.length = 0; world.add(logger); - const failWith = System.create({ name: "reportsTest/failWith", input: { type: "object", properties: { k: { type: "integer" } } } }, ({ input }) => { throw new Error(`run ${input.k}`); }); + const failWith = System.create({ name: "reportsTest/failWith" }, (_context, input: { readonly k: number }) => { throw new Error(`run ${input.k}`); }); for (let k = 0; k < retainedRuns; k++) await world.run(failWith, { k }); logged.length = 0; const reused = await world.run(failWith, { k: retainedRuns }); diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 4d9f194e..5168d531 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -412,11 +412,11 @@ const createPlainArrayLookup = (state: State, lookup: SystemLookup) => { }; /** - * A body's context: its resources, lookups, reductions, input and commands, each under its own key and present only + * A body's context: its resources, lookups, reductions and commands, each under its own key and present only * when declared. `bind` resolves the resources' cells for a run; `reduced[t]` holds the leaves of the fold into its * `t`th reduction target so far this run. */ -export type Context = { readonly value: { input?: unknown; up?: unknown }; readonly bind: () => void; readonly reduced: readonly Float64Array[] }; +export type Context = { readonly value: { up?: unknown }; readonly bind: () => void; readonly reduced: readonly Float64Array[] }; /** * What a context holds beyond the declaration's resources and lookups, and for lookups through indexes, the index of @@ -425,7 +425,6 @@ export type Context = { readonly value: { input?: unknown; up?: unknown }; reado type Extras = { readonly targets?: readonly ReductionTarget[]; readonly commands?: Commands; - readonly input?: boolean; readonly indexes?: ReadonlyMap; readonly since?: Since; }; @@ -437,8 +436,8 @@ const never: Since = { value: -1 }; const own = (): object => Object.create({}); /** Resource cells are resolved once per run: they move only at sync points. An unresolved resource throws on access. */ -export const createContext = (state: State, declaration: Pick, { targets = [], commands, input = false, indexes, since = never }: Extras = {}): Context => { - const value: { input?: unknown; up?: unknown } = own(); +export const createContext = (state: State, declaration: Pick, { targets = [], commands, indexes, since = never }: Extras = {}): Context => { + const value: { up?: unknown } = own(); const { lookup, resources } = declaration; const names = Object.keys(lookup ?? {}); if (names.length > 0) { @@ -501,7 +500,6 @@ export const createContext = (state: State, declaration: Pick { if (keys.length > 0) { diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index 8bd5f49f..c36f5e4b 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -67,7 +67,7 @@ Rule.create({ name: "rulesTest/sprites", components: { has: [sprite] }, derive: Rule.create({ name: "rulesTest/tag", components: { has: [tagged] }, derive: [tag] }); /** Runs `edit` with commands once, as its own run. */ -const edit = System.create({ name: "rulesTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { +const edit = System.create({ name: "rulesTest/edit" }, ({ commands }, input: { readonly step: number }) => { for (const step of steps[input.step] ?? []) step(commands); }); let steps: ((commands: System.Commands) => void)[][] = []; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 3f4f7c5a..e1ad699f 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -186,7 +186,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const leafNumber = numberOf(leaf); const label = labelOfItem(leaf); // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. - const system: { body(subject: unknown, context?: unknown): void } = leaf; + const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); const excluded = (query?.not ?? []).map(key => idOf(state, key)); @@ -313,10 +313,11 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { // A kernel's reductions fold in its partials, so only a body that runs as written gets reducer functions. Render // bodies, GPU kernels and observer systems issue no commands. const ownInput = inputFor(leaf); + /** The current run's input, which the body takes after its context; kept until the next run, as its context is. */ + let runInput: unknown; const context = createContext(state, declaration, { targets: kernel === null ? targets : [], commands: leaf.kind === "render" || declaration.kernel === "gpu" || declaration.observer === true ? undefined : commands, - input: declaration.input !== undefined, indexes: indexLookups.byName, since: lookupSince, }); @@ -415,27 +416,28 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { if (archetype === undefined || !matches.includes(archetype)) return; const row = bindRow(archetype); row.index = state.entities.row[index]; - system.body(row.value, context.value); + system.body(row.value, context.value, runInput); }; const runBody = (archetype: Archetype, since: number): void => { const row = bindRow(archetype); const { value } = row; const values = context.value; + const input = runInput; const filters = filtersOf(archetype); // Every row passes, so nothing is filtered. // A body may remove its own system, after which no row of it runs. if (since < 0 || filters.length === 0) { for (let index = 0, count = archetype.count; index < count && !holdings.removed; index++) { row.index = index; - system.body(value, values); + system.body(value, values, input); } return; } forEachPassingRange(filters, since, 0, archetype.count, (start, end) => { for (let index = start; index < end && !holdings.removed; index++) { row.index = index; - system.body(value, values); + system.body(value, values, input); } }); }; @@ -515,11 +517,12 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { // The parent's row is bound after the system's own, for the archetype both may share. const parentRow = parentArchetype < 0 ? null : bindParent(state.archetypes[parentArchetype]); const upContext = context.value; + const input = runInput; upContext.up = parentRow === null ? null : parentRow.value; for (let n = start; n < end && !holdings.removed; n++) { row.index = rows[n]; if (parentRow !== null) parentRow.index = parents[n]; - system.body(row.value, upContext); + system.body(row.value, upContext, input); } }; @@ -539,6 +542,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runs = order.runs(sorted, matches, since < 0 ? null : orderPassing); const slot = partialCount === 0 ? 0 : takeSlots(1); const contextValue = context.value; + const input = runInput; for (let k = 0; k < runs.length && !holdings.removed; k++) { const run = runs[k]; const archetype = matches[run.owner]; @@ -550,7 +554,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { value } = row; for (let n = run.start; n < run.end && !holdings.removed; n++) { row.index = run.rows[n]; - system.body(value, contextValue); + system.body(value, contextValue, input); } } }; @@ -560,6 +564,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owners = order.owners(matches); const { entries, stride, words, lengths, blocks } = sorted; const contextValue = context.value; + const input = runInput; let current = -1; let row: ReturnType | null = null; let value: object | null = null; @@ -579,7 +584,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { current = owner; } row.index = entries[at + 2]; - system.body(value, contextValue); + system.body(value, contextValue, input); } } }; @@ -926,7 +931,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { /** -1 passes every row. */ const since = !filtered || resourcesChanged ? -1 : lastRun; context.bind(); - if (declaration.input !== undefined) context.value.input = args; + runInput = args; if (indexed && kernel !== null) indexParameters = kernel.refreshIndexes(lastRun); if (placement === "render") { refreshMatches(); @@ -935,7 +940,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { } if (query === undefined) { ran = since < 0; - if (ran) system.body(context.value); + if (ran) system.body(context.value, args); return chunks; } // Only resource and index filters, none of which changed: no row passes, unless its parent changed. diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts index 1251fa95..a7cfdfdd 100644 --- a/packages/ecs/src/implementation/world/schedule.test.ts +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -21,7 +21,7 @@ Component.register(value, { type: { type: "number" } }); Component.register(other, { type: { type: "number" } }); /** Systems built as values, not registered, so each test's are its own. */ -const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, body: () => {} }); +const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration }); const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name)); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index 59fb1ea8..192e9871 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -240,7 +240,7 @@ export const createSharing = (host: SharingHost) => { released(holder, thread, sequence); continue; } - const layout = bytes >= 0 && definition.kind !== "composite" ? inputLayoutOf(definition, definition.declaration.input) : null; + const layout = bytes >= 0 && definition.kind !== "composite" ? inputLayoutOf(definition, definition.input) : null; const held = holder !== null && holder.tasks !== null ? heldInput(thread.number, sequence, holder.tasks) : null; const value = own ? joinedRun(sequence) : held ?? (layout !== null && layout.kind === "linear" ? layout.read(thread.views[slot], 0) : input); let ran: Promise; diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index 13608233..923f1019 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -39,23 +39,23 @@ Resource.register(title, { type: { type: "string", default: "" } }); /** Text of every kind a string holds: none, letters, astral characters as surrogate pairs, and a long run. */ const textOf = (n: number): string => n % 4 === 0 ? "" : n % 4 === 1 ? `item ${n}` : n % 4 === 2 ? `✓ ${n} 😀 naïve` : `${"long ".repeat(40)}${n}`; -const spawn = System.create({ name: "stringsTest/spawn", input: { type: "object", properties: { count: { type: "integer" }, from: { type: "integer" } } } }, ({ input, commands }) => { +const spawn = System.create({ name: "stringsTest/spawn" }, ({ commands }, input: { readonly count: number; readonly from: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: textOf(input.from + i), [order]: input.from + i }); }); /** `count` rows with labels of 400 code units, whose records are 1024-byte blocks of the arena. */ -const spawnLong = System.create({ name: "stringsTest/spawnLong", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnLong = System.create({ name: "stringsTest/spawnLong" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: `${i}`.padStart(400, "·"), [order]: i }); }); /** A run over every row that changes nothing, for its sync point. */ const pass = System.create({ name: "stringsTest/pass", components: { read: [order] } }, row => { void row[order]; }); -const rename = System.create({ name: "stringsTest/rename", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], write: [label] } }, (row, { input }) => { +const rename = System.create({ name: "stringsTest/rename", components: { read: [order], write: [label] } }, (row, _context, input: { readonly suffix: string }) => { row[label] = `${textOf(row[order])}${input.suffix}`; }); -const relabel = System.create({ name: "stringsTest/relabel", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], has: [label] } }, (row, { input, commands }) => { +const relabel = System.create({ name: "stringsTest/relabel", components: { read: [order], has: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); @@ -73,11 +73,11 @@ const noted = System.create({ name: "stringsTest/noted", components: { has: [ord const despawnEven = System.create({ name: "stringsTest/despawnEven", components: { read: [order] } }, (row, { commands }) => { if (row[order] % 2 === 0) commands.despawn(row.entity); }); -const labelAgain = System.create({ name: "stringsTest/labelAgain", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], not: [label] } }, (row, { input, commands }) => { +const labelAgain = System.create({ name: "stringsTest/labelAgain", components: { read: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const edit = System.create({ name: "stringsTest/edit", input: { type: "object", properties: { title: { type: "string" } } }, resources: { write: [mode, title] } }, ({ input, resources }) => { +const edit = System.create({ name: "stringsTest/edit", resources: { write: [mode, title] } }, ({ resources }, input: { readonly title: string }) => { resources[mode] = "edit"; resources[title] = input.title; }); diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index 80736502..4f387eab 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -77,7 +77,7 @@ export const advance = System.create({ name: "tailsTest/advance", components: { row[word] = wordOf(value); }); /** On the world's thread: every row's word as another, of the size of the first ticks', so their records are reused. */ -export const reword = System.create({ name: "tailsTest/reword", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [word] } }, (row, { input }) => { +export const reword = System.create({ name: "tailsTest/reword", components: { write: [word] } }, (row, _context, input: { readonly round: number }) => { row[word] = `r${input.round}`; }); /** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ @@ -87,16 +87,16 @@ export const grow = System.create({ name: "tailsTest/grow", resources: { read: [ resources[spawning] = 0; }); /** Spawns `count` rows. */ -export const seed = System.create({ name: "tailsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seed = System.create({ name: "tailsTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ name: "tailsTest/setStall", input: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { +export const setStall = System.create({ name: "tailsTest/setStall", resources: { write: [stall] } }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; }); -export const setSpawning = System.create({ name: "tailsTest/setSpawning", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [spawning] } }, ({ input, resources }) => { +export const setSpawning = System.create({ name: "tailsTest/setSpawning", resources: { write: [spawning] } }, ({ resources }, input: { readonly count: number }) => { resources[spawning] = input.count; }); -export const setFailAt = System.create({ name: "tailsTest/setFailAt", input: { type: "object", properties: { at: { type: "integer" } } }, resources: { write: [failAt] } }, ({ input, resources }) => { +export const setFailAt = System.create({ name: "tailsTest/setFailAt", resources: { write: [failAt] } }, ({ resources }, input: { readonly at: number }) => { resources[failAt] = input.at; }); diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts index a8364fe8..1a9a1c95 100644 --- a/packages/ecs/src/implementation/world/target.test.ts +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -14,16 +14,14 @@ declare module "@adobe/ecs" { Component.register(amount, { type: { type: "number" } }); Component.register(tagged, { type: { const: true } }); -const one = { type: "object", properties: { entity: Entity.schema } } as const; - let spawned: Entity[] = []; const spawn = System.create({ name: "targetTest/spawn" }, ({ commands }) => { spawned = [commands.spawn([amount, tagged])({ [amount]: 1 }), commands.spawn([amount, tagged])({ [amount]: 2 }), commands.spawn([amount])({ [amount]: 3 })]; }); -const bump = System.create({ name: "targetTest/bump", input: one, target: "entity", components: { write: [amount], has: [tagged] } }, row => { +const bump = System.create({ name: "targetTest/bump", target: "entity", components: { write: [amount], has: [tagged] } }, (row, _context, input: { readonly entity: Entity }) => { row[amount] += 10; }); -const remove = System.create({ name: "targetTest/remove", input: one, target: "entity", components: { has: [tagged] } }, (row, { commands }) => { +const remove = System.create({ name: "targetTest/remove", target: "entity", components: { has: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); let amounts: number[] = []; @@ -66,9 +64,9 @@ describe("systems on one entity", () => { it("take their entity from an entity field of their input, and have components, no kernel, up, order or reduce", () => { // @ts-expect-error its target isn't an entity field - expect(() => System.create({ name: "targetTest/notEntity", input: { type: "object", properties: { count: { type: "number" } } }, target: "count", components: { has: [amount] } }, () => {})) + expect(() => System.create({ name: "targetTest/notEntity", target: "count", components: { has: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) .toThrow('System "targetTest/notEntity" runs on one entity, so it takes it from an input field "count" holding an entity.'); // @ts-expect-error it needs components - expect(() => System.create({ name: "targetTest/noComponents", input: one, target: "entity" }, () => {})).toThrow('System "targetTest/noComponents" runs on one entity, so it needs components.'); + expect(() => System.create({ name: "targetTest/noComponents", target: "entity" }, (_context, input: { readonly entity: Entity }) => {})).toThrow('System "targetTest/noComponents" runs on one entity, so it needs components.'); }); }); diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 93451c1a..bd995cdd 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -67,7 +67,7 @@ Resource.register(nextUid, { type: { type: "integer", default: 1 } }); const makeNote = (text: string): Note => ({ text, read: () => text }); /** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ -export const seed = System.create({ name: "threadSystemsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [nextUid] } }, ({ input, resources, commands }) => { +export const seed = System.create({ name: "threadSystemsTest/seed", resources: { write: [nextUid] } }, ({ resources, commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); @@ -145,7 +145,7 @@ export const failing = System.create({ name: "threadSystemsTest/failing", system export const tick = System.create({ name: "threadSystemsTest/tick", systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); /** On the thread, with input, which comes to it by message. */ -export const stamp = System.create({ name: "threadSystemsTest/stamp", input: { type: "object", properties: { text: { type: "string" } } }, components: { read: [uid], write: [caption] } }, (row, { input }) => { +export const stamp = System.create({ name: "threadSystemsTest/stamp", components: { read: [uid], write: [caption] } }, (row, _context, input: { readonly text: string }) => { if (row[uid] === 1) row[caption] = { text: input.text }; }); @@ -256,7 +256,7 @@ export const scenario = async (world: World, objects: Objects = objectsThere(wor }; /** Spawns entities with captions, from the world's thread. */ -export const crowd = System.create({ name: "threadSystemsTest/crowd", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const crowd = System.create({ name: "threadSystemsTest/crowd" }, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([uid, caption])({ [uid]: 1000 + k }); }); /** On the world's thread: every crowd entity but uid 1000 moves archetype, flagged on one run and not on the next. */ diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index bcb38cce..e7cfd8df 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -607,7 +607,7 @@ type ThreadCell = { storage: ThreadStorage | undefined; row: number }; export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: ReadonlyMap = noRecords): ThreadRunner => { const { declaration } = leaf; // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. - const system: { body(subject: unknown, context?: unknown): void } = leaf; + const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; const numberOf = host.numberOf; const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(numberOf); @@ -712,14 +712,14 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon }); Object.defineProperty(value, "reduce", { value: reducers, enumerable: true }); } - if (declaration.input !== undefined) value.input = undefined; // A tail is terminal, so its context has no commands. - const tailValue: { input?: unknown } = own(); + const tailValue: object = own(); for (const name of Object.getOwnPropertyNames(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, name); - if (descriptor !== undefined && name !== "input") Object.defineProperty(tailValue, name, descriptor); + if (descriptor !== undefined) Object.defineProperty(tailValue, name, descriptor); } - if (declaration.input !== undefined) tailValue.input = undefined; + /** The current run's input, which the body takes after its context; kept until the next run, as its context is. */ + let runInput: unknown; Object.defineProperty(value, "commands", { value: host.commands, enumerable: true }); /** Resolves resource cells for a run: they move only at sync points. */ @@ -777,18 +777,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const runBody = (archetype: ThreadArchetype, count: number, since: number): void => { const row = bindRow(archetype); const subject = row.value; + const input = runInput; const filters = filtersOf(archetype); if (since < 0 || filters.length === 0) { for (let index = 0; index < count; index++) { row.index = index; - system.body(subject, value); + system.body(subject, value, input); } return; } forEachPassingRange(filters, since, 0, count, (start, end) => { for (let index = start; index < end; index++) { row.index = index; - system.body(subject, value); + system.body(subject, value, input); } }); }; @@ -812,10 +813,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const buffer = host.refresh(); for (let k = 0; k < threadIndexes.length; k++) threadIndexes[k].refresh(buffer); } - if (declaration.input !== undefined) value.input = ownInput(input); + runInput = ownInput(input); if (query === undefined) { outcome.ran = since < 0; - if (outcome.ran) system.body(value); + if (outcome.ran) system.body(value, runInput); return outcome; } if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; @@ -867,18 +868,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const runRows = (archetype: ThreadArchetype, start: number, end: number, since: number): void => { const row = bindRow(archetype); const subject = row.value; + const input = runInput; const filters = filtersOf(archetype); if (since < 0 || filters.length === 0) { for (let index = start; index < end && !halted; index++) { row.index = index; - system.body(subject, tailValue); + system.body(subject, tailValue, input); } return; } forEachPassingRange(filters, since, start, end, (first, last) => { for (let index = first; index < last && !halted; index++) { row.index = index; - system.body(subject, tailValue); + system.body(subject, tailValue, input); } }); }; @@ -954,10 +956,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon if ((flags & headExecuted) === 0) return; outcome.executed = true; outcome.ran = (flags & headRan) !== 0; - if (declaration.input !== undefined) tailValue.input = ownInput(input); + runInput = ownInput(input); if (!outcome.ran) return; if (query === undefined) { - system.body(tailValue); + system.body(tailValue, runInput); writeBackResources(ownResources, resourceArchetype, resourceRow, tick, at, stamps); return; } @@ -990,10 +992,11 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon while (first < rows && positions[first] < position) first++; const row = bindRow({ id: -1, entities, storageById, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); const subject = row.value; + const ownedInput = runInput; try { for (let index = first; index < rows && !halted; index++) { row.index = index; - system.body(subject, tailValue); + system.body(subject, tailValue, ownedInput); } } finally { @@ -1052,10 +1055,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const resourcesChanged = resourceChanged.length > 0 && resourcesChangedSince(lastRun); const since = !filtered || resourcesChanged ? -1 : lastRun; bind(); - if (declaration.input !== undefined) tailValue.input = ownInput(input); + runInput = ownInput(input); if (query === undefined) { outcome.ran = since < 0; - if (outcome.ran) system.body(tailValue); + if (outcome.ran) system.body(tailValue, runInput); return outcome; } if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 7b2cad3a..7ef52049 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -18,7 +18,7 @@ const count = System.create({ name: "ticksTest/count", resources: { write: [fram describe("ticks", () => { it("reject a system that conflicts with one added with no order between them, and one with input", async () => { const other = System.create({ name: "ticksTest/other", resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); - const withInput = System.create({ name: "ticksTest/withInput", input: { type: "object", properties: { by: { type: "number" } } } }, () => {}); + const withInput = System.create({ name: "ticksTest/withInput" }, (_context, input: { readonly by: number }) => {}); await using world = createWorld({ [workers]: 0 }); world.add(count); expect(() => world.add(other)).toThrow('both access "ticksTest/frames"'); diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts index ba9fad9b..7614da63 100644 --- a/packages/ecs/src/implementation/world/transfers.test-helper.ts +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -58,40 +58,40 @@ const store = (value: unknown, resources: { [order]: number }): void => { }; /** On "transfersTest": records the buffer it's given. */ -export const storeThere = System.create({ name: "transfersTest/storeThere", input: { type: "object", properties: { buffer: any } }, resources: { read: [anchor], write: [order] } }, ({ input, resources }) => { +export const storeThere = System.create({ name: "transfersTest/storeThere", resources: { read: [anchor], write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { if (resources[anchor].text === "anchor") store(input.buffer, resources); }); /** On the world's thread: records the buffer it's given. */ -export const storeHere = System.create({ name: "transfersTest/storeHere", input: { type: "object", properties: { buffer: any } }, resources: { write: [order] } }, ({ input, resources }) => { +export const storeHere = System.create({ name: "transfersTest/storeHere", resources: { write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { store(input.buffer, resources); }); /** On the world's thread: what a run from a joined thread saw of the order, by its value. */ export const marks: { readonly value: number; readonly order: number }[] = []; -export const mark = System.create({ name: "transfersTest/mark", input: { type: "object", properties: { value: { type: "number" } } }, resources: { write: [order] } }, ({ input, resources }) => { +export const mark = System.create({ name: "transfersTest/mark", resources: { write: [order] } }, ({ resources }, input: { readonly value: number }) => { marks.push({ value: input.value, order: resources[order]++ }); }); /** On "transfersTest": gives an entity the port or bitmap it's given. */ export const attachThere = System.create({ - name: "transfersTest/attachThere", input: { type: "object", properties: { entity: Entity.schema, value: any } }, resources: { read: [anchor] }, -}, ({ input, resources, commands }) => { + name: "transfersTest/attachThere", resources: { read: [anchor] }, +}, ({ resources, commands }, input: { readonly entity: Entity; readonly value: unknown }) => { if (resources[anchor].text !== "anchor") return; kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, port, input.value); else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmap, input.value); }); /** On the world's thread: gives an entity the port or bitmap it's given. */ -export const attachHere = System.create({ name: "transfersTest/attachHere", input: { type: "object", properties: { entity: Entity.schema, value: any } } }, ({ input, commands }) => { +export const attachHere = System.create({ name: "transfersTest/attachHere" }, ({ commands }, input: { readonly entity: Entity; readonly value: unknown }) => { kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, portHere, input.value); else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmapHere, input.value); }); /** On "transfersTest": says hello through the port it's given, which only moving it there, not copying it, gives. */ -export const greetThere = System.create({ name: "transfersTest/greetThere", input: { type: "object", properties: { port: any } }, resources: { read: [anchor] } }, ({ input, resources }) => { +export const greetThere = System.create({ name: "transfersTest/greetThere", resources: { read: [anchor] } }, ({ resources }, input: { readonly port: unknown }) => { if (input.port instanceof MessagePort) input.port.postMessage(resources[anchor].text); }); /** On the world's thread: spawns an entity holding the port it's given. */ -export const spawnHere = System.create({ name: "transfersTest/spawnHere", input: { type: "object", properties: { value: any } } }, ({ input, commands }) => { +export const spawnHere = System.create({ name: "transfersTest/spawnHere" }, ({ commands }, input: { readonly value: unknown }) => { if (input.value instanceof MessagePort) commands.spawn([portHere])({ [portHere]: input.value }); }); @@ -100,8 +100,8 @@ export const spawnHere = System.create({ name: "transfersTest/spawnHere", input: * "transfersTest", it fails to register, so a run of it never runs. */ export const unregistered = System.create({ - name: "transfersTest/unregistered", input: { type: "object", properties: { value: any } }, components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, -}, (row, { input, up }) => { + name: "transfersTest/unregistered", components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, +}, (row, { up }, input: { readonly value: unknown }) => { row[depth] = (up === null ? 0 : up[depth]) + (row[port] === input.value ? 1 : 0); }); diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index 0d4ebac7..27ff139d 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -90,7 +90,7 @@ const spawn = System.create({ name: "upTest/spawn" }, ({ commands }) => { entities.other = commands.spawn([label, local, world])({ [label]: "other", [local]: 1000, [world]: 0 }); }); const nest = System.create({ name: "upTest/nest" }, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); -const bump = System.create({ name: "upTest/bump", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { +const bump = System.create({ name: "upTest/bump" }, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, local, 2); }); const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { @@ -103,7 +103,7 @@ const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); /** Spawns `count` leaves under the grandchild, more than the journal keeps for a world this small. */ const bulk: Entity[] = []; -const bulkUnder = System.create({ name: "upTest/bulkUnder", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const bulkUnder = System.create({ name: "upTest/bulkUnder" }, ({ commands }, input: { readonly count: number }) => { bulk.length = 0; for (let i = 0; i < input.count; i++) bulk.push(commands.spawn([label, local, world, parent])({ [label]: "bulk", [local]: 1, [world]: 0, [parent]: entities.grandchild })); }); diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 6216563b..2837c543 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -50,7 +50,7 @@ Component.register(target, { type: Entity.schema }); Resource.register(seconds, { type: { type: "number", default: 1 / 60 } }); Resource.register(floor, { type: { type: "number", precision: 1, default: -2.5 } }); -const seed = System.create({ name: "wasmTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "wasmTest/seed" }, ({ commands }, input: { readonly count: number }) => { let previous = Entity.none; for (let i = 0; i < input.count; i++) { previous = commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: [i * 0.37, (i % 13) * 0.71], [velocity]: [Math.sin(i) * 3, Math.cos(i) * 5], [bounces]: 0, [flipped]: false, [score]: 0, [target]: previous }); @@ -58,7 +58,7 @@ const seed = System.create({ name: "wasmTest/seed", input: { type: "object", pro }); /** Despawns every seventh entity and spawns replacements, so some targets go stale and their indexes are reused. */ -const cull = System.create({ name: "wasmTest/cull", input: { type: "object", properties: { frame: { type: "integer" } } }, components: { read: [position], has: [bounces] } }, (row, { input, commands }) => { +const cull = System.create({ name: "wasmTest/cull", components: { read: [position], has: [bounces] } }, (row, { commands }, input: { readonly frame: number }) => { if ((row.entity >>> 0) % 7 !== input.frame % 7) return; commands.despawn(row.entity); commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: row[position], [velocity]: [1, 1], [bounces]: 0, [flipped]: true, [score]: 0, [target]: row.entity }); @@ -118,9 +118,8 @@ const tally = System.create({ const scale = System.create({ name: "wasmTest/scale", kernel: "cpu", - input: { type: "object", properties: { factor: { type: "number" } } }, components: { write: [score] }, -}, (row, { input }) => { +}, (row, _context, input: { readonly factor: number }) => { row[score] = row[score] * input.factor; }); diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index 0bceffef..fec596d0 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -81,7 +81,7 @@ const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => Resource.register(seconds, { type: { type: "number", default: 0.25 } }); /** Spawns `count` moving entities, each following the one before it. */ -const seed = System.create({ name: "workersTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "workersTest/seed" }, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn([position, velocity])({ [position]: [0, 0], [velocity]: [1, 0] }); for (let i = 1; i < input.count; i++) { previous = commands.spawn([position, velocity, target])({ [position]: [i, 0], [velocity]: [1, i % 7], [target]: previous }); @@ -132,9 +132,8 @@ const main = System.create({ name: "workersTest/main", systems: [integrate, spli const stamp = System.create({ name: "workersTest/stamp", kernel: "cpu", - input: { type: "object", properties: { entity: Entity.schema } }, components: { read: [position] }, -}, (row, { input, commands }) => { +}, (row, { commands }, input: { readonly entity: Entity }) => { const [x, y] = row[position]; commands.add(input.entity, offset, x * 1000 + y); }); @@ -160,9 +159,8 @@ const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => const emit = System.create({ name: "workersTest/emit", kernel: "cpu", - input: { type: "object", properties: { edit: { type: "boolean" } } }, components: { read: [offset], not: [spawned] }, -}, (row, { input, commands }) => { +}, (row, { commands }, input: { readonly edit: boolean }) => { const entity = commands.spawn([position, spawned, tagC])({ [position]: [row[offset], -row[offset]] }); if (input.edit) { commands.add(entity, velocity, [row[offset], 1]); diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index 7966ce0f..d1c50e39 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -107,9 +107,8 @@ export const separate = System.create({ // A unique lookup gives the row or null. export const applyDelta = System.create({ - input: { type: "object", properties: { id: { type: "number" }, hp: { type: "number" } } }, lookup: { known: { index: byServerId, read: [health] } }, -}, ({ input, lookup, commands }) => { +}, ({ lookup, commands }, input: { readonly id: number; readonly hp: number }) => { const known = lookup.known(input.id); check>(); if (known === null) commands.spawn([serverId, health])({ [serverId]: input.id, [health]: input.hp }); diff --git a/packages/ecs/src/interface/registry/schedule.ts b/packages/ecs/src/interface/registry/schedule.ts index 0127bcd1..0b012995 100644 --- a/packages/ecs/src/interface/registry/schedule.ts +++ b/packages/ecs/src/interface/registry/schedule.ts @@ -9,18 +9,14 @@ import { lookupKeysOf, orderKeysOf } from './indexes.js'; type Key = symbol; -/** Throws unless each leaf's input is an object schema of named fields, and a field several declare has one schema. */ +/** Throws unless a field of the input several leaves take has one schema. */ const checkInputs = (leaves: readonly Leaf[]): void => { const fields = new Map(); for (const leaf of leaves) { - const { input } = leaf.declaration; + const { input } = leaf; const name = labelOfItem(leaf); if (input === undefined) continue; - const schema: Schema = input; - if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { - throw new Error(`System "${name}": input is an object schema of named fields.`); - } - for (const [field, property] of Object.entries(schema.properties)) { + for (const [field, property] of Object.entries(input.properties)) { const seen = fields.get(field); if (seen === undefined) fields.set(field, { schema: property, system: name }); else if (!sameDeclaration(seen.schema, property)) { diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts index 4f89a7e1..135134d2 100644 --- a/packages/ecs/src/interface/system/body.ts +++ b/packages/ecs/src/interface/system/body.ts @@ -3,7 +3,6 @@ import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Resource } from '../resource/resource.js'; import type { Holding } from '../resource/condition.js'; -import type { Schema } from '../schema/schema.js'; import type { Commands } from './commands.js'; import type { Declaration } from './declaration.js'; import type { Listed, Property } from './property.js'; @@ -36,15 +35,18 @@ export type Context = /** Each resource it reduces into, as a function contributing a value to the run's fold. */ readonly reduce: { readonly [K in Reduced]: (value: Resource.Value) => void }; }) - & (Property extends Schema ? { readonly input: Schema.ToType> } : {}) & (Property extends object ? { /** The parent's `up.read` values, or null for a row without a parent. */ readonly up: { readonly [K in Listed, "read"> & Component.Key]: Component.Value } | null; } : {}) & (D extends { readonly kernel: "gpu" } | { readonly shader: object } | { readonly observer: true } ? {} : { readonly commands: Commands }); -/** `(row, context)` for a system with `components`, `(context)` for one without, and `(gpu, context)` for a render system. */ -export type Body = +/** + * `(row, context, input)` for a system with `components`, `(context, input)` for one without, and `(gpu, context)` for + * a render system. `input` is the value each run passes, of the type the body gives its parameter; a body without one + * takes none. + */ +export type Body = D extends { readonly shader: object } ? (gpu: RenderContext, context: Context) => void : - Property extends undefined ? (context: Context) => void : - (row: Row, context: Context) => void; + D extends { readonly components: object } ? (row: Row, context: Context, input: I) => void : + (context: Context, input: I) => void; diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts index fb94dca8..2267dbfa 100644 --- a/packages/ecs/src/interface/system/check.ts +++ b/packages/ecs/src/interface/system/check.ts @@ -11,7 +11,7 @@ import { labelOfItem } from '../registry/items.js'; import type { Schema } from '../schema/schema.js'; import type { Composite } from './composite.js'; import type { Definition } from './definition.js'; -import type { Leaf } from './leaf.js'; +import type { InputSchema, Leaf } from './leaf.js'; type Key = symbol; @@ -136,20 +136,19 @@ const checkObserver = (leaf: Leaf): void => { if (declaration.kernel !== undefined) fail("isn't a kernel"); if ((declaration.components?.write?.length ?? 0) + (declaration.resources?.write?.length ?? 0) > 0) fail("writes nothing"); if (reducedBy(leaf).length > 0) fail("reduces into nothing"); - if (declaration.input !== undefined) fail("takes no input"); + if (leaf.input !== undefined) fail("takes no input"); if (declaration.up !== undefined) fail("has no up"); if ((declaration.before?.length ?? 0) + (declaration.after?.length ?? 0) + (declaration.during?.length ?? 0) > 0) fail("is ordered with nothing"); }; /** * Throws for a leaf that names a component or resource that isn't registered as one, lists a key in both `read` and - * `write`, writes a reference, has a lookup reading a component it writes, takes input that isn't an object schema, - * uses object components and resources of different threads, declares what an observer system can't, or whose + * `write`, writes a reference, has a lookup reading a component it writes, uses object components and resources of different threads, declares what an observer system can't, or whose * reductions, conditions, `up` or placement can't hold. */ export const checkLeaf = (leaf: Leaf): void => { const { name, declaration } = leaf; - const { components, resources, lookup, input } = declaration; + const { components, resources, lookup } = declaration; checkLists(name, "components", components); checkLists(name, "resources", resources); checkWorldWritten(`System "${name}"`, resources?.write); @@ -166,12 +165,6 @@ export const checkLeaf = (leaf: Leaf): void => { for (const key of orderKeysOf(declaration)) { if (writes.has(key)) throw new Error(`System "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", which the system writes.`); } - if (input !== undefined) { - const schema: Schema = input; - if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { - throw new Error(`System "${name}": input is an object schema of named fields.`); - } - } checkTarget(leaf); threadOf(declaration, "System", name); requireRegistered("System", name, "component", components?.has); @@ -190,13 +183,31 @@ export const checkLeaf = (leaf: Leaf): void => { checkPlacement(leaf); }; +const isInputSchema = (value: unknown): value is InputSchema => + typeof value === "object" && value !== null && Reflect.get(value, "type") === "object" + && typeof Reflect.get(value, "properties") === "object" && Reflect.get(value, "properties") !== null; + +/** + * The schema of a body's input, which the plugin passes from the type of its parameter after the `before` before it. + * Throws for a body taking input without one, as one not built with the plugin does, and for a schema that isn't an + * object of named fields. + */ +export const inputSchemaOf = (name: string, body: (...parameters: never[]) => void, before: number, input: unknown): InputSchema | undefined => { + if (input === undefined) { + if (body.length > before) throw new Error(`System "${name}" takes input, whose schema the plugin gives from the type of its body's parameter: write its body in place and build it with the kernel plugin.`); + return undefined; + } + if (!isInputSchema(input)) throw new Error(`System "${name}": input is an object schema of named fields.`); + return input; +}; + /** * Throws if a system on one entity takes it from what isn't an entity field of its input, has no components, or is a * kernel, has `up` or `order`, or reduces. */ const checkTarget = (leaf: Leaf): void => { - const { name, declaration } = leaf; - const { target, input, components, kernel, up, order, reduce } = declaration; + const { name, declaration, input } = leaf; + const { target, components, kernel, up, order, reduce } = declaration; if (target === undefined) return; const fail = (message: string): never => { throw new Error(`System "${name}" runs on one entity, so it ${message}.`); }; const field: Schema | undefined = input !== undefined && "properties" in input ? Reflect.get(input.properties, target) : undefined; diff --git a/packages/ecs/src/interface/system/composite-input.type-test.ts b/packages/ecs/src/interface/system/composite-input.type-test.ts index edd7a682..a8d1ae94 100644 --- a/packages/ecs/src/interface/system/composite-input.type-test.ts +++ b/packages/ecs/src/interface/system/composite-input.type-test.ts @@ -5,22 +5,10 @@ import { System } from './system.js'; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; const check = <_T extends true>() => { }; -const move = System.create({ - name: "compositeInputTest/move", - input: { type: "object", properties: { distance: { type: "number" } } }, -}, ({ input }) => { void input.distance; }); -const step = System.create({ - name: "compositeInputTest/step", - input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, -}, ({ input }) => { void input.dt; }); -const pause = System.create({ - name: "compositeInputTest/pause", - input: { type: "object", properties: { dt: { type: "number" } }, required: [] }, -}, ({ input }) => { void input.dt; }); -const label = System.create({ - name: "compositeInputTest/label", - input: { type: "object", properties: { distance: { type: "string" } } }, -}, ({ input }) => { void input.distance; }); +const move = System.create({ name: "compositeInputTest/move" }, (_context, input: { readonly distance: number }) => { void input.distance; }); +const step = System.create({ name: "compositeInputTest/step" }, (_context, input: { readonly distance: number; readonly dt: number }) => { void input.dt; }); +const pause = System.create({ name: "compositeInputTest/pause" }, (_context, input: { readonly dt?: number }) => { void input.dt; }); +const label = System.create({ name: "compositeInputTest/label" }, (_context, input: { readonly distance: string }) => { void input.distance; }); const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); // A composite takes every field its members' inputs have; a field in several is one value for all of them. @@ -57,8 +45,3 @@ void world.run(frame, { distance: 1, dt: 0.5 }); // @ts-expect-error run takes every field void world.run(frame, { distance: 1 }); -System.create({ - name: "compositeInputTest/scalar", - // @ts-expect-error input is an object of named fields - input: { type: "number" }, -}, () => {}); diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 1f78e727..5f2971f9 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -51,6 +51,8 @@ Component.register(height, { type: { type: "number", precision: 1 } }); /** Creates as an untyped caller would, bypassing the types' checks. */ const create = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); +/** As the plugin calls it for a body taking input: with the schema of its input after the body. */ +const createTaking = (declaration: object, input: unknown): unknown => Reflect.apply(System.create, undefined, [declaration, (_context: unknown, _input: unknown) => {}, input]); describe("System.create", () => { it("records the system, its value holding its declaration and body", () => { @@ -106,7 +108,7 @@ describe("System.create", () => { expect(() => create({ name: "typesSystemTest/both", components: { read: [position], write: [position] } })).toThrow("in both components read and write"); expect(() => create({ name: "typesSystemTest/bothResources", resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); expect(() => create({ name: "typesSystemTest/lookup", components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); - expect(() => create({ name: "typesSystemTest/input", input: { type: "number" } })).toThrow("input is an object schema of named fields"); + expect(() => createTaking({ name: "typesSystemTest/input" }, { type: "number" })).toThrow("input is an object schema of named fields"); expect(labelOfItem(System.create({ name: "typesSystemTest/both" }, () => {}))).toBe("typesSystemTest/both"); }); it("rejects at creation what the registry rules out, for untyped callers", () => { @@ -132,13 +134,13 @@ describe("System.create", () => { [{ observer: true, components: { write: [position] } }, "is an observer, so it writes nothing"], [{ observer: true, resources: { write: [time] } }, "is an observer, so it writes nothing"], [{ observer: true, components: { has: [position] }, reduce: { [total]: "+" } }, "is an observer, so it reduces into nothing"], - [{ observer: true, input: { type: "object", properties: {} } }, "is an observer, so it takes no input"], [{ observer: true, kernel: "cpu", components: { read: [position] } }, "is an observer, so it isn't a kernel"], [{ observer: true, components: { read: [position] }, up: { through: parent } }, "is an observer, so it has no up"], [{ observer: true, resources: { read: [time] }, after: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], [{ observer: true, resources: { read: [time] }, before: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], [{ observer: true, resources: { read: [time] }, during: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], ]; + expect(() => createTaking({ name: "typesSystemTest/observing", observer: true }, { type: "object", properties: {} })).toThrow("is an observer, so it takes no input"); rejected.forEach(([declaration, message], index) => { expect(() => create({ name: `typesSystemTest/rejected/${index}`, ...declaration })).toThrow(message); // A rejected system records nothing, so its name labels only the next. @@ -203,8 +205,8 @@ describe("System.create of a composite", () => { }); it("rejects a member no constructor returned, and an input field two members declare differently", () => { expect(() => System.create({ name: "typesSystemTest/forged", systems: [{ name: "typesSystemTest/showMember" }] })).toThrow('member "typesSystemTest/showMember" isn\'t a system a System constructor returned'); - const move = System.create({ name: "typesSystemTest/moveBy", input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); - const tag = System.create({ name: "typesSystemTest/tagBy", input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); + const move = System.create({ name: "typesSystemTest/moveBy" }, (_context, input: { readonly distance: number }) => {}); + const tag = System.create({ name: "typesSystemTest/tagBy" }, (_context, input: { readonly distance: string }) => {}); const nested = System.create({ name: "typesSystemTest/nested", systems: [tag] }); const untyped: (...parameters: never[]) => unknown = System.create; const composite = (declaration: object): unknown => Reflect.apply(untyped, undefined, [declaration]); diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 3160273a..61c71e43 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -6,10 +6,9 @@ import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; import type { Resource } from '../resource/resource.js'; -import type { Schema } from '../schema/schema.js'; import type { Body } from './body.js'; import type { Commands } from './commands.js'; -import { checkComposite, checkLeaf } from './check.js'; +import { checkComposite, checkLeaf, inputSchemaOf } from './check.js'; import type { Composite } from './composite.js'; import type { CompositeDeclaration } from './composite-declaration.js'; import type { Declaration } from './declaration.js'; @@ -82,27 +81,22 @@ type GpuKernel = { }; /** - * An observer system only reads, after a sync point: it writes and reduces into nothing, takes no input, isn't a - * kernel, has no `up`, and is ordered with nothing. + * An observer system only reads, after a sync point: it writes and reduces into nothing, isn't a kernel, has no `up`, + * and is ordered with nothing. That it takes no input is checked at its body. */ type Observer = { readonly kernel?: never; readonly components?: { readonly write?: never }; readonly resources?: { readonly write?: never }; readonly reduce?: never; - readonly input?: never; readonly up?: never; readonly before?: never; readonly after?: never; readonly during?: never; }; -/** The fields of input `I` that hold an entity. */ -type EntityFields = { [K in keyof I]-?: [Exclude] extends [Entity] ? K : never }[keyof I] & string; - -/** A system on one entity takes it from an entity field of its input, and runs on its row, in no order. */ -type Targeted = { - readonly target?: EntityFields>; +/** A system on one entity runs on its row, in no order; that its target is an entity field of its input is checked at its body. */ +type Targeted = { readonly kernel?: never; readonly up?: never; readonly order?: never; @@ -113,16 +107,34 @@ type Validate = Lists & IndexLookups & Order - & (D extends { readonly input: Schema } ? { readonly components?: { readonly added?: never; readonly changed?: never } } : {}) & (D extends { readonly kernel: "cpu" | "gpu" } ? Kernel : {}) & (D extends { readonly kernel: "gpu" } ? GpuKernel : {}) // `up` and `order` order rows, so a system without them has neither, and one has at most one of them. & (Property extends undefined ? { readonly up?: { readonly [key: string]: never }; readonly order?: { readonly "error: a system without components runs once, in no order": true } } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) & (D extends { readonly observer: true } ? Observer : {}) - & (D extends { readonly target: string } ? Targeted : {}) + & (D extends { readonly target: string } ? Targeted : {}) & (D extends { readonly target: string } ? Property extends undefined ? { readonly target?: { readonly "error: a system on one entity runs on its row, so it needs components": true } } : {} : {}) - & { readonly shader?: never }; + // A body's input is the type of its parameter after its context, not part of its declaration. + & { readonly shader?: never; readonly input?: never }; + +/** The fields of input `I` that hold an entity. */ +type EntityFields = { [K in keyof I]-?: [Exclude] extends [Entity] ? K : never }[keyof I] & string; + +/** + * What a declaration requires of the input its body takes, checked at the body, since the input is known only once the + * body is: a target names an entity field of it, and a system with input doesn't filter on added or changed, as its + * runs pass different input, nor is it an observer. Every check passes for `never`, the input before the body is + * inferred. + */ +type Checks = [I] extends [never] ? unknown : + (D extends { readonly target: infer T extends string } + ? [I] extends [undefined] ? { readonly "error: a system with a target takes its input after its context": T } + : T extends EntityFields ? unknown : { readonly "error: its target is no entity field of its input": T } + : unknown) + & ([I] extends [undefined] ? unknown + : (D extends { readonly components: { readonly added: object } | { readonly changed: object } } ? { readonly "error: a system with input can't filter on added or changed, as its runs pass different input; pass it through a resource": true } : unknown) + & (D extends { readonly observer: true } ? { readonly "error: an observer system takes no input": true } : unknown)); /** The body of a system that declares nothing, which runs once per run with its commands. */ type Alone = (context: { readonly commands: Commands }) => void; @@ -155,9 +167,10 @@ type Checked = C extends Alone ? unknown : C extends CompositeDeclaration ? { * One argument, a body or a composite, is one overload, so a mistake in it is reported where it is rather than as no * overload matching. */ -export function create(declaration: D & Validate & OneThread, ThreadedBy>, body: Body): System>; +export function create>(declaration: D & Validate & OneThread, ThreadedBy>, body: B & Checks>): System>; export function create(only: C & Checked): C extends Alone ? System : System>>; -export function create(first: Declaration | Leaf["body"] | readonly System[] | CompositeDeclaration, second?: Leaf["body"]): System { +// The plugin passes the schema of the body's input as a third argument. +export function create(first: Declaration | Leaf["body"] | readonly System[] | CompositeDeclaration, second?: Leaf["body"], input?: unknown): System { // The plugin gives its id to the call's first argument: the declaration, the members, or the body made into a // system alone. const id = takeIdOfDeclaration(first); @@ -166,7 +179,9 @@ export function create(first: Declaration | Leaf["body"] | readonly System = { kind: "system", name: nameOf(declaration.name, id, "system"), declaration, body }; + const name = nameOf(declaration.name, id, "system"); + const schema = inputSchemaOf(name, body, declaration.components === undefined ? 1 : 2, input); + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body }; checkLeaf(system); claimSystem(system, id); return system; diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 1fdb39b2..362f49e2 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Index } from '../indexes/index-type.js'; import type { Resource } from '../resource/resource.js'; -import type { Schema } from '../schema/schema.js'; import type { ComponentAccess, ResourceAccess, SystemLookup, Up } from './access.js'; import type { Name } from './name.js'; import type { Shader } from './render.js'; @@ -42,12 +41,10 @@ export type Declaration = { readonly resources?: ResourceAccess; /** The resources its body contributes to, each with the operator its `reduce` trait declares. */ readonly reduce?: { readonly [K in Reduced]?: OperatorOf }; - /** The object of named fields passed to each run. A system with input can't use `added` or `changed`. */ - readonly input?: Extract; /** - * The input field naming the one entity it runs on: its body runs once, on that entity's row, when the entity is - * alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, `up`, `order` - * or `reduce`. + * The field of its body's input naming the one entity it runs on: its body runs once, on that entity's row, when + * the entity is alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, + * `up`, `order` or `reduce`. */ readonly target?: string; /** Makes this a render system (see `render`): its body records GPU work over the GPU copy of the components it names. */ diff --git a/packages/ecs/src/interface/system/input.ts b/packages/ecs/src/interface/system/input.ts index 9f15c759..c064e3fb 100644 --- a/packages/ecs/src/interface/system/input.ts +++ b/packages/ecs/src/interface/system/input.ts @@ -1,12 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema } from '../schema/schema.js'; import type { System } from './system.js'; -/** - * The input a system declares: a value of its `input` schema, or `undefined` without one; `unknown` for `any`, as an - * overload's declaration erases to, since every schema's value type is too deep to compute. - */ -export type InputOf = 0 extends 1 & D ? unknown : D extends { readonly input: infer A extends Schema } ? Schema.ToType : undefined; +/** How many parameters come before a body's input: the row and the context, or the context alone. */ +type Before = D extends { readonly components: object } ? 2 : 1; + +/** The input body `B` of declaration `D` takes: the type of its parameter after the context, or `undefined` without one. */ +export type InputOf = B extends (...parameters: infer P) => void ? P["length"] extends Before ? undefined : P[Before] : undefined; /** What `run` takes after a system: nothing, or one value. */ export type Input = [I] extends [undefined] ? [] : [input: I]; diff --git a/packages/ecs/src/interface/system/leaf.ts b/packages/ecs/src/interface/system/leaf.ts index c8b7b0f4..627fc38a 100644 --- a/packages/ecs/src/interface/system/leaf.ts +++ b/packages/ecs/src/interface/system/leaf.ts @@ -1,14 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from '../schema/schema.js'; import type { Declaration } from './declaration.js'; import type { Name } from './name.js'; +/** The schema of a body's input: an object of named fields. */ +export type InputSchema = Extract; + /** * A system that runs a body: `"render"` for one made by `render`, `"system"` otherwise. Its body's parameters are - * checked where it's created, so a leaf holds any body. + * checked where it's created, so a leaf holds any body. `input` is the schema of its body's input, which the plugin + * gives from the type of the body's parameter after its context, or undefined; every leaf has it, so leaves share a shape. */ export type Leaf = { readonly kind: "system" | "render"; readonly name: Name; readonly declaration: Declaration; + readonly input: InputSchema | undefined; readonly body: (...parameters: never[]) => void; }; diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index c0e82eb7..d99130ff 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -86,6 +86,7 @@ export const render = ( components: { ...declaration.components, read: [...new Set(Object.values(declaration.shader.accessors))] }, resources: { ...declaration.resources, is: [...new Set([...declaration.resources?.is ?? [], gpuDevice])] }, }, + input: undefined, body, }; checkLeaf(system); diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 2e00dbc8..554d86aa 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -112,16 +112,25 @@ System.create({ lookup: { targets: { read: [position], changed: [velocity] } }, }, () => {}); -// Input: typed from its schema. +// Input: the type of the body's parameter after its context. export const move = System.create({ name: "test/move", - input: { type: "object", properties: { distance: { type: "number" } } }, components: { has: [player], write: [position] }, -}, (row, { input }) => { +}, (row, _context, input: { readonly distance: number }) => { row[position] += input.distance; - // @ts-expect-error input has its schema's type + // @ts-expect-error input has the type the body gives it void input.missing; }); +// The row and the context are still typed by the declaration. +System.create({ components: { read: [velocity], write: [position] }, resources: { read: [time] } }, (row, { resources }, input: { readonly by: number }) => { + row[position] += row[velocity] * resources[time] * input.by; + // @ts-expect-error a read component is read-only + row[velocity] = input.by; + // @ts-expect-error score isn't a declared resource + void resources[score]; +}); +// @ts-expect-error input is the body's parameter, not part of its declaration +System.create({ input: { type: "object", properties: {} } }, () => {}); // A system without components takes only its context; resource writes are writable, and written ones readable. export const tally = System.create({ @@ -145,13 +154,14 @@ System.create({ name: "test/tally" }, context => { void context.lookup; // @ts-expect-error no reductions declared void context.reduce; - // @ts-expect-error no input declared + // @ts-expect-error input isn't in the context void context.input; void context.commands; }); -// @ts-expect-error a system without components has no row -System.create({ name: "test/tally" }, (row: object, context: object) => { void [row, context]; }); +// A system without components has no row: its second parameter is its input. +check>>(); +const alone = System.create({ name: "test/tally" }, (_context, input: { readonly count: number }) => { void input.count; }); System.create({ name: "test/tally", @@ -165,12 +175,11 @@ System.create({ after: ["test/unregistered"], }, () => {}); -System.create({ - name: "test/move", - input: { type: "object", properties: { distance: { type: "number" } } }, - // @ts-expect-error a system with input can't use change filters - components: { changed: [position] }, -}, () => {}); +// @ts-expect-error a system with input can't use change filters +System.create({ name: "test/move", components: { changed: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +// @ts-expect-error a system with input can't use change filters +System.create({ name: "test/move", components: { added: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +System.create({ name: "test/move", components: { changed: [position] } }, row => { void row; }); export const main = System.create({ name: "test/main", systems: [integrate, follow, tally] }); const moving = System.create({ name: "test/moving", systems: [move] }); @@ -419,7 +428,7 @@ export const unnamed = System.create({ components: { write: [position] }, resour row[position] += resources[time]; }); check>(); -export const unnamedInput = System.create({ input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input }) => { +export const unnamedInput = System.create({}, (_context, input: { readonly count: number }) => { const count: number = input.count; void count; }); @@ -465,7 +474,7 @@ System.create({ observer: true, resources: { write: [score] } }, () => {}); // @ts-expect-error an observer system reduces into nothing System.create({ observer: true, components: { read: [velocity] }, reduce: { [total]: "+" } }, () => {}); // @ts-expect-error an observer system takes no input -System.create({ observer: true, input: { type: "object", properties: { count: { type: "integer" } } } }, () => {}); +System.create({ observer: true, resources: { read: [time] } }, (_context, input: { readonly count: number }) => { void input; }); // @ts-expect-error an observer system isn't a kernel System.create({ observer: true, kernel: "cpu", components: { read: [position] } }, () => {}); // @ts-expect-error an observer system is ordered with nothing @@ -476,13 +485,16 @@ System.create({ observer: true, resources: { read: [time] }, before: [integrate] System.create({ observer: true, resources: { read: [time] }, during: [integrate] }, () => {}); // A system on one entity: its input names the entity, and its body runs on that entity's row. -const onOne = { type: "object", properties: { entity: Entity.schema, by: { type: "number" } } } as const; -export const nudge = System.create({ input: onOne, target: "entity", components: { write: [position] } }, (row, { input }) => { +type OnOne = { readonly entity: Entity; readonly by: number }; +export const nudge = System.create({ target: "entity", components: { write: [position] } }, (row, _context, input: OnOne) => { row[position] += input.by; }); +check>>(); // @ts-expect-error its target is an entity field of its input -System.create({ input: onOne, target: "by", components: { write: [position] } }, () => {}); +System.create({ target: "by", components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); +// @ts-expect-error it takes its target from its input, so it takes input +System.create({ target: "entity", components: { write: [position] } }, row => { void row; }); // @ts-expect-error it runs on a row, so it needs components -System.create({ input: onOne, target: "entity" }, () => {}); +System.create({ target: "entity" }, (_context, input: OnOne) => { void input; }); // @ts-expect-error not a kernel -System.create({ input: onOne, target: "entity", kernel: "cpu", components: { write: [position] } }, () => {}); +System.create({ target: "entity", kernel: "cpu", components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts index 26382f7c..d9e09768 100644 --- a/packages/ecs/src/perftest/flat.ts +++ b/packages/ecs/src/perftest/flat.ts @@ -53,14 +53,14 @@ const archetypes = [ ] as const; /** Spawns with values passed inline. */ -export const spawnFlat = System.create({ name: "flat/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnFlat = System.create({ name: "flat/spawn" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(archetypes[i % 4])({ [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }); } }); /** The same spawns with each value object built first, as values from data or from another system's results are. */ -export const spawnFlatDynamic = System.create({ name: "flat/dynamic", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnFlatDynamic = System.create({ name: "flat/dynamic" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const values = { [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }; commands.spawn(archetypes[i % 4])(values); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 27b64400..2db886f4 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -41,7 +41,7 @@ const tags = [g0, g1, g2, g3, g4, g5, g6] as const; const hundred = Array.from({ length: 100 }, (_, bits) => Archetype.create([value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); /** Spawns `count` entities into each of the hundred archetypes. */ -export const fillEach = System.create({ name: "growth/fillEach", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const fillEach = System.create({ name: "growth/fillEach" }, ({ commands }, input: { readonly count: number }) => { for (const archetype of hundred) for (let i = 0; i < input.count; i++) commands.spawn(archetype)({ [value]: i }); }); diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 60d97a84..68dffdda 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -50,8 +50,7 @@ export const propagateChanged = System.create({ /** `roots` chains of `depth` rows each. */ export const forest = System.create({ name: "hierarchy/forest", - input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(root)({ [local]: 1, [world]: 0 }); for (let d = 1; d < input.depth; d++) previous = commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: previous }); @@ -61,12 +60,12 @@ export const forest = System.create({ const target = { type: "object", properties: { entity: Entity.schema } } as const; /** Changes one row's own value. */ -export const nudge = System.create({ name: "hierarchy/nudge", input: target, components: { write: [local] } }, (row, { input }) => { +export const nudge = System.create({ name: "hierarchy/nudge", components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { if (row.entity === input.entity) row[local] += 1; }); /** Adds one leaf under a row. */ -export const sprout = System.create({ name: "hierarchy/sprout", input: target }, ({ input, commands }) => { +export const sprout = System.create({ name: "hierarchy/sprout" }, ({ commands }, input: { readonly entity: Entity }) => { commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: input.entity }); }); @@ -77,8 +76,7 @@ const leaves: Entity[] = []; /** Despawns the leaves it spawned last time and spawns `count` new ones, one under each of `under`'s first rows; `tagged` puts them in an archetype of their own. */ export const turnover = System.create({ name: "hierarchy/turnover", - input: { type: "object", properties: { count: { type: "integer" }, tagged: { type: "boolean" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly count: number; readonly tagged: boolean }) => { for (const entity of leaves.splice(0)) commands.despawn(entity); for (let i = 0; i < input.count; i++) { leaves.push(input.tagged @@ -90,8 +88,7 @@ export const turnover = System.create({ /** Moves a row, and the subtree under it, to another parent. */ export const reparent = System.create({ name: "hierarchy/reparent", - input: { type: "object", properties: { entity: Entity.schema, to: Entity.schema } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly entity: Entity; readonly to: Entity }) => { commands.add(input.entity, parent, input.to); }); @@ -124,8 +121,7 @@ export const place = System.create({ /** `forest` in 4-byte values. */ export const gpuForest = System.create({ name: "hierarchy/gpuForest", - input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(gpuRoot)({ [offset]: 1, [placed]: 0 }); for (let d = 1; d < input.depth; d++) previous = commands.spawn(gpuChild)({ [offset]: 1, [placed]: 0, [parent]: previous }); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index 20f38444..5318bc6d 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -43,7 +43,7 @@ const million = 1_000_000; const keys = million / 10; const byKey = Index.create({ name: "indexes/byKey", key: [key] }); -const seedKeys = System.create({ name: "indexes/seedKeys", input: { type: "object", properties: { count: { type: "integer" }, marked: { type: "integer" } } } }, ({ input, commands }) => { +const seedKeys = System.create({ name: "indexes/seedKeys" }, ({ commands }, input: { readonly count: number; readonly marked: number }) => { for (let i = 0; i < input.count; i++) { if (i < input.marked) commands.spawn([key, amount, marked])({ [key]: (i * 7919) % keys, [amount]: i, [marked]: true }); else commands.spawn([key, amount])({ [key]: (i * 7919) % keys, [amount]: i }); diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 8f02842f..84d48620 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -35,7 +35,7 @@ Component.register(marked, { type: { const: true } }); Component.register(unmatched, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "observed/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "observed/seed" }, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 3eb23ad2..7c15f501 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -43,7 +43,7 @@ const archetypes = [ Archetype.create([color, mass, position, velocity]), ] as const; -export const spawn = System.create({ name: "perf/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawn = System.create({ name: "perf/spawn" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const p = [i + 1, i + 1, i + 1] as const; const v = [-i, -i, -i] as const; diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index 94653341..feb05839 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -32,7 +32,7 @@ Component.register(rank, { type: { type: "number" } }); Component.register(marked, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "reads/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "reads/seed" }, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index e3b64549..004837a2 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -43,7 +43,7 @@ Component.register(amount, { type: { type: "number" } }); export const empty = System.create({ name: "remote/empty" }, () => {}); /** Changes the observed resource every tick. */ export const count = System.create({ name: "remote/count", resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); -export const seedRanks = System.create({ name: "remote/seedRanks", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seedRanks = System.create({ name: "remote/seedRanks" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); const flip = System.create({ name: "remote/flip", resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); @@ -92,7 +92,7 @@ export const observerSelect = System.create({ name: "remote/observerSelect", obs }); /** Rows with a caption and an amount, a todo's label and a number, which `retext` and `reamount` rewrite 1% of a tick. */ -export const seedCaptions = System.create({ name: "remote/seedCaptions", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seedCaptions = System.create({ name: "remote/seedCaptions" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, caption, amount])({ [rank]: i, [caption]: `Buy milk and bread #${i}`, [amount]: i }); }); const recaption = System.create({ name: "remote/recaption", components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts index 204777cd..8e311f7f 100644 --- a/packages/ecs/src/perftest/render-order.ts +++ b/packages/ecs/src/perftest/render-order.ts @@ -39,9 +39,8 @@ const spawn = System.create({ name: "renderOrder/spawn" }, ({ commands }) => { const restack = System.create({ name: "renderOrder/restack", components: { write: [paintDepth] }, - input: { type: "object", properties: { item: Entity.schema, depth: { type: "number" } } }, target: "item", -}, (row, { input }) => { +}, (row, _context, input: { readonly item: Entity; readonly depth: number }) => { row[paintDepth] = input.depth; }); /** Draws nothing: its run uploads what it reads and its order. */ diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts index 0873fcbd..46f73686 100644 --- a/packages/ecs/src/perftest/strings.ts +++ b/packages/ecs/src/perftest/strings.ts @@ -26,7 +26,7 @@ const rows = 10_000; /** A todo's label, of a typical length, different per row and round. */ const labelOf = (row: number, round: number): string => `Buy milk and bread #${row}.${round}`; -const seed = System.create({ name: "strings/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "strings/seed" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([text, boxed])({ [text]: labelOf(i, 0), [boxed]: { text: labelOf(i, 0) } }); }); diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index ae187419..1c2d423e 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -67,15 +67,14 @@ const counted = { type: "object", properties: { count: { type: "integer" } } } a /** `count` entities of one archetype, or with `sources: 4`, interleaved over four by two source tags. */ export const seed = System.create({ name: "structural/seed", - input: { type: "object", properties: { count: { type: "integer" }, sources: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly count: number; readonly sources: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(seeded[input.sources === 1 ? 0 : i % 4])({ [index]: i, [value]: i }); } }); /** `count` spawns of one archetype. */ -export const spawnBulk = System.create({ name: "structural/spawnBulk", input: counted }, ({ input, commands }) => { +export const spawnBulk = System.create({ name: "structural/spawnBulk" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(made)({ [index]: i, [value]: i }); }); @@ -88,7 +87,7 @@ export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ( }); /** `count` spawns over sixteen archetypes, entity i into archetype i % 16: more component sets than spawns cache. */ -export const spawnSixteen = System.create({ name: "structural/spawnSixteen", input: counted }, ({ input, commands }) => { +export const spawnSixteen = System.create({ name: "structural/spawnSixteen" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(sixteen[i % 16])({ [index]: i, [value]: i }); } diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 62f10769..631588b9 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -3,6 +3,7 @@ import { Archetype } from "../interface/archetype/archetype.js"; import { Component } from "../interface/component/component.js"; import { Resource } from "../interface/resource/resource.js"; import { Entity } from "../interface/entity/entity.js"; +import type { I32 } from "../interface/math/i32/index.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -56,7 +57,7 @@ const moving = Archetype.create([position, velocity, index]); const following = Archetype.create([position, velocity, index, target]); /** `count` entities, each targeting the one spawned before it. */ -export const seed = System.create({ name: "suite/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seed = System.create({ name: "suite/seed" }, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); for (let i = 1; i < input.count; i++) { previous = commands.spawn(following)({ [position]: [i, i * 0.5, -i], [velocity]: [1, 0, 0], [index]: i, [target]: previous }); @@ -64,14 +65,14 @@ export const seed = System.create({ name: "suite/seed", input: { type: "object", }); /** Despawns a tenth of the entities and spawns as many replacements. */ -export const churn = System.create({ name: "suite/churn", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index, position] } }, (row, { input, commands }) => { +export const churn = System.create({ name: "suite/churn", components: { read: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { if (row[index] % 10 !== input.group) return; commands.despawn(row.entity); commands.spawn(moving)({ [position]: row[position], [velocity]: [0, 0, 0], [index]: row[index] }); }); /** Adds the tag to one tenth and removes it from another: a fifth of the entities move archetype. */ -export const retag = System.create({ name: "suite/retag", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index] } }, (row, { input, commands }) => { +export const retag = System.create({ name: "suite/retag", components: { read: [index] } }, (row, { commands }, input: { readonly group: number }) => { const group = row[index] % 10; if (group === input.group) commands.add(row.entity, tag); else if (group === (input.group + 5) % 10) commands.remove(row.entity, tag); @@ -81,9 +82,8 @@ export const retag = System.create({ name: "suite/retag", input: { type: "object export const poke = System.create({ name: "suite/poke", kernel: "cpu", - input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index], write: [position] }, -}, (row, { input }) => { +}, (row, _context, input: { readonly group: I32 }) => { if (row[index] % 100 === input.group) { const [x, y, z] = row[position]; row[position] = [x + 1, y, z]; @@ -94,9 +94,8 @@ export const poke = System.create({ export const pokeCluster = System.create({ name: "suite/pokeCluster", kernel: "cpu", - input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index], write: [position] }, -}, (row, { input }) => { +}, (row, _context, input: { readonly group: I32 }) => { const group = row[index] - input.group * 10_000; if (group >= 0 && group < 10_000) { const [x, y, z] = row[position]; diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts index 1abfc5c0..0594df18 100644 --- a/packages/ecs/src/perftest/transfers.ts +++ b/packages/ecs/src/perftest/transfers.ts @@ -23,19 +23,13 @@ Resource.register(sink, { thread: "transfersBenchmark" }); /** Bytes `take` took, on the claimed thread. */ export const taken = { bytes: 0 }; -/** - * On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. Its input's fields take - * any value, `{ allOf: [] }`, typed `unknown`, which it narrows. - */ +/** On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. */ export const take = System.create({ - name: "transfers/take", input: { type: "object", properties: { buffer: { allOf: [] }, bitmap: { allOf: [] } } }, resources: { read: [sink] }, -}, ({ input, resources }) => { + name: "transfers/take", resources: { read: [sink] }, +}, ({ resources }, input: { readonly buffer: ArrayBuffer; readonly bitmap: ImageBitmap }) => { if (resources[sink] !== null) return; - if (input.buffer instanceof ArrayBuffer) taken.bytes += input.buffer.byteLength; - if (typeof ImageBitmap === "function" && input.bitmap instanceof ImageBitmap) { - taken.bytes += input.bitmap.width * input.bitmap.height * 4; - input.bitmap.close(); - } + taken.bytes += input.buffer.byteLength + input.bitmap.width * input.bitmap.height * 4; + input.bitmap.close(); }); /** Every system the joined threads run, which they import so the world can place them there. */ diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 4ed04278..564c3d33 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -26,9 +26,8 @@ const spawn = System.create({ name: "uploads/spawn" }, ({ commands }) => { const drag = System.create({ name: "uploads/drag", components: { write: [place] }, - input: { type: "object", properties: { shape: Entity.schema, x: { type: "number" } } }, target: "shape", -}, (row, { input }) => { +}, (row, _context, input: { readonly shape: Entity; readonly x: number }) => { row[place] = [input.x, 1, 0]; }); /** Draws nothing: its run uploads what it reads. */ diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 8555da41..0ed06a7e 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -36,7 +36,7 @@ const Node = Archetype.create([pose, shown, extent, order]); const rows = 100_000; -const seed = System.create({ name: "values/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({ name: "values/seed" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(Node)({ [pose]: { tx: i, ty: 0, rotation: 0, sx: 1, sy: 1 }, [shown]: { tx: 0, ty: 0, rotation: 0, sx: 1, sy: 1 }, [extent]: [1, 1], [order]: i }); }); diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 27f71235..01d6f315 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -49,8 +49,7 @@ const random = (seed: number): (() => number) => { /** The particle count, and the field's width and height in pixels. Entity i goes to archetype i % 4. */ export const spawn = System.create({ name: "gpuParticles/spawn", - input: { type: "object", properties: { count: { type: "integer" }, width: { type: "number" }, height: { type: "number" } } }, -}, ({ input: { count, width, height }, commands }) => { +}, ({ commands }, { count, width, height }: { readonly count: number; readonly width: number; readonly height: number }) => { const next = random(7); for (let i = 0; i < count; i++) { const p = [next() * width, next() * height, 0] as const; diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index f927712e..0bd4ece2 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -59,9 +59,8 @@ export const integrate = System.create({ // Transaction carrying frame time into the ECS. export const setDelta = System.create({ name: "particles/setDelta", - input: { type: "object", properties: { seconds: { type: "number" } } }, resources: { write: [delta] }, -}, ({ resources, input }) => { +}, ({ resources }, input: { readonly seconds: number }) => { resources[delta] = input.seconds; }); diff --git a/packages/ecs/src/samples/todo/features/completion/model.ts b/packages/ecs/src/samples/todo/features/completion/model.ts index 68299fbc..8bdd164d 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.ts @@ -6,9 +6,8 @@ import { byDone } from "./data.js"; /** Marks every todo done, or every one not done. Only todos that change are written. */ export const toggleAll = System.create({ name: "completion/toggleAll", - input: { type: "object", properties: { done: { type: "boolean" } } }, components: { write: [done], has: [todo] }, -}, (row, { input }) => { +}, (row, _context, input: { readonly done: boolean }) => { if (row[done] !== input.done) row[done] = input.done; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.ts b/packages/ecs/src/samples/todo/features/filter/model.ts index 0c3305b0..d6144bc9 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.ts @@ -1,11 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { System } from "../../../../index.js"; -import { filter, filterSchema } from "./data.js"; +import { filter, type Filter } from "./data.js"; export const setFilter = System.create({ name: "filter/set", - input: { type: "object", properties: { filter: filterSchema } }, resources: { write: [filter] }, -}, ({ input, resources }) => { +}, ({ resources }, input: { readonly filter: Filter }) => { resources[filter] = input.filter; }); diff --git a/packages/ecs/src/samples/todo/features/generate/model.ts b/packages/ecs/src/samples/todo/features/generate/model.ts index d0ef7b1e..d938e0b1 100644 --- a/packages/ecs/src/samples/todo/features/generate/model.ts +++ b/packages/ecs/src/samples/todo/features/generate/model.ts @@ -10,9 +10,8 @@ export const generatedLabel = (n: number): string => `${tasks[n % tasks.length]} /** Adds `count` todos with generated labels at the end of the list. */ export const addGenerated = System.create({ name: "generate/add", - input: { type: "object", properties: { count: { type: "integer", minimum: 0 } } }, lookup: { ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { +}, ({ lookup, commands }, input: { readonly count: number }) => { const all = lookup.ordered(); const first = orderAfter(all); const spawn = commands.spawn(Todo); diff --git a/packages/ecs/src/samples/todo/features/todos/model.ts b/packages/ecs/src/samples/todo/features/todos/model.ts index df8ef70c..6dcfa1e2 100644 --- a/packages/ecs/src/samples/todo/features/todos/model.ts +++ b/packages/ecs/src/samples/todo/features/todos/model.ts @@ -2,37 +2,33 @@ import { Entity, System } from "../../../../index.js"; import { Todo, done, label, order, orderAfter, ordered, todo } from "./data.js"; -const text = { type: "string" } as const; - /** Adds a todo at the end of the list, with its label trimmed; a blank label adds nothing. */ export const addTodo = System.create({ name: "todos/add", - input: { type: "object", properties: { label: text } }, lookup: { ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { +}, ({ lookup, commands }, input: { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") return; commands.spawn(Todo)({ [label]: trimmed, [done]: false, [order]: orderAfter(lookup.ordered()) }); }); -const one = { entity: Entity.schema } as const; +/** The todo a system on one acts on. */ +type One = { readonly entity: Entity }; export const toggleTodo = System.create({ name: "todos/toggle", - input: { type: "object", properties: one }, target: "entity", components: { write: [done], has: [todo] }, -}, row => { +}, (row, _context, _input: One) => { row[done] = !row[done]; }); /** Gives a todo a new label, trimmed; a blank one deletes it. */ export const editTodo = System.create({ name: "todos/edit", - input: { type: "object", properties: { ...one, label: text } }, target: "entity", components: { write: [label], has: [todo] }, -}, (row, { input, commands }) => { +}, (row, { commands }, input: One & { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") commands.despawn(row.entity); else row[label] = trimmed; @@ -40,10 +36,9 @@ export const editTodo = System.create({ export const deleteTodo = System.create({ name: "todos/delete", - input: { type: "object", properties: one }, target: "entity", components: { has: [todo] }, -}, (row, { commands }) => { +}, (row, { commands }, _input: One) => { commands.despawn(row.entity); }); @@ -53,9 +48,8 @@ export const deleteTodo = System.create({ */ export const moveTodo = System.create({ name: "todos/move", - input: { type: "object", properties: { entity: Entity.schema, before: Entity.schema } }, lookup: { todos: { read: [order], has: [todo] }, ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { +}, ({ lookup, commands }, input: { readonly entity: Entity; readonly before: Entity }) => { const { entity, before } = input; if (entity === before || lookup.todos(entity) === null) return; const next = before === Entity.none ? null : lookup.todos(before); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index 33e5f479..e00513a4 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -3,7 +3,7 @@ import { Component, Resource, System, Vec2, clock } from '../../index.js'; // The transfers sample's keys and systems, which every thread imports: the world, on a worker, draws sprites into the // canvas main moved to it, each with a texture a loader worker decoded and moved to it. Values move through runs whose -// input holds them; an input field's schema `{ allOf: [] }` takes any value, typed `unknown`, which bodies narrow. +// input holds them. export const texture = Symbol("transferSprites/texture"); export const position = Symbol("transferSprites/position"); @@ -34,11 +34,9 @@ Resource.register(stats, { type: { type: "object", properties: { sprites: { type: "integer" }, frames: { type: "integer" } }, default: { sprites: 0, frames: 0 } }, }); -const any = { allOf: [] } as const; - /** From main: the canvas the world draws into. */ -export const attachSurface = System.create({ name: "transferSprites/attachSurface", input: { type: "object", properties: { canvas: any } }, resources: { write: [surface] } }, ({ input, resources }) => { - if (typeof OffscreenCanvas === "function" && input.canvas instanceof OffscreenCanvas) resources[surface] = input.canvas; +export const attachSurface = System.create({ name: "transferSprites/attachSurface", resources: { write: [surface] } }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { + resources[surface] = input.canvas; }); /** A deterministic number in [0, 1) for each seed. */ @@ -49,10 +47,10 @@ const random = (seed: number): number => { /** From the loader: a sprite with the texture it decoded, somewhere on the canvas, moving. */ export const addSprite = System.create({ - name: "transferSprites/addSprite", input: { type: "object", properties: { bitmap: any, seed: { type: "integer" } } }, resources: { read: [surface], write: [stats] }, -}, ({ input, resources, commands }) => { + name: "transferSprites/addSprite", resources: { read: [surface], write: [stats] }, +}, ({ resources, commands }, input: { readonly bitmap: ImageBitmap; readonly seed: number }) => { const canvas = resources[surface]; - if (canvas === null || typeof ImageBitmap !== "function" || !(input.bitmap instanceof ImageBitmap)) return; + if (canvas === null) return; const angle = random(input.seed) * Math.PI * 2; const speed = 60 + random(input.seed + 1) * 120; commands.spawn([texture, position, velocity])({ diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 2031ffb1..4ceaf0b5 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -629,8 +629,8 @@ is in the input, and the system, which writes the component, runs on that thread point, and a staged slot of a joined thread's once it replays that sync point, which the world wakes it for. Releasing closes an `ImageBitmap`, `VideoFrame`, `AudioData`, `MessagePort` or `RTCDataChannel`, cancels or aborts a stream, and leaves an `ArrayBuffer`'s or an `OffscreenCanvas`'s memory to the collector. -- **Types:** an input schema can't name a Transferable type yet; a field declared `{ allOf: [] }` takes any value, - typed `unknown`, which the body narrows. +- **Types:** an input field is typed as its class, `{ readonly canvas: OffscreenCanvas }`, whose schema the plugin + gives as one taking any value. ### Without cross-origin isolation @@ -910,8 +910,7 @@ that's gone rejects; an input no task took is released once its run finishes; a its thread. The sample (`src/samples/transfers`) runs the world on a worker drawing into the OffscreenCanvas main moved to it, main showing its state in the DOM, and a loader worker moving each texture it decoded to the world's thread. -- Interface: none. An input schema can't name a Transferable type, so a run's input can't be typed with one; proposed - for review, and meanwhile a field `{ allOf: [] }` takes one as `unknown`. +- Interface: none; an input field is typed as its Transferable class. - Contract: a run's Transferable input moves, never copied, to the thread its system runs on, and the run lands once it arrived, in call order with that thread's other runs; a run whose input moved to a thread that's gone rejects; a value whose command drops is released on its thread. From aff46bb0db675c642438c8b81ff9ee847e498ae2 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 23:08:33 -0700 Subject: [PATCH 012/127] ecs: input types read the values of as const objects, and take any global class Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../compiler/input-schemas.test.ts | 26 ++++- .../implementation/compiler/input-schemas.ts | 96 ++++++++++++++----- .../implementation/compiler/plugin.test.ts | 4 +- 4 files changed, 96 insertions(+), 32 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 3f474219..d9e6ad52 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -118,7 +118,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Without `kernel`, a system is plain JS on the main thread. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. - - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, the package's `Entity` and math types, the platform's transferable and cloneable classes, and `unknown`. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. + - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. - Its checks are on the body, after the body is inferred: a system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input; a `target` names an entity field of it; an observer system takes none. A check in the declaration's position can't see the input, which TypeScript infers only from the body, after the declaration. - **`target`** names a field of the input holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. - **A composite's input** has every field of its members' inputs, and each member receives the fields it declares, so workers get only those. A field several members declare is one value for all of them, with the same schema in each: a different type is a compile-time error at each member that differs, and creating the composite throws naming both. A field optional in one and required in another is required. diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts index 51a7b871..cbf6fda8 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.test.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -82,11 +82,27 @@ describe("passInputSchemas", () => { }, }]); }); - test("takes any value for a platform class or unknown", async () => { - expect(await schemasOf(`System.create({}, (_c, input: { canvas: OffscreenCanvas; bitmap: ImageBitmap; anything: unknown }) => {});`)).toEqual([ - { type: "object", properties: { canvas: { allOf: [] }, bitmap: { allOf: [] }, anything: { allOf: [] } } }, + test("takes any value for a global class or unknown", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { canvas: OffscreenCanvas; bitmap: ImageBitmap; element: HTMLCanvasElement; anything: unknown }) => {});`)).toEqual([ + { type: "object", properties: { canvas: { allOf: [] }, bitmap: { allOf: [] }, element: { allOf: [] }, anything: { allOf: [] } } }, ]); }); + test("reads the values of an `as const` object, here or imported, as a union of their literals", async () => { + const files = { "/src/align.ts": `export const Align = { start: "start", end: "end", again: "start" } as const;\nexport type Align = (typeof Align)[keyof typeof Align];` }; + expect(await schemasOf([ + `import { Align } from "./align.js";`, + `const Level = { low: 1, high: -1, on: true } as const satisfies Record;`, + `type Level = (typeof Level)[keyof typeof Level];`, + `System.create({}, (_c, input: { align: Align; level: Level; inline: (typeof Level)[keyof typeof Level] }) => {});`, + ].join("\n"), files)).toEqual([{ + type: "object", + properties: { + align: { anyOf: [{ const: "start" }, { const: "end" }] }, + level: { anyOf: [{ const: 1 }, { const: -1 }, { const: true }] }, + inline: { anyOf: [{ const: 1 }, { const: -1 }, { const: true }] }, + }, + }]); + }); test("follows aliases and interfaces, here and in modules imported", async () => { const files = { "/src/types.ts": `export type Move = { readonly by: number };\nexport interface Named { readonly name: string }\nexport { type Tagged } from "./more.js";\nexport * from "./all.js";`, @@ -122,7 +138,9 @@ describe("passInputSchemas", () => { expect(await failure(`type Box = { readonly value: T };\nSystem.create({}, (_c, input: Box) => {});`)).toContain("generic"); expect(await failure(`System.create({}, (_c, input: [number, string]) => {});`)).toContain("one type"); expect(await failure(`System.create({}, (_c, input: number) => {});`)).toContain("object of named fields"); - expect(await failure(`System.create({}, (_c, input: { when: Date }) => {});`)).toContain("isn't a type an input can hold"); + expect(await failure(`const Loose = { a: "a" };\nSystem.create({}, (_c, input: { a: (typeof Loose)[keyof typeof Loose] }) => {});`)).toContain("as const"); + expect(await failure(`const Nested = { a: { b: 1 } } as const;\nSystem.create({}, (_c, input: { a: (typeof Nested)[keyof typeof Nested] }) => {});`)).toContain("literals"); + expect(await failure(`System.create({}, (_c, input: { a: Big["x"] }) => {});`)).toContain("keyof typeof"); expect(await failure(`System.create({}, (_c, ...rest: [{ a: number }]) => {});`)).toContain("rest parameter"); }); test("inserts each schema after the body, mapping the rest of the line back", async () => { diff --git a/packages/ecs/src/implementation/compiler/input-schemas.ts b/packages/ecs/src/implementation/compiler/input-schemas.ts index 14b14443..9d4656f3 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.ts @@ -12,6 +12,7 @@ type Statement = TypedProgram["body"][number]; type Alias = Extract; type Interface = Extract; type Import = Extract; +type Declarator = Extract["declarations"][number]; type TypeNode = Alias["typeAnnotation"]; type Member = Interface["body"]["body"][number]; type Named = Extract["typeName"]; @@ -40,16 +41,10 @@ export type InputResult = type Thrown = { readonly inputFailure: Failure }; const isThrown = (value: unknown): value is Thrown => typeof value === "object" && value !== null && "inputFailure" in value; -/** Classes of the platform an input may hold, which aren't data: values that can be transferred or cloned. */ -const platformClasses: ReadonlySet = new Set([ - "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", "ReadableStream", - "WritableStream", "TransformStream", "RTCDataChannel", "Blob", "File", "ImageData", -]); - -/** A value of any type: a class of the platform's, or `unknown`. */ +/** A value of any type: a global class, such as the platform's, or `unknown`. */ const anything: Schema = { allOf: [] }; -const supported = "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the package's `Entity` and math types, a platform class or `unknown`"; +const supported = "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, the package's `Entity` and math types, a global class or `unknown`"; /** The schema of the package's type at `path`, such as `["Vec2", "I32"]`: its namespace's `schema`. */ const packageSchema = (path: readonly string[]): Schema | undefined => { @@ -63,11 +58,12 @@ const packageSchema = (path: readonly string[]): Schema | undefined => { // The package's own schemas are its exported constants. const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null; -/** A parsed module: its declarations of types by name, and its imports by local name. */ +/** A parsed module: its declarations of types and its top-level constants by name, and its imports by local name. */ type Module = { readonly file: string; readonly code: string; readonly types: ReadonlyMap; + readonly constants: ReadonlyMap; readonly imports: ReadonlyMap; readonly exports: readonly Statement[]; }; @@ -77,6 +73,14 @@ const declarationOf = (statement: Statement): Alias | Interface | null => { return declared?.type === "TSTypeAliasDeclaration" || declared?.type === "TSInterfaceDeclaration" ? declared : null; }; +/** The top-level constants a statement declares. */ +const constantsOf = (statement: Statement): readonly Declarator[] => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "VariableDeclaration" && declared.kind === "const" ? declared.declarations.filter(declarator => declarator.id.type === "Identifier") : []; +}; + +const nameOfDeclarator = (declarator: Declarator): string => declarator.id.type === "Identifier" ? declarator.id.name : ""; + const importsOf = (statement: Import): [string, { readonly source: string; readonly imported: string | null }][] => statement.specifiers.map(specifier => [specifier.local.name, { source: statement.source.value, @@ -87,13 +91,15 @@ const importsOf = (statement: Import): [string, { readonly source: string; reado const moduleOf = (file: string, code: string, program: TypedProgram): Module => { const types = new Map(); + const constants = new Map(); const imports = new Map(); for (const statement of program.body) { const declared = declarationOf(statement); if (declared !== null) types.set(declared.id.name, declared); + for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); } - return { file, code, types, imports, exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") }; + return { file, code, types, constants, imports, exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") }; }; /** The line and column of an offset, 1-based line, 0-based column, as a failure's position. */ @@ -132,42 +138,47 @@ const createConverter = (host: InputHost, root: string) => { return loading; }; - /** The declaration `name` exports, following re-exports; null when it exports no type by that name. */ - const exported = async (module: Module, name: string, seen: Set): Promise<{ readonly module: Module; readonly declaration: Alias | Interface } | "package" | null> => { + /** + * What `name` names in `module`'s exports, of `kind`, following re-exports: a type's declaration, or a constant's + * declarator; null when it exports none by that name. + */ + const exported = async (module: Module, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | "package" | null> => { const key = `${module.file}#${name}`; if (seen.has(key)) return null; seen.add(key); + // A module's own declaration comes before what it re-exports. + const own = kind(module).get(name); + if (own !== undefined) return { module, declaration: own }; for (const statement of module.exports) { if (statement.type === "ExportNamedDeclaration") { - const declared = declarationOf(statement); - if (declared !== null && declared.id.name === name) return { module, declaration: declared }; for (const specifier of statement.specifiers) { const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; if (exportedName !== name) continue; const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; - if (statement.source !== null) return await fromSource(module, statement.source.value, localName, seen); - const local = module.types.get(localName); + if (statement.source !== null) return await fromSource(module, statement.source.value, localName, kind, seen); + const local = kind(module).get(localName); if (local !== undefined) return { module, declaration: local }; const imported = module.imports.get(localName); - if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, seen); + if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, kind, seen); } } else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { - const found = await fromSource(module, statement.source.value, name, seen); + const found = await fromSource(module, statement.source.value, name, kind, seen); if (found !== null) return found; } } - const local = module.types.get(name); - return local === undefined ? null : { module, declaration: local }; + return null; }; - const fromSource = async (module: Module, source: string, name: string, seen: Set): Promise<{ readonly module: Module; readonly declaration: Alias | Interface } | "package" | null> => { + const fromSource = async (module: Module, source: string, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | "package" | null> => { if (await host.isPackage(source, module.file)) return "package"; const file = await host.resolve(source, module.file); if (file === null) return null; const target = await load(file); - return target === null ? null : await exported(target, name, seen); + return target === null ? null : await exported(target, name, kind, seen); }; + const typesOf = (module: Module): ReadonlyMap => module.types; + const constantsIn = (module: Module): ReadonlyMap => module.constants; const nameParts = (name: Named): readonly string[] => name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...nameParts(name.left), name.right.name] : []; @@ -224,6 +235,7 @@ const createConverter = (host: InputHost, root: string) => { return optional ? { type: "object", properties, required } : { type: "object", properties }; } case "TSTypeReference": return await referenced(type, module, active); + case "TSIndexedAccessType": return await valuesOf(type, module); default: return fail(module, type, `an input is ${supported}`); } }; @@ -279,15 +291,15 @@ const createConverter = (host: InputHost, root: string) => { return schema ?? fail(module, at, `${path.join(".")} is no type of the package an input can take: it takes Entity and the math types`); } if (imported.imported === null || rest.length > 0) return fail(module, at, `input type ${path.join(".")} is read through a namespace, which this pass doesn't follow; import it by name`); - const found = await fromSource(module, imported.source, imported.imported, new Set()); + const found = await fromSource(module, imported.source, imported.imported, typesOf, new Set()); if (found === "package") { return packageSchema([imported.imported]) ?? fail(module, at, `${imported.imported} is no type of the package an input can take: it takes Entity and the math types`); } if (found === null) return fail(module, at, `input type ${first} isn't a type alias or interface its module exports, which this pass can read`); return await declared(found.declaration, found.module, active, at); } - if (rest.length === 0 && platformClasses.has(first)) return anything; - return fail(module, at, `${path.join(".")} isn't a type an input can hold: an input is ${supported}`); + // Neither declared nor imported, it's a global, which TypeScript checked exists: a class, such as the platform's. + return anything; }; const referenced = async (type: Extract, module: Module, active: ReadonlySet): Promise => { @@ -306,6 +318,40 @@ const createConverter = (host: InputHost, root: string) => { return await named(path, module, active, type); }; + /** `(typeof X)[keyof typeof X]`: the values of the `as const` object `X`, as a union of their literals. */ + const valuesOf = async (type: Extract, module: Module): Promise => { + const queried = (node: TypeNode): string | null => { + const inner = node.type === "TSParenthesizedType" ? node.typeAnnotation : node; + return inner.type === "TSTypeQuery" && inner.typeArguments === null && inner.exprName.type === "Identifier" ? inner.exprName.name : null; + }; + const name = queried(type.objectType); + const index = type.indexType.type === "TSTypeOperator" && type.indexType.operator === "keyof" ? queried(type.indexType.typeAnnotation) : null; + if (name === null || index !== name) return fail(module, type, `an input's indexed access type is \`(typeof X)[keyof typeof X]\`, the values of an \`as const\` object`); + const imported = module.imports.get(name); + const local = module.constants.get(name); + const found = local !== undefined ? { module, declaration: local } + : imported !== undefined && imported.imported !== null ? await fromSource(module, imported.source, imported.imported, constantsIn, new Set()) : null; + if (found === null || found === "package") return fail(module, type, `${name} isn't a constant its module declares or exports, which this pass can read`); + const { declaration } = found; + let init = declaration.init; + let constant = false; + while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) { + if (init.type === "TSAsExpression" && init.typeAnnotation.type === "TSTypeReference" && init.typeAnnotation.typeName.type === "Identifier" && init.typeAnnotation.typeName.name === "const") constant = true; + init = init.expression; + } + if (init === null || init.type !== "ObjectExpression" || !constant) return fail(found.module, declaration, `${name} is an object declared \`as const\`, whose values are literals`); + const values: (string | number | boolean)[] = []; + for (const property of init.properties) { + const value = property.type === "Property" ? property.value : null; + const literal = value?.type === "Literal" && (typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean") ? value.value + : value?.type === "UnaryExpression" && value.operator === "-" && value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value + : undefined; + if (literal === undefined) return fail(found.module, property, `${name}'s values are string, number or boolean literals`); + if (!values.includes(literal)) values.push(literal); + } + return { anyOf: values.map(value => ({ const: value })) }; + }; + return { convert, moduleOf, read, site, fail }; }; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 0727db46..78f3e782 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -441,9 +441,9 @@ test("serve passes each system the schema of its body's input, from types other test("a build fails at a system's input whose type can't be one", async () => { const entry = join(directory, "bad-input.ts"); - writeFileSync(entry, `import { System } from ${source("../../interface/system/system.ts")};\nexport const bad = System.create({}, (_context, input: { readonly when: Date }) => { void input; });\n`); + writeFileSync(entry, `import { System } from ${source("../../interface/system/system.ts")};\nexport const bad = System.create({}, (_context, input: { readonly when: Big["when"] }) => { void input; });\n`); await expect(build({ configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], build: { outDir: join(directory, "dist-bad-input"), minify: false, lib: { entry, formats: ["es"], fileName: "main" } }, - })).rejects.toThrow("Date isn't a type an input can hold"); + })).rejects.toThrow("(typeof X)[keyof typeof X]"); }); From 07d3db477b39796b15197c542d3d34703bda96a5 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 23:23:17 -0700 Subject: [PATCH 013/127] ecs: the loop churn benchmarks compare memory once the world keeps its full count of reports Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 2 -- packages/ecs/src/perftest/systems.ts | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 242c1576..83342afe 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -130,8 +130,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done record. - **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the thread's run (see perf.md "Next"). -- **`loop tick add remove` fails its leak check:** the world holds 3 schedules and 38,080 arena bytes after, against 4 - and 16,192 before, so `pnpm perf` stops there. It fails the same before the change to input types. ## Earlier open work diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts index c32a515c..4553240f 100644 --- a/packages/ecs/src/perftest/systems.ts +++ b/packages/ecs/src/perftest/systems.ts @@ -4,6 +4,7 @@ import { always } from "../interface/resources/always.js"; import { eachTick, ticksAfter } from "./observer.js"; import { System } from "../interface/system/system.js"; import { backToBack, createWorld } from "../implementation/index.js"; +import { retainedRuns } from "../implementation/world/reports.js"; import type { Benchmark } from "./benchmark.js"; import { idle } from "./suite.js"; @@ -57,7 +58,8 @@ const churning = async (churn: (() => System) | null, after: boolean, stretches world.add(again); await ticksAfter(world, 2); world.remove(again); - await ticksAfter(world, 2); + // The world keeps the reports of its last runs and ticks, so it holds what it will hold once it has that many. + await ticksAfter(world, retainedRuns); await world.run(idle); const before = await world.tables(); let added: System | null = null; From 998738a338c8486e57813b9b821bfaac92b71f13 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 23:33:26 -0700 Subject: [PATCH 014/127] ecs: one buffer spawns every report, and benchmarks after a run warm up past the kept reports Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 4 ---- packages/ecs/src/implementation/world/perf.md | 1 + packages/ecs/src/implementation/world/reports.ts | 9 +++++++-- packages/ecs/src/perftest/benchmarks.ts | 12 +++++++++--- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 83342afe..8e269a69 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -117,10 +117,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Performance -- **The filtered `up` benchmarks are slower than this machine's record at `e3c30aa1`:** `up one root changed` +49%, - `up filtered after a reparent` +45%, `up small tree in a big world` +79%. They're the same at `c6bb3c83`, before - strings, so the drift is older. Worth bisecting from `e3c30aa1`. - - **A joined thread's first tail task once never finished,** under heavy machine load (two test suites at once): the world waited on `channel.idle()` with task 1 posted and not done (`threads.browser.test.ts`, tails "see one tick's values"). It didn't recur in 25 runs without the extra load, and the base failed once on another threads test in the diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 0b9bd6cc..9107339c 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -124,6 +124,7 @@ A report for every run and tick, `Report.run` written in place and `Report.error - **About 125 ns a run:** two `performance.now()` calls for `elapsed`, 31 ns each, then locating the report, writing its struct and stamping it. - **`Report.run` is written field by field** through its layout's leaves, not the general struct writer: `run idle` 583 → 500 ns, and `run observer` 375 → 291. - **What a tier ran is worked out once per list of runners:** counting each runner every run took `run 50 idle` to 4.08 µs a run. +- **A world's first 256 runs each spawn their report,** so each has a sync point applying a spawn: 3.2–5.1 µs a run in node against 1.1–1.5 µs once reports are reused, about 1 ms over a world's life. One command buffer spawns every report, 3.8–5.7 → 3.2–5.1 µs. Benchmarks timing a run after another (`after` in `perftest/benchmarks.ts`) warm up past the 256, so they time a world's steady state: the filtered `up` benchmarks timed the spawn's sync point instead, `up one root changed` 0.042 against 0.024 ms, `up filtered after a reparent` 0.028 against 0.018, `up small tree in a big world` 0.020 against 0.011. - **A reused report keeps its errors and takes new ones in place,** so a run with a blocking tier every tick changes no archetype, and its sync point isn't one tails wait for. Strings in shared memory, alternating against `c6bb3c83`, node, five and six rounds, medians: every touched benchmark within noise (`run idle` a mean over 20,000 runs 626–644 against 622–660 ns, `run 50 idle`, `run observer`, `loop tick idle`, `sync point one spawn`, `move 1K`, `main thread 1K`, `spawn bulk`, `despawn bulk`, `despawn one`, `add tag`, `add value`, `remote run`, `joined observer resource`, `joined observer 10K`, `handoff`, `tail prompt`, `tail late snapshot 10K`, `despawn objects 10K` 97.5 against 97.7 ns). diff --git a/packages/ecs/src/implementation/world/reports.ts b/packages/ecs/src/implementation/world/reports.ts index 8613ad5c..66c7bc50 100644 --- a/packages/ecs/src/implementation/world/reports.ts +++ b/packages/ecs/src/implementation/world/reports.ts @@ -2,7 +2,7 @@ import { Report } from '../../interface/report/report.js'; import type { Failure, Warning } from '../../interface/report/failure.js'; import { stampChanged } from './archetype.js'; -import { addCommand, createBuffer, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; +import { addCommand, clearBuffer, createBuffer, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; import { writeCell } from './cells.js'; import type { Views } from '../../interface/registry/layout.js'; import { pushValues } from './commands.js'; @@ -79,6 +79,8 @@ export const createReports = (state: State, applyAlone: (buffer: CommandBuffer) let oldest = 0; /** The slot the last run took. */ let last: Slot | null = null; + /** The buffer that spawns reports. */ + const spawns = createBuffer(1); /** The storage and row of a slot's component `id`, while its entity holds it. */ const cellOf = (slot: Slot, id: number) => { @@ -130,7 +132,10 @@ export const createReports = (state: State, applyAlone: (buffer: CommandBuffer) slot.errors = null; } slot.entity = reserve(state.entities); - const buffer = createBuffer(); + // One buffer spawns every report, as each sync point is done with it before the next run takes one; a new one + // only while it's still queued. + const buffer = state.queued.some(queued => queued.buffer === spawns) ? createBuffer(1) : spawns; + clearBuffer(buffer); pushValues(state, buffer, spawnCommand, slot.entity, { [Report.run]: { number: run, elapsed: 0, systems: 0, workers: 0, joined: 0, main: false, gpu: false, dropped: 0 } satisfies RunValues, }); diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index 100d4edf..73e09526 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -9,6 +9,7 @@ import * as structural from "./structural.js"; import { chain, churn, emit, failing, fastest, follow, highest, hits, idle, idles, idleObserver, index, indexSum, observe, poke, pokeCluster, reduce, retag, seeded, small, smalls, spawnOne, tag, totalOf } from "./suite.js"; import type { Benchmark } from "./benchmark.js"; +import { retainedRuns } from "../implementation/world/reports.js"; import { loopTickIdle, loopTicks, observerIdles } from "./loop.js"; import { main, placed, pooled } from "./placed.js"; import { wake } from "./wake.js"; @@ -51,14 +52,19 @@ const observed = async (world: World, writer: typeof poke | typeof pokeCluster): return best; }; -/** The fastest run of `measured` right after `before` runs, over `rounds` rounds after `warmup`. */ +/** + * The fastest run of `measured` right after `before` runs, over `rounds - warmup` rounds after a warmup of at least + * `warmup` rounds, and enough for the world to keep its full count of reports: until then each run spawns its own, + * which its sync point costs. + */ const after = async (before: () => Promise, measured: () => Promise, rounds = 100, warmup = 20): Promise => { let best = Infinity; - for (let i = 0; i < rounds; i++) { + const warm = Math.max(warmup, Math.ceil(retainedRuns / 2)); + for (let i = 0; i < warm + rounds - warmup; i++) { await before(); const start = performance.now(); await measured(); - if (i >= warmup) best = Math.min(best, performance.now() - start); + if (i >= warm) best = Math.min(best, performance.now() - start); } return best; }; From 65544065b2ff73d2e1627a77dc8e86d3a6dddf06 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 23:59:38 -0700 Subject: [PATCH 015/127] ecs: benchmarks moving one entity between two large buckets of a hash index, then reading lengths or rows in order Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/perftest/indexes.ts | 77 ++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index 5318bc6d..a4df81f3 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -343,6 +343,82 @@ const restingCounts = async (filtered: boolean): Promise => { return best; }; +/** Entities toggled between `byFlag`'s two buckets, one a run, each a different one spread through entity order. */ +let flaggedEntities: Entity[] = []; +const seedFlagged = System.create({ name: "indexes/seedFlagged" }, ({ commands }, input: { readonly count: number }) => { + flaggedEntities = []; + for (let i = 0; i < input.count; i++) flaggedEntities.push(commands.spawn([flag])({ [flag]: i % 2 === 0 })); +}); +const toggleFlag = System.create({ name: "indexes/toggleFlag", target: "entity", components: { write: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { + row[flag] = !row[flag]; +}); +/** Both buckets' lengths, as a count of done and not done todos. */ +const readFlagLengths = System.create({ name: "indexes/readFlagLengths", lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + found = lookup.byFlag(false).length + lookup.byFlag(true).length; +}); +/** Both buckets' first ten rows, in entity order. */ +const readFlagRows = System.create({ name: "indexes/readFlagRows", lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + let sum = 0; + for (const value of [false, true]) { + const rows = lookup.byFlag(value); + let last = 0; + for (let i = 0; i < 10 && i < rows.length; i++) { + const entity = rows.at(i).entity; + check(entity > last, "bucket move: rows out of entity order"); + last = entity; + sum += entity; + } + } + found = sum; +}); +/** Moves a round of `bucketMove` takes. */ +const movesPerRound = 200; +/** + * The fastest round's mean time of moving one entity between `byFlag`'s two buckets of `count` entities between them, + * then running `reader`, which refreshes the index and reads it. + */ +const bucketMove = async (count: number, reader: System): Promise => { + const world = inline(); + await world.run(seedFlagged, { count }); + await world.run(reader); + let best = Infinity; + let next = 0; + for (let round = 0; round < 8; round++) { + const start = performance.now(); + for (let k = 0; k < movesPerRound; k++) { + await world.run(toggleFlag, { entity: flaggedEntities[(next++ * 7919) % count] }); + await world.run(reader); + } + if (round >= 2) best = Math.min(best, (performance.now() - start) / movesPerRound); + } + check(found > 0, "bucket move: nothing found"); + await world.dispose(); + return best; +}; +const bucketMoveBenchmarks = [1_000, 10_000, 100_000].flatMap((count): Benchmark[] => { + const label = `${count / 1000}K`; + return [ + { + name: `bucket move ${label} length`, + operation: `A run moving one entity between the two buckets of a hash index on a boolean over ${label} entities, then a system reading both buckets' lengths: toggling a todo while counts of done and not done ones are shown.`, + per: "moves", + n: 1, + typical: [1, 100], + best: { bytes: 48, basis: "The move reads its 8-byte key and tick, and writes one 12-byte entry out of one bucket and one into the other; each length reads 4 bytes." }, + run: () => bucketMove(count, readFlagLengths), + }, + { + name: `bucket move ${label} in order`, + operation: `As \`bucket move ${label} length\`, the system reading each bucket's first ten rows in entity order: a list of done todos shown after a toggle.`, + per: "moves", + n: 1, + typical: [1, 100], + best: { bytes: 288, basis: "As the length's, and twenty 12-byte entries read." }, + run: () => bucketMove(count, readFlagRows), + }, + ]; +}); + export const indexBenchmarks: readonly Benchmark[] = [ { name: "observer index unchanged 100", @@ -689,4 +765,5 @@ export const indexBenchmarks: readonly Benchmark[] = [ return best; }, }, + ...bucketMoveBenchmarks, ]; From 89bdcf50453dd0dce7f6bde36d1c358450ba6e78 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 00:38:51 -0700 Subject: [PATCH 016/127] ecs: long hash buckets are left unsorted by a change and sorted before anything reads them by position A change to a bucket of at least 64 entries of an index that isn't unique leaves a hole or joins a tail instead of shifting the bucket, so moving an entity between two long buckets is O(log bucket) to find and O(1) amortized to move. Lists sort their bucket on the world's thread at the first `at`; kernels, workers and joined threads get every bucket sorted before their parameters are written. Long moves copy 8-byte words over shared memory. bucket move 100K length 418 -> 5.6 us, in order 416 -> 69 us. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 4 +- packages/ecs/followups.md | 3 - .../world/index-buckets.test.ts | 284 ++++++++++ .../src/implementation/world/index-hash.ts | 496 +++++++++++++++--- .../implementation/world/index-joined.test.ts | 55 ++ .../src/implementation/world/index-lookups.ts | 6 +- .../world/index-workers.cases.ts | 9 + .../world/index-workers.fixture.ts | 72 +++ .../ecs/src/implementation/world/indexes.ts | 104 +--- .../implementation/world/kernel-indexes.ts | 12 +- packages/ecs/src/implementation/world/perf.md | 10 +- .../implementation/world/thread-indexes.ts | 3 +- packages/ecs/src/samples/todo/README.md | 20 +- 13 files changed, 893 insertions(+), 185 deletions(-) create mode 100644 packages/ecs/src/implementation/world/index-buckets.test.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index d9e6ad52..00f68117 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -284,8 +284,8 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **The observer pass:** each list of observer systems keeps its runners, read again once runners change, and whether any looks up through an index, so an idle pass reads no index and no GPU state: `observer idle 100` about 80 ns an observer system a tick, and a run of observer systems alone skips its empty sync point: `run observer` 0.21 µs against 0.38–0.42 for `run idle`. One on a joined thread is posted only when its conditions hold and a filter may pass, by its resources' and matched chunks' ticks, into a block of its own, waking each thread once a pass; that thread reads the shared memory directly, the next writer copying what it hasn't read yet, or, for one reading only struct resources, reads copies the post holds, written into one of two blocks so the world never waits for it: `joined observer resource` 4.3–5.0 µs a tick and `joined observer 10K` 0.41–0.51 ms in node; 7.2–7.5 µs and 0.34–0.50 ms in chromium. - **Indexes:** - Keys are encoded as order-preserving words (f32 and f64 bits flipped, NaN last and -0 as +0, i32 with its sign bit flipped, booleans 0 or 1, object parts as ids interned per index), hashed by a multiply-xorshift mix, the same in JS and WASM. - - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. - - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets one by one and long ones in one merge, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. + - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`, `refreshSource`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. + - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets kept in entity order and long ones left unsorted, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. - Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits. - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. - Kernels compile each lookup call into a probe the world binds to its index's layout; WASM probes in straight-line code, reading the index's arrays at addresses each run's parameters give, on the world's thread and pool workers; joined threads read a record of them the world writes before each task: `grid neighbors 100K` 3.7 µs a row on the world's thread, 179 ns a row in a kernel across the pool. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 8e269a69..6b209735 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -84,9 +84,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over the entity a reference names. -4. **Low-cardinality hash indexes cost O(bucket) per change.** Buckets are kept in entity order and shifted on removal: - one toggle costs 26, 62 and 400 µs at 1K, 10K and 100K. Proposal: unordered buckets with swap-remove and a position - map, or a count-only index. 5. **Change filters scan the whole dirty chunk** (1,024 row ticks), once per system that filters on `changed`. Proposal: a second level of change ticks, one per 32–64 rows. 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts new file mode 100644 index 00000000..666a12a1 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -0,0 +1,284 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; + +// Long buckets of a hash index, which a change may leave unsorted until a list is read by position: every reader still +// gets each bucket's rows in entity order. + +const group = Symbol("indexBucketsTest/group"); +const serial = Symbol("indexBucketsTest/serial"); +const hidden = Symbol("indexBucketsTest/hidden"); +const extra = Symbol("indexBucketsTest/extra"); +const reads = Symbol("indexBucketsTest/reads"); +const sum = Symbol("indexBucketsTest/sum"); +const sumByHand = Symbol("indexBucketsTest/sumByHand"); + +declare module "@adobe/ecs" { + interface Components { + [group]: { type: number }; + [serial]: { type: number }; + [hidden]: { type: true }; + [extra]: { type: true }; + [reads]: { type: number }; + [sum]: { type: number }; + [sumByHand]: { type: number }; + } +} +Component.register(group, { type: { type: "integer", minimum: 0, maximum: 2 } }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 10000000 } }); +Component.register(hidden, { type: { const: true } }); +Component.register(extra, { type: { const: true } }); +Component.register(reads, { type: { type: "integer", minimum: 0, maximum: 2 } }); +Component.register(sum, { type: { type: "number" } }); +Component.register(sumByHand, { type: { type: "number" } }); + +const groups = [0, 1, 2]; +const byGroup = Index.create({ name: "indexBucketsTest/byGroup", key: [group], components: { has: [serial], not: [hidden] } }); + +/** What a reader saw: each bucket's length, or each bucket's rows as entity index and serial, in order. */ +let lengths: number[] = []; +let rows: (readonly [number, number])[][] = []; +const readLengths = System.create({ name: "indexBucketsTest/readLengths", lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { + lengths = groups.map(value => lookup.byGroup(value).length); +}); +const readRows = System.create({ name: "indexBucketsTest/readRows", lookup: { byGroup: { index: byGroup, read: [serial] } } }, ({ lookup }) => { + rows = groups.map(value => { + const list = lookup.byGroup(value); + const found: (readonly [number, number])[] = []; + for (let i = 0; i < list.length; i++) { + const row = list.at(i); + found.push([row.entity >>> 0, row[serial]]); + } + return found; + }); +}); + +/** Each reader row's bucket's serials, each times its position plus one: a sum that tells the order. */ +const orderSum = System.create({ + name: "indexBucketsTest/orderSum", kernel: "cpu", + components: { read: [reads], write: [sum] }, + lookup: { byGroup: { index: byGroup, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.byGroup(row[reads]); + let total = 0; + for (let i = 0; i < list.length; i++) total += (i + 1) * list.at(i)[serial]; + row[sum] = total; +}); +const orderSumByHand = System.create({ + name: "indexBucketsTest/orderSumByHand", + components: { read: [reads], write: [sumByHand] }, + lookup: { byGroup: { index: byGroup, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.byGroup(row[reads]); + let total = 0; + for (let i = 0; i < list.length; i++) total += (i + 1) * list.at(i)[serial]; + row[sumByHand] = total; +}); +let sums: { kernel: number[]; byHand: number[] } = { kernel: [], byHand: [] }; +const readSums = System.create({ name: "indexBucketsTest/readSums", components: { read: [reads, sum, sumByHand] } }, row => { + sums.kernel[row[reads]] = row[sum]; + sums.byHand[row[reads]] = row[sumByHand]; +}); +const spawnReaders = System.create({ name: "indexBucketsTest/spawnReaders" }, ({ commands }) => { + for (const value of groups) commands.spawn([reads, sum, sumByHand])({ [reads]: value, [sum]: 0, [sumByHand]: 0 }); +}); + +/** An entity as the test keeps it, and the changes a round makes. */ +type Held = { group: number; readonly serial: number; hidden: boolean }; +type Change = + | { readonly kind: "spawn"; readonly group: number; readonly serial: number; readonly extra: boolean } + | { readonly kind: "despawn" | "hide" | "show" | "extra" | "plain"; readonly entity: Entity } + | { readonly kind: "group"; readonly entity: Entity; readonly group: number }; + +let spawned: Entity[] = []; +const apply = System.create({ name: "indexBucketsTest/apply" }, ({ commands }, input: { readonly changes: readonly Change[] }) => { + spawned = []; + for (const change of input.changes) { + switch (change.kind) { + case "spawn": + spawned.push(change.extra + ? commands.spawn([group, serial, extra])({ [group]: change.group, [serial]: change.serial, [extra]: true }) + : commands.spawn([group, serial])({ [group]: change.group, [serial]: change.serial })); + break; + case "despawn": commands.despawn(change.entity); break; + case "hide": commands.add(change.entity, hidden); break; + case "show": commands.remove(change.entity, hidden); break; + case "extra": commands.add(change.entity, extra); break; + case "plain": commands.remove(change.entity, extra); break; + case "group": commands.add(change.entity, group, change.group); break; + } + } +}); + +/** A seeded generator of numbers in [0, 1). */ +const generator = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (state + 0x6D2B79F5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** The world and what it holds, as the test keeps it: entities by entity, and the next serial. */ +type Scenario = { readonly world: CreatedWorld; readonly held: Map; next: number }; + +const applyChanges = async (scenario: Scenario, changes: readonly Change[]): Promise => { + await scenario.world.run(apply, { changes }); + let k = 0; + for (const change of changes) { + if (change.kind === "spawn") scenario.held.set(spawned[k++], { group: change.group, serial: change.serial, hidden: false }); + else if (change.kind === "despawn") scenario.held.delete(change.entity); + else if (change.kind === "hide" || change.kind === "show") { + const found = scenario.held.get(change.entity); + if (found !== undefined) found.hidden = change.kind === "hide"; + } + else if (change.kind === "group") { + const found = scenario.held.get(change.entity); + if (found !== undefined) found.group = change.group; + } + } +}; + +const spawnMany = (scenario: Scenario, count: number, groupOf: (k: number) => number): Change[] => + Array.from({ length: count }, (_, k): Change => ({ kind: "spawn", group: groupOf(k), serial: scenario.next++, extra: k % 3 === 0 })); + +/** Each bucket's rows as the index must give them: by entity index. */ +const expectedRows = (scenario: Scenario): (readonly [number, number])[][] => groups.map(value => [...scenario.held] + .filter(([, held]) => held.group === value && !held.hidden) + .map(([entity, held]): readonly [number, number] => [entity >>> 0, held.serial]) + .sort((a, b) => a[0] - b[0])); + +const orderSumOf = (bucket: readonly (readonly [number, number])[]): number => bucket.reduce((total, [, value], i) => total + (i + 1) * value, 0); + +const checkRows = async (scenario: Scenario): Promise => { + await scenario.world.run(readRows); + expect(rows).toEqual(expectedRows(scenario)); +}; +const checkLengths = async (scenario: Scenario): Promise => { + await scenario.world.run(readLengths); + expect(lengths).toEqual(expectedRows(scenario).map(bucket => bucket.length)); +}; + +/** A round of random changes: entities moved between buckets, hidden and shown, given a tag and plain again, despawned and spawned. */ +const randomChanges = (scenario: Scenario, next: () => number, count: number): Change[] => { + const entities = [...scenario.held.keys()]; + const pick = (): Entity => entities[Math.floor(next() * entities.length)]; + const used = new Set(); + const changes: Change[] = []; + for (let k = 0; k < count; k++) { + const entity = pick(); + const choice = next(); + if (choice < 0.08) { + changes.push({ kind: "spawn", group: Math.floor(next() * 3), serial: scenario.next++, extra: next() < 0.5 }); + continue; + } + // One change an entity a round, so the test's model applies them as the sync point does. + if (used.has(entity)) continue; + used.add(entity); + const held = scenario.held.get(entity); + if (held === undefined) continue; + if (choice < 0.6) changes.push({ kind: "group", entity, group: (held.group + 1 + Math.floor(next() * 2)) % 3 }); + else if (choice < 0.7) changes.push({ kind: "despawn", entity }); + else if (choice < 0.8) changes.push({ kind: held.hidden ? "show" : "hide", entity }); + else changes.push({ kind: next() < 0.5 ? "extra" : "plain", entity }); + } + return changes; +}; + +const createScenario = (wasm = true): Scenario => ({ world: createWorld({ [workers]: 0 }, { wasm }), held: new Map(), next: 0 }); + +describe("a hash index's long buckets", () => { + it.each([[1], [2], [3], [4]])("give their rows in entity order after random changes, read by length and by position, seed %i", async seed => { + const scenario = createScenario(); + await using _world = scenario.world; + const next = generator(seed); + await applyChanges(scenario, spawnMany(scenario, 600, k => k % 3)); + await checkRows(scenario); + for (let round = 0; round < 80; round++) { + await applyChanges(scenario, randomChanges(scenario, next, Math.floor(next() * 60))); + const mode = Math.floor(next() * 4); + if (mode === 0 || mode === 2) await checkLengths(scenario); + if (mode === 1 || mode === 2) await checkRows(scenario); + } + await checkRows(scenario); + expect(expectedRows(scenario).every(bucket => bucket.length >= 64)).toBe(true); + }); + it("give their rows in entity order after an entity leaves and comes back, and one moves between buckets each run", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + await applyChanges(scenario, spawnMany(scenario, 300, k => k % 2)); + await checkRows(scenario); + const entities = [...scenario.held.keys()]; + for (let k = 0; k < 400; k++) { + const entity = entities[(k * 37) % entities.length]; + const held = scenario.held.get(entity); + if (held === undefined) continue; + await applyChanges(scenario, [k % 5 === 0 ? { kind: held.hidden ? "show" : "hide", entity } : { kind: "group", entity, group: 1 - held.group }]); + if (k % 7 === 0) await checkRows(scenario); + else await checkLengths(scenario); + } + await checkRows(scenario); + }); + it("give their rows in entity order after a refresh that builds the index again, and changes after it", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + await applyChanges(scenario, spawnMany(scenario, 6000, k => k % 3)); + await checkLengths(scenario); + const next = generator(9); + for (let round = 0; round < 6; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 30)); + await checkLengths(scenario); + // More changes than a refresh patches: the index is built again, its unsorted buckets with it. + const everyOther = [...scenario.held].filter((_, k) => k % 2 === round % 2).map(([entity, held]): Change => ({ kind: "group", entity, group: (held.group + 1) % 3 })); + await applyChanges(scenario, [...randomChanges(scenario, next, 30).filter(change => change.kind === "spawn"), ...everyOther]); + await checkLengths(scenario); + await applyChanges(scenario, randomChanges(scenario, next, 30)); + await checkRows(scenario); + } + }); + it("give their rows in entity order after a build in phases, and changes after it", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + // Enough entities for a build in phases (`rebuildPhased`). + await applyChanges(scenario, spawnMany(scenario, 70_000, k => k % 2)); + await checkLengths(scenario); + const next = generator(11); + for (let round = 0; round < 4; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 50)); + await checkLengths(scenario); + await applyChanges(scenario, randomChanges(scenario, next, 50)); + await checkRows(scenario); + } + expect((await scenario.world.tables()).phasedBuilds).toBeGreaterThan(0); + }); +}); + +describe.each([[true], [false]])("kernels reading a hash index's long buckets, WASM %s", wasm => { + it("read their rows in entity order, as a main-thread system does, after changes left them unsorted", async () => { + const scenario = createScenario(wasm); + await using _world = scenario.world; + await scenario.world.run(spawnReaders); + await applyChanges(scenario, spawnMany(scenario, 600, k => k % 3)); + const next = generator(5); + for (let round = 0; round < 12; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 40)); + // Read by length only, so the buckets stay unsorted for the kernel. + await checkLengths(scenario); + await scenario.world.run(orderSum); + await scenario.world.run(orderSumByHand); + sums = { kernel: [], byHand: [] }; + await scenario.world.run(readSums); + const expected = expectedRows(scenario).map(orderSumOf); + expect(sums.kernel).toEqual(expected); + expect(sums.byHand).toEqual(expected); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/index-hash.ts b/packages/ecs/src/implementation/world/index-hash.ts index b0b55d36..1eebf6d5 100644 --- a/packages/ecs/src/implementation/world/index-hash.ts +++ b/packages/ecs/src/implementation/world/index-hash.ts @@ -18,9 +18,19 @@ const freedCapacity = 0xFFFFFFFF; /** * A hash index: an open-addressing table of buckets by key, linear probing, at most half full, each slot a key's hash * and its bucket, so a probe reads one line until a hash matches; each bucket a record of its hash, segment and key, - * its segment of one entries buffer with spare slots holding its entries in entity order. Every array is in the arena, - * so other threads read it where the world's thread wrote it. By entity index, the bucket it's filed in, so its old - * bucket is found without its old key. + * its segment of one entries buffer with spare slots holding its entries in entity order. Every array readers use is + * in the arena, so other threads read it where the world's thread wrote it. By entity index, the bucket it's filed in, + * so its old bucket is found without its old key. + * + * A change to a bucket of at least `unsortedMinimum` entries, in an index that isn't unique, may leave it unsorted: + * its entries in entity order up to its tail, with holes where entries left (archetype -1, the entity index kept for + * binary search), then a tail of entries appended in any order, each one's position kept by entity index. Its + * record's length counts its entries, not its holes, so reading `length` needs no order. It's sorted before anything + * reads its entries by position: on the world's thread by a list's `at`, and every unsorted bucket before the index's + * arrays are handed to a kernel or another thread; when its segment is full and has holes, which sorting closes in + * place; and once its holes and tail pass four times its entries. A segment an unsorted bucket outgrows gets half its + * length more spare slots, so sorts to make room come once every so many changes, O(1) amortized each. What only + * unsorted buckets use is the world's thread's alone, outside the arena. */ export type HashTable = { readonly memory: Memory; @@ -48,16 +58,41 @@ export type HashTable = { size: number; /** By component a lookup watches: by bucket, the newest tick its value may have changed in a row there. */ readonly watched: Map; + /** How many buckets are unsorted, and a list holding each of them, and some sorted since. */ + unsorted: number; + unsortedBuckets: Int32Array; + unsortedListed: number; + /** + * By bucket id: where its tail starts, or -1 when it's sorted; the slots its entries and holes take; and a position + * no hole is before. + */ + tails: Int32Array; + extents: Int32Array; + holes: Int32Array; + /** By entity index: its position in its bucket's tail, valid only while it's there. */ + positionOf: Int32Array; + /** Scratch for sorting a tail: twice its words. */ + spare: Int32Array; + /** The entries as 8-byte words, and a scratch as 8- and 4-byte words, for long moves (`moveWide`). */ + wide: Float64Array; + scratch: Float64Array; + scratchWords: Int32Array; }; /** What lookups read of a hash table: on the world's thread the table, on a joined thread its views of it. */ -export type HashView = Pick & { readonly watched: ReadonlyMap }; +export type HashView = Pick & { readonly watched: ReadonlyMap }; + +/** Whether a view is the world's table itself, whose unsorted buckets a list sorts before reading them by position. */ +export const isHashTable = (view: HashView): view is HashTable => "tails" in view; const minimumSlots = 16; /** Slots the table keeps for each bucket at least: it's at most half full. */ const slotsPerBucket = 2; +/** Entries a bucket holds at least before a change may leave it unsorted; a shorter one shifts its entries. */ +export const unsortedMinimum = 64; + /** Whether `slots` slots hold `buckets` buckets. */ export const slotsHold = (slots: number, buckets: number): boolean => slotsPerBucket * buckets + 2 <= slots; @@ -78,6 +113,17 @@ export const createHashTable = (memory: Memory, words: number): HashTable => ({ bucketOf: allocateArray(memory, Int32Array, 64).fill(-1), size: 0, watched: new Map(), + unsorted: 0, + unsortedBuckets: new Int32Array(8), + unsortedListed: 0, + tails: new Int32Array(8).fill(-1), + extents: new Int32Array(8), + holes: new Int32Array(8), + positionOf: new Int32Array(64), + spare: new Int32Array(0), + wide: new Float64Array(0), + scratch: new Float64Array(0), + scratchWords: new Int32Array(0), }); /** Frees every array, once nothing reads the table again. */ @@ -96,10 +142,19 @@ const regrow = (memory: Memor return grown; }; +/** A copy of `array`, `length` long, the rest filled with `fill`. */ +const grown = (array: Int32Array, length: number, fill: number): Int32Array => { + const copy = new Int32Array(length); + copy.set(array); + if (fill !== 0) copy.fill(fill, array.length); + return copy; +}; + /** Grows what's by entity index to cover `length` indexes. */ export const coverEntities = (table: HashTable, length: number): void => { if (table.bucketOf.length >= length) return; table.bucketOf = regrow(table.memory, table.bucketOf, Int32Array, Math.max(length, 2 * table.bucketOf.length), table.bucketOf.length, -1); + table.positionOf = grown(table.positionOf, table.bucketOf.length, 0); }; /** Grows what's by bucket id to hold `count` buckets. */ @@ -111,6 +166,9 @@ export const coverBuckets = (table: HashTable, count: number): void => { table.records = regrow(memory, table.records, Uint32Array, length * stride, capacity * stride, 0); table.ticks = regrow(memory, table.ticks, Float64Array, length, capacity, 0); for (const [component, ticks] of table.watched) table.watched.set(component, regrow(memory, ticks, Float64Array, length, capacity, 0)); + table.tails = grown(table.tails, length, -1); + table.extents = grown(table.extents, length, 0); + table.holes = grown(table.holes, length, 0); }; /** A bucket's segment's first entry, length and capacity. */ @@ -203,29 +261,97 @@ const allocateSegment = (table: HashTable, capacity: number): number => { return start; }; -/** Gives a bucket room for `length` entries, moving its segment to the end of the buffer when it doesn't fit. */ -const reserve = (table: HashTable, bucket: number, length: number): void => { +/** The slots a bucket's entries take, with an unsorted one's holes. */ +const extentOf = (table: HashTable, bucket: number): number => table.tails[bucket] < 0 ? lengthOf(table, bucket) : table.extents[bucket]; + +/** + * Gives a bucket room for `slots` slots, moving its segment to the end of the buffer when they don't fit; an unsorted + * one's gets half as many again spare, since its tail grows into them. + */ +const reserve = (table: HashTable, bucket: number, slots: number): void => { const base = bucket * table.stride; - if (table.records[base + recordCapacity] >= length) return; - const capacity = capacityFor(length); + if (table.records[base + recordCapacity] >= slots) return; + const capacity = capacityFor(slots) + (table.tails[bucket] >= 0 ? slots >> 1 : 0); + const kept = extentOf(table, bucket); const start = allocateSegment(table, capacity); - const { entries, records } = table; - const from = records[base + recordStart] * entryWords; - entries.copyWithin(start * entryWords, from, from + records[base + recordLength] * entryWords); + const { records } = table; + move(table, start * entryWords, records[base + recordStart] * entryWords, kept * entryWords); table.garbage += records[base + recordCapacity]; records[base + recordStart] = start; records[base + recordCapacity] = capacity; }; +/** The table's entries as 8-byte words, made again when its entries were. */ +const wideOf = (table: HashTable): Float64Array => { + const { entries } = table; + // Its last 8-byte word may take 4 bytes past the entries, which are in its block: blocks are 64-byte aligned. + const length = (entries.length + 1) >> 1; + if (table.wide.buffer !== entries.buffer || table.wide.byteOffset !== entries.byteOffset || table.wide.length !== length) { + table.wide = new Float64Array(entries.buffer, entries.byteOffset, length); + } + return table.wide; +}; + +/** Scratch of at least `words` 4-byte words, as 4- and 8-byte words. */ +const scratchOf = (table: HashTable, words: number): Float64Array => { + if (2 * table.scratch.length < words) { + table.scratch = new Float64Array(Math.max((words + 1) >> 1, 2 * table.scratch.length)); + table.scratchWords = new Int32Array(table.scratch.buffer); + } + return table.scratch; +}; + /** - * Moves `words` of entries, a multiple of `entryWords`, from `from` to `to` within one buffer: by hand, an entry a step, - * when few, which a native call would cost more than. + * Moves `words` of entries from `from` to `to` in a table's buffer. Shared memory copies an element at a time, so a + * long move copies 8-byte words, 8 times faster than 4-byte ones: directly when both ends have the same parity, an + * edge word by hand; else through a scratch, which shifts them by a word, the target's edge words kept. */ -const move = (entries: Int32Array, to: number, from: number, words: number): void => { - if (words > 96) { +const moveWide = (table: HashTable, to: number, from: number, words: number): void => { + const { entries } = table; + if (!table.memory.shared) { entries.copyWithin(to, from, from + words); return; } + const wide = wideOf(table); + if (((to ^ from) & 1) === 0) { + const odd = from & 1; + const even = (words - odd) & ~1; + const rest = words - odd - even; + if (to < from) { + if (odd === 1) entries[to] = entries[from]; + wide.copyWithin((to + odd) >> 1, (from + odd) >> 1, (from + odd + even) >> 1); + if (rest === 1) entries[to + words - 1] = entries[from + words - 1]; + } + else { + if (rest === 1) entries[to + words - 1] = entries[from + words - 1]; + wide.copyWithin((to + odd) >> 1, (from + odd) >> 1, (from + odd + even) >> 1); + if (odd === 1) entries[to] = entries[from]; + } + return; + } + const sourceFirst = from & ~1; + const sourceEnd = (from + words + 1) & ~1; + const targetFirst = to & ~1; + const targetEnd = (to + words + 1) & ~1; + const scratch = scratchOf(table, Math.max(sourceEnd - sourceFirst, targetEnd - targetFirst) + 2); + const scratchWords = table.scratchWords; + scratch.set(wide.subarray(sourceFirst >> 1, sourceEnd >> 1)); + scratchWords.copyWithin(to - targetFirst, from - sourceFirst, from - sourceFirst + words); + if ((to & 1) === 1) scratchWords[0] = entries[targetFirst]; + if (((to + words) & 1) === 1) scratchWords[targetEnd - targetFirst - 1] = entries[to + words]; + wide.set(scratch.subarray(0, (targetEnd - targetFirst) >> 1), targetFirst >> 1); +}; + +/** + * Moves `words` of entries, a multiple of `entryWords`, from `from` to `to` within a table's buffer: by hand, an entry + * a step, when few, which a native call would cost more than. + */ +const move = (table: HashTable, to: number, from: number, words: number): void => { + const { entries } = table; + if (words > 96) { + moveWide(table, to, from, words); + return; + } if (to < from) { for (let k = 0; k < words; k += 3) { entries[to + k] = entries[from + k]; @@ -242,15 +368,16 @@ const move = (entries: Int32Array, to: number, from: number, words: number): voi } }; -/** Where entity index `index` is in a bucket, or where it would go, by binary search: `~position` when it isn't there. */ -export const positionIn = (table: HashTable, bucket: number, index: number): number => { - const { entries } = table; - const base = startOf(table, bucket); +/** + * Where entity index `index` is among the `end` entries in entity order from entry `start`, holes included, or where it + * would go, by binary search: `~position` when it isn't there. + */ +const search = (entries: Int32Array, start: number, end: number, index: number): number => { let low = 0; - let high = lengthOf(table, bucket); + let high = end; while (low < high) { const middle = (low + high) >>> 1; - const at = entries[(base + middle) * entryWords]; + const at = entries[(start + middle) * entryWords]; if (at < index) low = middle + 1; else if (at > index) high = middle; else return middle; @@ -258,9 +385,24 @@ export const positionIn = (table: HashTable, bucket: number, index: number): num return ~low; }; +/** Where entity index `index`'s entry is in a bucket, or -1 when it isn't there. */ +const findEntry = (table: HashTable, bucket: number, index: number): number => { + const { entries } = table; + const start = startOf(table, bucket); + const tail = table.tails[bucket]; + if (tail < 0) { + const position = search(entries, start, lengthOf(table, bucket), index); + return position < 0 ? -1 : position; + } + const position = table.positionOf[index]; + if (position >= tail && position < table.extents[bucket] && entries[(start + position) * entryWords] === index) return position; + const found = search(entries, start, tail, index); + return found >= 0 && entries[(start + found) * entryWords + 1] >= 0 ? found : -1; +}; + /** Updates where entity index `index` is: its archetype and row, in the bucket it's filed in. */ export const relocate = (table: HashTable, bucket: number, index: number, archetype: number, row: number): void => { - const position = positionIn(table, bucket, index); + const position = findEntry(table, bucket, index); if (position < 0) return; const at = (startOf(table, bucket) + position) * entryWords; table.entries[at + 1] = archetype; @@ -273,82 +415,286 @@ const countEmpty = (table: HashTable, before: number, after: number): void => { else if (before > 0 && after === 0) table.empty++; }; -/** Removes entity index `index` from a bucket, stamping it. */ -export const removeEntry = (table: HashTable, bucket: number, index: number, tick: number): void => { - const position = positionIn(table, bucket, index); - if (position < 0) return; +/** Marks a sorted bucket unsorted, with an empty tail and no holes, listing it. */ +const unsort = (table: HashTable, bucket: number): void => { + const length = lengthOf(table, bucket); + table.tails[bucket] = length; + table.extents[bucket] = length; + table.holes[bucket] = length; + table.unsorted++; + let listed = table.unsortedListed; + // The list drops the buckets sorted since they were listed once they're most of it. + if (listed >= 2 * table.unsorted + 8) { + const list = table.unsortedBuckets; + let kept = 0; + for (let k = 0; k < listed; k++) if (table.tails[list[k]] >= 0) list[kept++] = list[k]; + listed = kept; + } + if (listed === table.unsortedBuckets.length) table.unsortedBuckets = grown(table.unsortedBuckets, 2 * listed, 0); + table.unsortedBuckets[listed] = bucket; + table.unsortedListed = listed + 1; +}; + +/** Marks an unsorted bucket sorted. */ +const sorted = (table: HashTable, bucket: number): void => { + table.tails[bucket] = -1; + if (--table.unsorted === 0) table.unsortedListed = 0; +}; + +/** + * After a change to an unsorted bucket: sorted when it's empty or in order, or once its holes and tail pass four times + * its entries and 64 more, which bounds what a bucket nothing reads by position keeps. + */ +const settle = (table: HashTable, bucket: number): void => { + const length = lengthOf(table, bucket); + const extent = table.extents[bucket]; + const tail = table.tails[bucket]; + if (length === 0 || (extent === length && tail === extent)) sorted(table, bucket); + else if (2 * extent - length - tail > (length << 2) + unsortedMinimum) sortBucket(table, bucket); +}; + +/** Removes entity index `index` from a bucket, stamping it; a long one may be left unsorted when `unsorting`. */ +export const removeEntry = (table: HashTable, bucket: number, index: number, tick: number, unsorting: boolean): void => { const record = bucket * table.stride; const length = table.records[record + recordLength]; - const base = table.records[record + recordStart] * entryWords; - move(table.entries, base + position * entryWords, base + (position + 1) * entryWords, (length - position - 1) * entryWords); + const start = table.records[record + recordStart]; + const { entries } = table; + const tail = table.tails[bucket]; + if (tail < 0) { + const position = search(entries, start, length, index); + if (position < 0) return; + if (position < length - 1) { + if (!unsorting || length < unsortedMinimum) { + const base = start * entryWords; + move(table, base + position * entryWords, base + (position + 1) * entryWords, (length - position - 1) * entryWords); + } + else { + unsort(table, bucket); + entries[(start + position) * entryWords + 1] = -1; + table.holes[bucket] = position; + } + } + } + else { + const extent = table.extents[bucket]; + const position = table.positionOf[index]; + if (position >= tail && position < extent && entries[(start + position) * entryWords] === index) { + // Out of the tail, its last entry taking its place. + const at = (start + position) * entryWords; + const last = (start + extent - 1) * entryWords; + if (at !== last) { + entries[at] = entries[last]; + entries[at + 1] = entries[last + 1]; + entries[at + 2] = entries[last + 2]; + table.positionOf[entries[at]] = position; + } + table.extents[bucket] = extent - 1; + } + else { + const found = search(entries, start, tail, index); + if (found < 0 || entries[(start + found) * entryWords + 1] < 0) return; + if (found === tail - 1 && tail === extent) { + table.tails[bucket] = tail - 1; + table.extents[bucket] = extent - 1; + table.holes[bucket] = Math.min(table.holes[bucket], tail - 1); + } + else { + entries[(start + found) * entryWords + 1] = -1; + table.holes[bucket] = Math.min(table.holes[bucket], found); + } + } + } table.records[record + recordLength] = length - 1; table.ticks[bucket] = tick; table.size--; countEmpty(table, length, length - 1); + if (table.tails[bucket] >= 0) settle(table, bucket); }; -/** Inserts entity index `index` at its archetype and row into a bucket, in entity order, stamping it. */ -export const insertEntry = (table: HashTable, bucket: number, index: number, archetype: number, row: number, tick: number): void => { - const record = bucket * table.stride; - const length = table.records[record + recordLength]; - reserve(table, bucket, length + 1); - const found = positionIn(table, bucket, index); - const position = found < 0 ? ~found : found; - const base = table.records[record + recordStart] * entryWords; +/** Appends an entry to an unsorted bucket's tail. */ +const appendTail = (table: HashTable, bucket: number, index: number, archetype: number, row: number): void => { + const extent = table.extents[bucket]; + reserve(table, bucket, extent + 1); + const at = (startOf(table, bucket) + extent) * entryWords; const { entries } = table; - move(entries, base + (position + 1) * entryWords, base + position * entryWords, (length - position) * entryWords); - const at = base + position * entryWords; entries[at] = index; entries[at + 1] = archetype; entries[at + 2] = row; + table.positionOf[index] = extent; + table.extents[bucket] = extent + 1; +}; + +/** + * Inserts entity index `index`, which the bucket doesn't hold, at its archetype and row, stamping it: in entity order, + * or when `unsorting`, into a long bucket's tail, or the hole it left. + */ +export const insertEntry = (table: HashTable, bucket: number, index: number, archetype: number, row: number, tick: number, unsorting: boolean): void => { + const record = bucket * table.stride; + const length = table.records[record + recordLength]; + // A full unsorted bucket with holes closes them in place rather than moving its segment to grow it. + if (table.tails[bucket] >= 0 && table.extents[bucket] > length && table.extents[bucket] >= table.records[record + recordCapacity]) sortBucket(table, bucket); + const tail = table.tails[bucket]; + if (tail < 0) { + reserve(table, bucket, length + 1); + const start = table.records[record + recordStart]; + const { entries } = table; + const after = length === 0 || entries[(start + length - 1) * entryWords] < index; + if (after || !unsorting || length < unsortedMinimum) { + const found = after ? ~length : search(entries, start, length, index); + const position = found < 0 ? ~found : found; + const base = start * entryWords; + move(table, base + (position + 1) * entryWords, base + position * entryWords, (length - position) * entryWords); + const at = base + position * entryWords; + entries[at] = index; + entries[at + 1] = archetype; + entries[at + 2] = row; + } + else { + unsort(table, bucket); + appendTail(table, bucket, index, archetype, row); + } + } + else { + const start = table.records[record + recordStart]; + const { entries } = table; + const extent = table.extents[bucket]; + const found = search(entries, start, tail, index); + if (found >= 0) { + // The hole it left. + const at = (start + found) * entryWords; + entries[at + 1] = archetype; + entries[at + 2] = row; + } + else if (~found === tail && tail === extent) { + // After every entry in order, with no tail: still in order. + reserve(table, bucket, extent + 1); + const at = (startOf(table, bucket) + extent) * entryWords; + table.entries[at] = index; + table.entries[at + 1] = archetype; + table.entries[at + 2] = row; + if (table.holes[bucket] === tail) table.holes[bucket] = tail + 1; + table.tails[bucket] = tail + 1; + table.extents[bucket] = extent + 1; + } + else appendTail(table, bucket, index, archetype, row); + } table.records[record + recordLength] = length + 1; table.ticks[bucket] = tick; table.size++; countEmpty(table, length, length + 1); + if (table.tails[bucket] >= 0) settle(table, bucket); +}; + +/** Copies entry `from` of `source` to entry `to` of `target`. */ +const copyEntry = (target: Int32Array, to: number, source: Int32Array, from: number): void => { + target[to * entryWords] = source[from * entryWords]; + target[to * entryWords + 1] = source[from * entryWords + 1]; + target[to * entryWords + 2] = source[from * entryWords + 2]; }; /** - * Applies many removals and insertions to one bucket in one merge: `removed` entity indexes and `added` entries - * (`entryWords` each), both in entity order. Keeps the bucket's entries in entity order. + * Sorts `count` entries by entity index, the first `count * entryWords` words of `spare`, which holds twice that: runs + * of eight by insertion, then merged in passes between its halves. */ -export const mergeEntries = (table: HashTable, bucket: number, removed: Int32Array, removedCount: number, added: Int32Array, addedCount: number, scratch: Int32Array, tick: number): void => { - const record = bucket * table.stride; - const length = table.records[record + recordLength]; - const result = length - removedCount + addedCount; - const base = table.records[record + recordStart] * entryWords; - // The bucket's current entries, copied out, merged back with the insertions, skipping the removals. - scratch.set(table.entries.subarray(base, base + length * entryWords)); - reserve(table, bucket, result); - const target = table.entries; - let to = table.records[record + recordStart] * entryWords; - let r = 0; - let a = 0; - for (let k = 0; k < length; k++) { - const index = scratch[k * entryWords]; - while (r < removedCount && removed[r] < index) r++; - while (a < addedCount && added[a * entryWords] < index) { - target[to++] = added[a * entryWords]; - target[to++] = added[a * entryWords + 1]; - target[to++] = added[a * entryWords + 2]; - a++; +const sortEntries = (spare: Int32Array, count: number): void => { + const run = 8; + for (let low = 0; low < count; low += run) { + for (let k = low + 1, high = Math.min(count, low + run); k < high; k++) { + const index = spare[k * entryWords]; + const archetype = spare[k * entryWords + 1]; + const row = spare[k * entryWords + 2]; + let at = k; + while (at > low && spare[(at - 1) * entryWords] > index) { + copyEntry(spare, at, spare, at - 1); + at--; + } + spare[at * entryWords] = index; + spare[at * entryWords + 1] = archetype; + spare[at * entryWords + 2] = row; + } + } + let from = 0; + let to = count; + for (let width = run; width < count; width *= 2) { + for (let low = 0; low < count; low += 2 * width) { + const middle = Math.min(low + width, count); + const high = Math.min(low + 2 * width, count); + let a = low; + let b = middle; + for (let k = low; k < high; k++) { + if (b >= high || (a < middle && spare[(from + a) * entryWords] <= spare[(from + b) * entryWords])) copyEntry(spare, to + k, spare, from + a++); + else copyEntry(spare, to + k, spare, from + b++); + } } - if (r < removedCount && removed[r] === index) { - r++; + const swap = from; + from = to; + to = swap; + } + if (from !== 0) spare.copyWithin(0, from * entryWords, (from + count) * entryWords); +}; + +/** + * Puts an unsorted bucket's entries in entity order again: its tail sorted aside, its holes closed from the first, and + * the tail merged in from the end, so only the entries past the first hole and the first tail entry's place move. + */ +export const sortBucket = (table: HashTable, bucket: number): void => { + const tail = table.tails[bucket]; + const extent = table.extents[bucket]; + const start = startOf(table, bucket); + const { entries } = table; + const count = extent - tail; + if (table.spare.length < 2 * count * entryWords) table.spare = new Int32Array(Math.max(2 * count * entryWords, 2 * table.spare.length)); + const { spare } = table; + for (let k = 0; k < count; k++) copyEntry(spare, k, entries, start + tail + k); + sortEntries(spare, count); + // Holes closed, a run of entries at a time; past the last hole, the rest in one move. + let left = extent - lengthOf(table, bucket); + let write = Math.min(table.holes[bucket], tail); + let read = write; + while (read < tail) { + if (left === 0) { + if (write !== read) move(table, (start + write) * entryWords, (start + read) * entryWords, (tail - read) * entryWords); + write += tail - read; + break; + } + if (entries[(start + read) * entryWords + 1] < 0) { + read++; + left--; continue; } - target[to++] = index; - target[to++] = scratch[k * entryWords + 1]; - target[to++] = scratch[k * entryWords + 2]; + let end = read + 1; + while (end < tail && entries[(start + end) * entryWords + 1] >= 0) end++; + if (write !== read) move(table, (start + write) * entryWords, (start + read) * entryWords, (end - read) * entryWords); + write += end - read; + read = end; } - for (; a < addedCount; a++) { - target[to++] = added[a * entryWords]; - target[to++] = added[a * entryWords + 1]; - target[to++] = added[a * entryWords + 2]; + // The tail merged in from the end: each of its entries, from the last, after the run of entries above it. + let kept = write; + for (let k = count - 1; k >= 0; k--) { + const index = spare[k * entryWords]; + const found = search(entries, start, kept, index); + const position = found < 0 ? ~found : found; + const to = position + k + 1; + if (kept > position) move(table, (start + to) * entryWords, (start + position) * entryWords, (kept - position) * entryWords); + copyEntry(entries, start + position + k, spare, k); + kept = position; } - table.records[record + recordLength] = result; - table.ticks[bucket] = tick; - table.size += result - length; - countEmpty(table, length, result); + sorted(table, bucket); +}; + +/** Sorts every unsorted bucket, before the table's arrays are handed to a reader that reads entries by position. */ +export const sortHashTable = (table: HashTable): void => { + const list = table.unsortedBuckets; + for (let k = 0; k < table.unsortedListed && table.unsorted > 0; k++) if (table.tails[list[k]] >= 0) sortBucket(table, list[k]); + table.unsortedListed = 0; +}; + +/** Forgets every unsorted bucket, before the table is built again. */ +export const forgetUnsorted = (table: HashTable): void => { + const list = table.unsortedBuckets; + for (let k = 0; k < table.unsortedListed; k++) table.tails[list[k]] = -1; + table.unsorted = 0; + table.unsortedListed = 0; }; /** @@ -374,8 +720,9 @@ export const tidyHashTable = (table: HashTable, tick: number): void => { if (table.garbage > Math.max(1024, table.used - table.garbage)) compact(table); }; -/** Lays every bucket's segment out again, one after another, with their spare slots. */ +/** Lays every bucket's segment out again, one after another, with their spare slots, each sorted first. */ const compact = (table: HashTable): void => { + sortHashTable(table); const { records, stride } = table; let total = 0; for (let bucket = 0; bucket < table.count; bucket++) if (!isFreed(table, bucket)) total += capacityFor(records[bucket * stride + recordLength]); @@ -405,6 +752,7 @@ const compact = (table: HashTable): void => { */ export const loadHashTable = (table: HashTable, count: number, entries: Int32Array, keys: Uint32Array, hashes: Uint32Array, bucketOfEntry: Int32Array, tick: number, entityCount: number): void => { const { words, stride } = table; + forgetUnsorted(table); coverEntities(table, entityCount); table.bucketOf.fill(-1); // Sized for as many buckets as it last held, growing as more are found, so the table is as small as its keys need. diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 5122897e..f5722b9b 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -25,6 +25,8 @@ const rank = Symbol("indexJoinedTest/rank"); const label = Symbol("indexJoinedTest/label"); const name = Symbol("indexJoinedTest/name"); const title = Symbol("indexJoinedTest/title"); +const band = Symbol("indexJoinedTest/band"); +const bandReader = Symbol("indexJoinedTest/bandReader"); declare module "@adobe/ecs" { export interface Threads { @@ -40,6 +42,8 @@ declare module "@adobe/ecs" { [label]: { type: { readonly text: string }; thread: "indexJoinedTest" }; [name]: { type: { readonly text: string }; thread: "indexJoinedTest" }; [title]: { type: string }; + [band]: { type: number }; + [bandReader]: { type: true }; } } Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); @@ -54,6 +58,8 @@ const blank = { text: "" }; Component.register(label, { type: { ...text, default: blank }, thread: "indexJoinedTest" }); Component.register(name, { type: text, thread: "indexJoinedTest" }); Component.register(title, { type: { type: "string" } }); +Component.register(band, { type: { type: "integer", minimum: 0, maximum: 1 } }); +Component.register(bandReader, { type: { const: true } }); const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); @@ -61,6 +67,41 @@ const lanes = Index.create({ name: "indexJoinedTest/lanes", key: [lane, rank], s const names = Index.create({ name: "indexJoinedTest/names", key: [name] }); const titles = Index.create({ name: "indexJoinedTest/titles", key: [title] }); const weighted = Index.create({ name: "indexJoinedTest/weighted", key: [], components: { has: [weight] } }); +/** Two long buckets, which changes leave unsorted until a list is read by position. */ +const bands = Index.create({ name: "indexJoinedTest/bands", key: [band], components: { has: [serial] } }); + +/** On the joined thread: the serials of a band's rows, in entity order. */ +const describeBand = System.create({ + name: "indexJoinedTest/describeBand", + components: { read: [target], write: [label], has: [bandReader] }, + lookup: { inBand: { index: bands, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target]); + const serials: number[] = []; + for (let i = 0; i < list.length; i++) serials.push(list.at(i)[serial]); + row[label] = { text: `${row[target]}:${serials.join(",")}` }; +}); +const seedBands = System.create({ name: "indexJoinedTest/seedBands" }, ({ commands }) => { + for (let k = 0; k < 300; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); + for (let k = 0; k < 2; k++) commands.spawn([target, label, bandReader])({ [target]: k, [label]: blank }); +}); +/** Moves some entities to the other band, despawns some and spawns others. */ +const stirBands = System.create({ name: "indexJoinedTest/stirBands", components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { + if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; + if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); + if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); +}); +/** Seeds the bands, then reads them on the thread around changes; every label after each read. */ +const bandScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: (readonly string[])[] = []; + await world.run(seedBands); + for (let round = 0; round < 6; round++) { + await world.run(describeBand); + seen.push(await labels(holder, world)); + await world.run(stirBands, { round }); + } + return seen; +}; /** On the joined thread, in the tier before the lookups, so a task of consecutive tiers there would see the index before it: moves some cells. */ const shift = System.create({ name: "indexJoinedTest/shift", components: { read: [serial], write: [cell, label] } }, row => { @@ -230,6 +271,20 @@ describe("systems on a joined thread looking up through indexes", () => { await remote.dispose(); await world.dispose(); }); + it("read a hash index's long buckets in entity order after changes left them unsorted, as the world's own systems do", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await bandScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await bandScenario(world, remote)).toEqual(alone); + expect(alone.every(round => round.length === 2 && round.every(text => text.split(",").length > 100))).toBe(true); + await remote.dispose(); + await world.dispose(); + }); it("release the index and its records once the last system using it there is removed", async () => { const driver = backToBack(); const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); diff --git a/packages/ecs/src/implementation/world/index-lookups.ts b/packages/ecs/src/implementation/world/index-lookups.ts index 2ca976e1..3f5b471f 100644 --- a/packages/ecs/src/implementation/world/index-lookups.ts +++ b/packages/ecs/src/implementation/world/index-lookups.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Scalar } from '../../interface/registry/layout.js'; import { encodeScalar, encodeValue, hashWords, orderF32, orderF64, partWords, type KeyPart } from './index-keys.js'; -import { entryWords, findBucket, lengthOf, recordKey, recordLength, recordStart, startOf, type HashView } from './index-hash.js'; +import { entryWords, findBucket, isHashTable, lengthOf, recordKey, recordLength, recordStart, sortBucket, startOf, type HashView } from './index-hash.js'; import { blockAt, blockBase, bound, firstPosition, treeMaximum, type SortedView } from './index-sorted.js'; import { sharedRows, type Row, type RowArchetype, type RowHost } from './rows.js'; import { idOf, type Key, type State } from './state.js'; @@ -192,7 +192,9 @@ const hashAt = function (this: HashList, position: number): object | undefined { const { bucket, hash } = found; const record = bucket * hash.stride; if (bucket < 0 || !(position >= 0 && position < hash.records[record + recordLength])) return undefined; - const at = (hash.records[record + recordStart] + position) * entryWords; + // On the world's thread, a bucket a change left unsorted is sorted by the first read by position. + if (hash.unsorted !== 0 && isHashTable(hash) && hash.tails[bucket] >= 0) sortBucket(hash, bucket); + const at =(hash.records[record + recordStart] + position) * entryWords; return rowAt(found.lookup, found.pool, position, hash.entries[at + 1], hash.entries[at + 2]); }; diff --git a/packages/ecs/src/implementation/world/index-workers.cases.ts b/packages/ecs/src/implementation/world/index-workers.cases.ts index 801f401d..b19a7f82 100644 --- a/packages/ecs/src/implementation/world/index-workers.cases.ts +++ b/packages/ecs/src/implementation/world/index-workers.cases.ts @@ -15,4 +15,13 @@ export const indexWorkersCases = (load: () => Promise) => descri // Targets' tallies through the index without a key: its length in millions, then a stride of its weights. expect(three.rows.filter(row => /^\[\d+,-?\d+,\d{7,}\]$/.test(row)).length).toBeGreaterThan(900); }); + it.each([[true], [false]])("read a hash index's long buckets in entity order after changes left them unsorted, WASM %s", async wasm => { + const fixture = await load(); + for (const threads of [0, 3]) { + const orders = await fixture.bandOrders(threads, wasm); + expect(orders.kernel).toEqual(orders.byHand); + expect(orders.kernel.every(round => round[0] > 0)).toBe(true); + if (threads > 0) expect(orders.tasks).toBeGreaterThan(0); + } + }); }); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index 740ef7ad..e2f4d400 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -21,6 +21,9 @@ const seen = Symbol("indexWorkersTest/seen"); const serial = Symbol("indexWorkersTest/serial"); const target = Symbol("indexWorkersTest/target"); const tally = Symbol("indexWorkersTest/tally"); +const band = Symbol("indexWorkersTest/band"); +const bandSum = Symbol("indexWorkersTest/bandSum"); +const bandSumByHand = Symbol("indexWorkersTest/bandSumByHand"); declare module "@adobe/ecs" { interface Components { @@ -35,6 +38,9 @@ declare module "@adobe/ecs" { [serial]: { type: number }; [target]: { type: number }; [tally]: { type: number }; + [band]: { type: number }; + [bandSum]: { type: number }; + [bandSumByHand]: { type: number }; } } Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); @@ -48,6 +54,9 @@ Component.register(seen, { type: Entity.schema }); Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); Component.register(tally, { type: { type: "number" } }); +Component.register(band, { type: { type: "integer", minimum: 0, maximum: 1 } }); +Component.register(bandSum, { type: { type: "number" } }); +Component.register(bandSumByHand, { type: { type: "number" } }); const grid = Index.create({ name: "indexWorkersTest/grid", key: [cell], components: { has: [weight] } }); const lanes = Index.create({ name: "indexWorkersTest/lanes", key: [lane, rank], sorted: true }); @@ -190,3 +199,66 @@ export const simulate = async (threads: number, wasm: boolean): Promise<{ readon await ecs.dispose(); } }; + +/** Two long buckets, which changes leave unsorted until something reads them by position. */ +const bands = Index.create({ name: "indexWorkersTest/bands", key: [band], components: { has: [serial] } }); + +/** Each row's band's serials, each times its position plus one: a sum that tells the order. */ +const bandOrder = System.create({ + name: "indexWorkersTest/bandOrder", kernel: "cpu", + components: { read: [target], write: [bandSum] }, + lookup: { inBand: { index: bands, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target] % 2); + let sum = 0; + for (let i = 0; i < list.length; i++) sum += (i + 1) * list.at(i)[serial]; + row[bandSum] = sum; +}); +const bandOrderByHand = System.create({ + name: "indexWorkersTest/bandOrderByHand", + components: { read: [target], write: [bandSumByHand] }, + lookup: { inBand: { index: bands, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target] % 2); + let sum = 0; + for (let i = 0; i < list.length; i++) sum += (i + 1) * list.at(i)[serial]; + row[bandSumByHand] = sum; +}); +const seedBands = System.create({ name: "indexWorkersTest/seedBands" }, ({ commands }) => { + for (let k = 0; k < 600; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); + for (let k = 0; k < 512; k++) commands.spawn([target, bandSum, bandSumByHand])({ [target]: k, [bandSum]: 0, [bandSumByHand]: 0 }); +}); +/** Moves some entities to the other band, despawns some and spawns others. */ +const stirBands = System.create({ name: "indexWorkersTest/stirBands", components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { + if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; + if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); + if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); +}); + +/** + * Over rounds of changes, what a kernel reading the bands in order wrote, and what a main-thread system reading them + * after it wrote, each round's sorted; and the run tasks posted to workers. + */ +export const bandOrders = async (threads: number, wasm: boolean): Promise<{ readonly kernel: readonly (readonly number[])[]; readonly byHand: readonly (readonly number[])[]; readonly tasks: number }> => { + const counting = countingWorkers(); + const ecs = createWorld({ [workers]: threads }, { minimumChunk: 64, wasm, pool: counting.platform }); + try { + await ecs.run(seedBands); + const posted = (): number => counting.posted("run") + counting.posted("window"); + const before = posted(); + const kernel: number[][] = []; + const byHand: number[][] = []; + for (let round = 0; round < 6; round++) { + await ecs.run(stirBands, { round }); + await ecs.run(bandOrder); + await ecs.run(bandOrderByHand); + const values = await Promise.all((await select(ecs, { required: [target] })).map(entity => readEntity(ecs, entity, [bandSum, bandSumByHand]))); + kernel.push(values.map(found => found?.[bandSum] ?? -1).sort((a, b) => a - b)); + byHand.push(values.map(found => found?.[bandSumByHand] ?? -1).sort((a, b) => a - b)); + } + return { kernel, byHand, tasks: posted() - before }; + } + finally { + await ecs.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index 1d1cab2b..6c6567dc 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -9,7 +9,7 @@ import { chunkShift } from './kernel.js'; import { journalEnd } from './journal.js'; import { encodeStored, hashWords, keyPartsOf, missing, release as releaseInterned, retain, type KeyPart } from './index-keys.js'; import { - bucketFor, coverBuckets, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, loadHashTable, mergeEntries, place, placeAll, recordHash, + bucketFor, coverBuckets, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, loadHashTable, forgetUnsorted, place, placeAll, recordHash, recordKey, rehash, relocate, slotsHold, releaseHashTable, removeEntry, startOf, tidyHashTable, unwatchHashComponent, watchHashComponent, type HashTable, } from './index-hash.js'; import { allocateArray, freeArray } from './arrays.js'; @@ -69,8 +69,6 @@ export type WorldIndex = { pending: Int32Array; pendingArchetypes: Int32Array; pendingRows: Int32Array; - /** What an incremental refresh changes, by bucket: removals' entity indexes and insertions' entries. */ - operations: Int32Array; /** A unique index's insertions of a refresh, as bucket and entity index pairs. */ inserted: Int32Array; /** @@ -80,8 +78,6 @@ export type WorldIndex = { readonly built: { entries: Int32Array; keys: Uint32Array; hashes: Uint32Array; buckets: Int32Array; order: Int32Array; spare: Int32Array; readonly histogram: Int32Array }; /** What a build across the pool lists, in the arena, kept for the next; null before the first. */ pooled: Pooled | null; - /** A merge's scratch, kept for the next: by bucket, its operations' start; operations in bucket order; removals, insertions and a segment. */ - merging: { starts: Int32Array; order: Int32Array; removed: Int32Array; added: Int32Array; segment: Int32Array }; }; /** A build across the pool's arrays in the arena (`index-build.ts`): by entry, by piece, by partition, and its plan. */ @@ -140,11 +136,9 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn pending: new Int32Array(64), pendingArchetypes: new Int32Array(64), pendingRows: new Int32Array(64), - operations: new Int32Array(64), inserted: new Int32Array(64), built: { entries: new Int32Array(0), keys: new Uint32Array(0), hashes: new Uint32Array(0), buckets: new Int32Array(0), order: new Int32Array(0), spare: new Int32Array(0), histogram: new Int32Array(sorted && words > 0 ? 65536 : 0) }, pooled: null, - merging: { starts: new Int32Array(0), order: new Int32Array(0), removed: new Int32Array(0), added: new Int32Array(0), segment: new Int32Array(0) }, }; state.indexes.set(definition, index); state.journal.readers++; @@ -495,38 +489,17 @@ const refresh = (state: State, index: WorldIndex, end: number, keyed: boolean, t }; /** - * A bucket at least this long takes a refresh's operations in one merge, rather than a shift each; shorter ones take - * each as it comes. - */ -const mergeMinimum = 64; - -/** - * Files each pending entity in the bucket of its key, or none: an operation applies at once on a short bucket, and on - * a long one waits to merge with the bucket's others. A unique index's buckets holding two entries once every - * operation applied are conflicts. + * Files each pending entity in the bucket of its key, or none. A long bucket of an index that isn't unique may be left + * unsorted (`index-hash.ts`), so a change to it costs O(1) amortized; a unique index's stay sorted, and its buckets + * holding two entries once every change applied are conflicts. */ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, tick: number, failures: Failure[]): void => { const { pending, key, words, pendingArchetypes, pendingRows } = index; const { entities } = state; coverEntities(hash, entities.length); - // Each waiting operation: bucket, entity index, archetype id or -1 for a removal, row. - let waiting = 0; - const wait = (bucket: number, entity: number, archetype: number, row: number): void => { - if (4 * (waiting + 1) > index.operations.length) index.operations = fit(index.operations, 4 * (waiting + 1)); - const at = 4 * waiting++; - index.operations[at] = bucket; - index.operations[at + 1] = entity; - index.operations[at + 2] = archetype; - index.operations[at + 3] = row; - }; + const unsorting = !index.unique; // A unique index's insertions, as bucket and entity index pairs, checked once all apply. let inserted = 0; - const remove = (bucket: number, entity: number): void => { - index.changed = tick; - holdKey(hash, index, bucket, false); - if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, -1, 0); - else removeEntry(hash, bucket, entity, tick); - }; for (let k = 0; k < count; k++) { const entity = pending[k]; const scanned = pendingArchetypes[k]; @@ -534,7 +507,9 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, const old = hash.bucketOf[entity]; if (archetypeId < 0 || index.member[archetypeId] !== 1) { if (old < 0) continue; - remove(old, entity); + index.changed = tick; + holdKey(hash, index, old, false); + removeEntry(hash, old, entity, tick, unsorting); hash.bucketOf[entity] = -1; continue; } @@ -551,9 +526,11 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, } if (!fresh) holdKey(hash, index, bucket, true); index.changed = tick; - if (old >= 0) remove(old, entity); - if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, archetypeId, row); - else insertEntry(hash, bucket, entity, archetypeId, row, tick); + if (old >= 0) { + holdKey(hash, index, old, false); + removeEntry(hash, old, entity, tick, unsorting); + } + insertEntry(hash, bucket, entity, archetypeId, row, tick, unsorting); hash.bucketOf[entity] = bucket; if (index.unique) { if (2 * (inserted + 1) > index.inserted.length) index.inserted = fit(index.inserted, 2 * (inserted + 1)); @@ -561,7 +538,6 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, index.inserted[2 * inserted++ + 1] = entity; } } - if (waiting > 0) merge(index, hash, waiting, tick); for (let k = 0; k < inserted; k++) { const bucket = index.inserted[2 * k]; if (lengthOf(hash, bucket) > 1 && hash.bucketOf[index.inserted[2 * k + 1]] === bucket) conflict(state, index, hash, bucket, index.inserted[2 * k + 1], failures); @@ -622,59 +598,6 @@ const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count for (let k = 0; k < inserted; k++) sortedConflict(state, index, sorted, index.inserted[k], failures); }; -/** Applies the operations long buckets wait with, each bucket's in one merge, in entity order. */ -const merge = (index: WorldIndex, hash: HashTable, count: number, tick: number): void => { - const operations = index.operations; - const merging = index.merging; - // By bucket, counted then placed, in the order they came; then each bucket's by entity index. - if (merging.starts.length < hash.count + 1) merging.starts = new Int32Array(Math.max(hash.count + 1, 2 * merging.starts.length)); - if (merging.order.length < count) { - const length = Math.max(count, 2 * merging.order.length); - merging.order = new Int32Array(length); - merging.removed = new Int32Array(length); - merging.added = new Int32Array(length * entryWords); - } - const { starts, order, removed, added } = merging; - starts.fill(0, 0, hash.count + 1); - for (let k = 0; k < count; k++) starts[operations[4 * k] + 1]++; - for (let bucket = 0; bucket < hash.count; bucket++) starts[bucket + 1] += starts[bucket]; - for (let k = 0; k < count; k++) order[starts[operations[4 * k]]++] = k; - let scratch = merging.segment; - for (let start = 0; start < count;) { - const bucket = operations[4 * order[start]]; - let end = start + 1; - while (end < count && operations[4 * order[end]] === bucket) end++; - // Insertion sort by entity index: a bucket's operations of one refresh are few. - for (let k = start + 1; k < end; k++) { - const moved = order[k]; - const entity = operations[4 * moved + 1]; - let at = k; - while (at > start && operations[4 * order[at - 1] + 1] > entity) { - order[at] = order[at - 1]; - at--; - } - order[at] = moved; - } - let removals = 0; - let insertions = 0; - for (let k = start; k < end; k++) { - const at = 4 * order[k]; - if (operations[at + 2] < 0) removed[removals++] = operations[at + 1]; - else { - added[insertions * entryWords] = operations[at + 1]; - added[insertions * entryWords + 1] = operations[at + 2]; - added[insertions * entryWords + 2] = operations[at + 3]; - insertions++; - } - } - const length = lengthOf(hash, bucket) * entryWords; - if (scratch.length < length) scratch = new Int32Array(Math.max(length, 2 * scratch.length)); - mergeEntries(hash, bucket, removed, removals, added, insertions, scratch, tick); - start = end; - } - merging.segment = scratch; -}; - /** Builds an index from every entity it holds, in entity order. */ const rebuild = (state: State, index: WorldIndex, tick: number, failures: Failure[]): void => { refreshMembers(state, index); @@ -882,6 +805,7 @@ const startBuild = (state: State, index: WorldIndex, hash: HashTable, total: num const entityEnd = entities.length; const pieces = Math.ceil(entityEnd / pieceEntities); coverEntities(hash, entityEnd); + forgetUnsorted(hash); // Sized for as many buckets as it last held, as `loadHashTable` sizes it. rehash(hash, Math.min(total, Math.max(16, hash.count - hash.freed.length - hash.empty))); const pooled = pooledFor(state, index, total, pieces); diff --git a/packages/ecs/src/implementation/world/kernel-indexes.ts b/packages/ecs/src/implementation/world/kernel-indexes.ts index f34109b8..4d3fec06 100644 --- a/packages/ecs/src/implementation/world/kernel-indexes.ts +++ b/packages/ecs/src/implementation/world/kernel-indexes.ts @@ -4,7 +4,7 @@ import { labelOf, watchedBy } from '../../interface/registry/index.js'; import type { KernelDescription } from './kernel.js'; import { partWords } from './index-keys.js'; import { sortedStride } from './index-sorted.js'; -import { recordKey } from './index-hash.js'; +import { recordKey, sortHashTable } from './index-hash.js'; import type { WorldIndex } from './indexes.js'; import type { IndexReaderSetup, IndexSource, RowLeaf } from './index-readers.js'; import { idOf, type Key, type State } from './state.js'; @@ -102,11 +102,16 @@ export const kernelIndexesOf = (state: State, description: KernelDescription, lo }; }); -/** Writes a lookup through an index's WASM parameters into `out` from `at`, as of now: its arrays' addresses, counts and ticks. */ +/** + * Writes a lookup through an index's WASM parameters into `out` from `at`, as of now: its arrays' addresses, counts and + * ticks. A hash index's unsorted buckets are sorted first, since its reader reads entries by position where it can't + * sort them. + */ export const writeIndexParameters = (bound: Pick, since: number, out: number[], at: number): number => { const { hash, sorted } = bound.index; let next = at; if (hash !== null) { + if (hash.unsorted !== 0) sortHashTable(hash); out[next++] = hash.slots.byteOffset; out[next++] = (hash.slots.length >> 1) - 1; out[next++] = hash.records.byteOffset; @@ -133,11 +138,12 @@ export const writeIndexParameters = (bound: Pick { const { hash, sorted } = bound.index; source.since = since; if (hash !== null) { + if (hash.unsorted !== 0) sortHashTable(hash); source.slots = hash.slots; source.slotsBase = 0; source.mask = (hash.slots.length >> 1) - 1; source.records = hash.records; source.recordsBase = 0; source.hashEntries = hash.entries; source.hashEntriesBase = 0; diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 9107339c..2013bd12 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -155,6 +155,12 @@ A 2D editor's first tick after loading a document, against `826c45ad`, node, alt - **A sorted index's rebuild skips the sort when its entries are in key order already,** as a load in document order lists them, checked in one pass that stops at the first pair out of order: the paint 5.6–5.8 → 5.1–5.3 ms. - **A main-thread system run in an index's order binds its row in place when the archetype changes,** rather than looking the shared row up again, which happens at every row when archetypes interleave in the order: the paint 4.85 → 4.5 ms. +Long hash buckets left unsorted until read by position, against `1ff6c206`, node, alternating, medians of five: new `bucket move 1K/10K/100K length`, one entity moved between the two buckets of an index on a boolean and a run reading both lengths, as toggling a todo is, 9.0 → 5.7, 63.9 → 5.5 and 418 → 5.6 µs a move (two runs' fixed cost included); new `bucket move … in order`, the run reading each bucket's first ten rows, 9.4 → 6.8, 65.2 → 12.7 and 416 → 68.9 µs. Within noise: `index refresh 1M 1%` 159 → 152 ns, `unique lookup 1M`, `children lookup`, `grid neighbors 100K main` and `workers`, `sorted range 1M`, `sorted page 100K`, `order run 100K`, `order filtered 100K 1%`, `index idle`, the keyless and `lookup 1K entities` benchmarks, `index rebuild 1M main` and `workers` (7.74 → 8.19 ms, runs spread 7.2–8.4 both); `rekey 1M` 13.6 → 14.4 ms (+6%), its 10K buckets of 100 taking about six moves each a frame, which a batched merge did in one pass. +- **A long bucket's change leaves a hole or joins a tail,** found by binary search over the part in entity order or by the tail position kept per entity, so moving an entity costs no shift of the bucket; its record's length counts entries, so `length` and `changed` read no order. +- **Sorting is lazy where it can be, eager where it must be:** a list's `at` on the world's thread sorts its bucket; `writeIndexParameters` and `refreshSource` sort every unsorted bucket before kernels, workers and joined threads read the arrays, since they read entries by position and can't write them. +- **A sort moves 8-byte words:** shared memory copies an element at a time, a 150K-word `copyWithin` 193 µs as `Int32Array` against 25 as `Float64Array`; a shift by an odd number of words goes through a scratch, 58 µs. `bucket move 100K in order` 222 → 68 µs. +- **A full unsorted bucket with holes sorts in place rather than moving its segment, and one that grows gets half its length more spare slots:** `rekey 1M` 15.5 → 14.4 ms, from relocated segments, buffer growth and compactions. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -205,7 +211,7 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ **Tails.** A tail's snapshot is laid out for the rows it found passing and copied a run of consecutive rows at a time, its entities and each linear read one `set` each, its positions, archetypes and rows written in place, and tags marked only for a tail that writes objects: `tail late snapshot 10K` 29.8 → 6.7 ns a row in node, from a row at a time through a scratch list, whose tag pass alone cost 3.3 ns a row for a tail that writes nothing. The copy is what lets the world's work after its writer overlap a late tail: `tail overlap` 2.53 ms a tick against 4.70 for `tail overlap waiting` in node (below). **Indexes.** Node numbers, `pnpm perf`. -- Keeping one current costs nothing while its entities rest: a refresh checks its journal position and its key parts' last writes, which systems' writes and sync points' values mark per component, `index idle` 9 ns an index. Moving 1% of 1M entities to other keys, `index refresh 1M 1%`, 4.3 → 1.56 ms: rows listed with their archetype and row, without marks when one key part's scan is all a refresh reads, short buckets' operations applied as they come and long ones merged once, ordered by a counting sort by bucket; a refresh whose changed chunks' rows pass an eighth counts the rows' own ticks before building again, so a few writes spread over every chunk patch: `rekey 1M`, 1M particles 6% of which cross into another cell a frame, 46.9 → 14.1 ms, against 4.8 ms for the same frame without the index. +- Keeping one current costs nothing while its entities rest: a refresh checks its journal position and its key parts' last writes, which systems' writes and sync points' values mark per component, `index idle` 9 ns an index. Moving 1% of 1M entities to other keys, `index refresh 1M 1%`, 4.3 → 1.56 ms: rows listed with their archetype and row, without marks when one key part's scan is all a refresh reads, short buckets' operations applied as they come and long ones left unsorted; a refresh whose changed chunks' rows pass an eighth counts the rows' own ticks before building again, so a few writes spread over every chunk patch: `rekey 1M`, 1M particles 6% of which cross into another cell a frame, 46.9 → 14.1 ms, against 4.8 ms for the same frame without the index. - Lookups: a call site's encoding, hash and probe generated for its shape and the index's layout, buckets as records of hash, segment and key beside two-word slots of hash and bucket, and a row bound only when its archetype changed: `unique lookup 1M` 86 → 45–50 ns. A microbenchmark of the same probes over the same table reads 29 ns sequential and 85 random a lookup; an identity hash for integer keys is under "Tried and not kept". `children lookup` 32 ns a child, `grid neighbors 100K main` 10.5 µs a row of 27 probes. - Sorted ranges: a search over a compact array of each block's last key and entity, comparing words only for keys of words, and `at` walking blocks through a cursor: `sorted range 1M` 1.08 → 0.76 µs a range of ten; `sorted page 100K` 22 µs a change against 1.12 ms for `select page 100K`. - Kernels: probes in straight-line WASM over the index's arrays, `grid neighbors 100K workers` 179 ns a row across nine workers. @@ -353,6 +359,8 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **The tree's build linking parents over locals,** rather than through `link`: the opacity `up` 4.44–4.65 against 4.55–4.76 ms, about 2%, within noise. - **An index rebuild listing its entities in one loop over locals,** rather than a closure per entity: `index rebuild 1M main` 28.3–30 against 28.7–34.7 ms, and the editor's layout 2.33–2.84 against 2.35–2.56 ms, no gain. - **Binding a row without making a slot object per component:** the editor's paint 5.02–5.06 against 4.99–5.07 ms, no gain; V8 removes the allocation. +- **Unsorted buckets sorted once their holes and tail passed a quarter of their entries,** rather than when full: `rekey 1M` 15.5 ms against 14.4, sorting buckets of 100 every few frames that nothing reads by position. A bucket at least 256 long before it's left unsorted, shorter ones shifted: 14.8 ms, no better. Merging a long tail an entry at a time rather than by a binary search and a move per tail entry: no measurable difference at `rekey 1M`, 15.4–16.7 ms against 15.4–15.8 then; worth revisiting where tails of thousands are sorted. +- **Moves of 4-byte words over shared memory,** `copyWithin` on the entries' `Int32Array`: `bucket move 100K in order` 222 µs against 68 with 8-byte words; closing holes by scanning the whole bucket rather than stopping at the last, 245 µs. - **Presizing a hash index's table for a rebuild,** measured rather than tried: the editor's 601 parents rehash and place their buckets in 0.2 of 34 ms of profile, since a table is sized for the buckets it last held. Worth revisiting for a first build of an index with as many keys as entities. ## Next diff --git a/packages/ecs/src/implementation/world/thread-indexes.ts b/packages/ecs/src/implementation/world/thread-indexes.ts index 27c224a4..2681c87a 100644 --- a/packages/ecs/src/implementation/world/thread-indexes.ts +++ b/packages/ecs/src/implementation/world/thread-indexes.ts @@ -44,7 +44,8 @@ export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => Lo const watchedPositions = Array.from({ length: setup.watched }, (_, k) => k); const hashWatched = new Map(); const sortedWatched = new Map(); - const hash: HashView | null = setup.sorted ? null : { words, stride: recordKey + words, slots: none32, records: none32, ticks: none64, cleared: 0, entries: noneI32, watched: hashWatched }; + // The world sorts a hash index's buckets before writing its record, so this view has none unsorted. + const hash: HashView | null = setup.sorted ? null : { words, stride: recordKey + words, slots: none32, records: none32, ticks: none64, cleared: 0, entries: noneI32, unsorted: 0, watched: hashWatched }; const sorted: SortedView | null = !setup.sorted ? null : { words, stride: sortedStride(words), parts, objects: false, entries: none32, lengths: noneI32, order: noneI32, blocks: 0, counts: noneI32, tree: none64, leaves: 1, fences: none32, size: 0, cleared: 0, watched: sortedWatched, diff --git a/packages/ecs/src/samples/todo/README.md b/packages/ecs/src/samples/todo/README.md index ceffdf52..58f5520d 100644 --- a/packages/ecs/src/samples/todo/README.md +++ b/packages/ecs/src/samples/todo/README.md @@ -141,15 +141,17 @@ What the current API does and doesn't offer the DOM path, each with the change i to back in an empty world, at most 0.3 µs more with the app's observer systems added, and 2.5 µs in the app with 1K todos, where a task round trip takes 7.6 µs. Node's `run idle` is 0.33 µs. A run made right after a frame, as an event's is, takes - 0.02–0.10 ms, which is the CPU coming back from idle, not the ECS. Toggling one todo of 10K takes 62 µs, of which - the profile gives 64% to `mergeEntries` (`index-hash.ts`), keeping `completion/byDone`'s two buckets of 5K entries in - entity order, and 16% to `forEachPassingRange` (`ranges.ts`), the change filters scanning the 1024 row ticks of the - one chunk that changed, for each observer system with `changed`. The rest is the sync point, the observer pass and - the DOM writes, about 13 µs. *Proposals* in the next two findings. -- **A low-cardinality hash index costs O(bucket) per change:** toggling one todo moves it between `byDone`'s buckets, - and each bucket's entries shift to stay in entity order: 26 µs a toggle at 1K, 62 at 10K, 400 at 100K. *Proposal:* - unordered buckets with swap-remove and a position per entity, sorted only when a list is read in entity order; or - a count-only index for what reads only `length`. + 0.02–0.10 ms, which is the CPU coming back from idle, not the ECS. Toggling one todo of 10K took 62 µs, of which + the profile gave 64% to keeping `completion/byDone`'s two buckets of 5K entries in entity order, which the next + finding removes, and 16% to `forEachPassingRange` (`ranges.ts`), the change filters scanning the 1024 row ticks of + the one chunk that changed, for each observer system with `changed`. The rest is the sync point, the observer pass + and the DOM writes, about 13 µs. +- **A low-cardinality hash index costs O(1) amortized per change:** toggling one todo moves it between `byDone`'s + buckets, which are left unsorted, its entry leaving a hole in one and joining a tail of the other, until a list is + read by position (`index-hash.ts`). In node, a toggle and a run reading both buckets' lengths, as the summary does, + take 5.5, 5.3 and 5.5 µs at 1K, 10K and 100K todos (`bucket move … length`), against 9.1, 64 and 423 µs with every + bucket kept in entity order; reading each bucket's first rows after it, as `clearCompleted` reads the done ones, + sorts both buckets first: 6.2, 12.7 and 69 µs (`bucket move … in order`), against 9.4, 65 and 418. - **A change filter scans a whole chunk for one changed row,** 1024 ticks for each system with `changed`. *Proposal:* a second level of ticks per 32 or 64 rows, or a list of the rows changed per chunk. - **Nothing awaits the next tick.** Tests watch `clock` changes through an observer, after a first run that passes it From 1b0a553cfa2a710a3bebfb1be197b06e281e96ee Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 00:59:51 -0700 Subject: [PATCH 017/127] ecs: unsorted hash buckets are listed once each, short odd moves allocate nothing, and a hole first in a bucket leaves rows to be made ahead later A bucket sorted and left unsorted again while another stayed unsorted was listed once more each time, and every listing then compacted the whole list. A move by an odd number of words goes through the scratch's views only from 1,024 words. growPool no longer marks a list's rows made ahead when its first entry is a hole. Only writeIndexParameters sorts for a kernel. A joined observer system reading unsorted buckets is tested. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../implementation/world/index-hash.test.ts | 34 ++++++++++++ .../src/implementation/world/index-hash.ts | 53 +++++++++++++++---- .../implementation/world/index-joined.test.ts | 38 +++++++++++++ .../src/implementation/world/index-lookups.ts | 5 +- .../implementation/world/kernel-indexes.ts | 6 ++- packages/ecs/src/implementation/world/perf.md | 4 +- 7 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 packages/ecs/src/implementation/world/index-hash.test.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 00f68117..d4ca9454 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -284,7 +284,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **The observer pass:** each list of observer systems keeps its runners, read again once runners change, and whether any looks up through an index, so an idle pass reads no index and no GPU state: `observer idle 100` about 80 ns an observer system a tick, and a run of observer systems alone skips its empty sync point: `run observer` 0.21 µs against 0.38–0.42 for `run idle`. One on a joined thread is posted only when its conditions hold and a filter may pass, by its resources' and matched chunks' ticks, into a block of its own, waking each thread once a pass; that thread reads the shared memory directly, the next writer copying what it hasn't read yet, or, for one reading only struct resources, reads copies the post holds, written into one of two blocks so the world never waits for it: `joined observer resource` 4.3–5.0 µs a tick and `joined observer 10K` 0.41–0.51 ms in node; 7.2–7.5 µs and 0.34–0.50 ms in chromium. - **Indexes:** - Keys are encoded as order-preserving words (f32 and f64 bits flipped, NaN last and -0 as +0, i32 with its sign bit flipped, booleans 0 or 1, object parts as ids interned per index), hashed by a multiply-xorshift mix, the same in JS and WASM. - - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`, `refreshSource`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. + - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets kept in entity order and long ones left unsorted, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. - Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits. - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. diff --git a/packages/ecs/src/implementation/world/index-hash.test.ts b/packages/ecs/src/implementation/world/index-hash.test.ts new file mode 100644 index 00000000..bd2fdf44 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-hash.test.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { bucketFor, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, removeEntry, sortBucket, startOf } from "./index-hash.js"; +import { hashWords } from "./index-keys.js"; +import { createMemory } from "./memory.js"; + +describe("a hash table's unsorted buckets", () => { + it("are listed once each, however often one is sorted and left unsorted again while another stays unsorted", () => { + const table = createHashTable(createMemory(), 1); + coverEntities(table, 400); + const key = new Uint32Array(1); + const bucketOf = (value: number): number => { + key[0] = value; + return bucketFor(table, key, 0, hashWords(key, 0, 1), 0); + }; + const buckets = [bucketOf(0), bucketOf(1)]; + for (let entity = 1; entity <= 300; entity++) insertEntry(table, buckets[entity % 2], entity, 0, entity, 0, true); + // One entity leaves the first bucket once: it stays unsorted. + removeEntry(table, buckets[0], 2, 1, true); + let out = false; + for (let run = 0; run < 500; run++) { + // An entity of the second bucket leaves it or comes back, and a read by position sorts it. + if (out) insertEntry(table, buckets[1], 151, 0, 151, run, true); + else removeEntry(table, buckets[1], 151, run, true); + out = !out; + if (table.tails[buckets[1]] >= 0) sortBucket(table, buckets[1]); + } + expect(table.unsortedListed).toBeLessThanOrEqual(2); + sortBucket(table, buckets[0]); + const entities = (bucket: number): number[] => Array.from({ length: lengthOf(table, bucket) }, (_, k) => table.entries[(startOf(table, bucket) + k) * entryWords]); + expect(entities(buckets[0])).toEqual(Array.from({ length: 149 }, (_, k) => 2 * k + 4)); + expect(entities(buckets[1])).toEqual(Array.from({ length: 150 }, (_, k) => 2 * k + 1)); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-hash.ts b/packages/ecs/src/implementation/world/index-hash.ts index 1eebf6d5..26b476d7 100644 --- a/packages/ecs/src/implementation/world/index-hash.ts +++ b/packages/ecs/src/implementation/world/index-hash.ts @@ -58,10 +58,11 @@ export type HashTable = { size: number; /** By component a lookup watches: by bucket, the newest tick its value may have changed in a row there. */ readonly watched: Map; - /** How many buckets are unsorted, and a list holding each of them, and some sorted since. */ + /** How many buckets are unsorted, and a list holding each of them once, and some sorted since; by bucket, 1 when listed. */ unsorted: number; unsortedBuckets: Int32Array; unsortedListed: number; + listed: Uint8Array; /** * By bucket id: where its tail starts, or -1 when it's sorted; the slots its entries and holes take; and a position * no hole is before. @@ -116,6 +117,7 @@ export const createHashTable = (memory: Memory, words: number): HashTable => ({ unsorted: 0, unsortedBuckets: new Int32Array(8), unsortedListed: 0, + listed: new Uint8Array(8), tails: new Int32Array(8).fill(-1), extents: new Int32Array(8), holes: new Int32Array(8), @@ -169,6 +171,9 @@ export const coverBuckets = (table: HashTable, count: number): void => { table.tails = grown(table.tails, length, -1); table.extents = grown(table.extents, length, 0); table.holes = grown(table.holes, length, 0); + const listed = new Uint8Array(length); + listed.set(table.listed); + table.listed = listed; }; /** A bucket's segment's first entry, length and capacity. */ @@ -301,10 +306,18 @@ const scratchOf = (table: HashTable, words: number): Float64Array => { return table.scratch; }; +/** + * Words a move by an odd number of words takes at least before it goes through the scratch, which costs two views of + * it; a shorter one copies a word at a time, allocating nothing. A move this long takes about a microsecond, the views + * tens of nanoseconds. + */ +const oddWideMinimum = 1024; + /** * Moves `words` of entries from `from` to `to` in a table's buffer. Shared memory copies an element at a time, so a * long move copies 8-byte words, 8 times faster than 4-byte ones: directly when both ends have the same parity, an - * edge word by hand; else through a scratch, which shifts them by a word, the target's edge words kept. + * edge word by hand; else, past `oddWideMinimum`, through a scratch, which shifts them by a word, the target's edge + * words kept. */ const moveWide = (table: HashTable, to: number, from: number, words: number): void => { const { entries } = table; @@ -329,6 +342,11 @@ const moveWide = (table: HashTable, to: number, from: number, words: number): vo } return; } + if (words < oddWideMinimum) { + if (to < from) for (let k = 0; k < words; k++) entries[to + k] = entries[from + k]; + else for (let k = words - 1; k >= 0; k--) entries[to + k] = entries[from + k]; + return; + } const sourceFirst = from & ~1; const sourceEnd = (from + words + 1) & ~1; const targetFirst = to & ~1; @@ -415,30 +433,43 @@ const countEmpty = (table: HashTable, before: number, after: number): void => { else if (before > 0 && after === 0) table.empty++; }; -/** Marks a sorted bucket unsorted, with an empty tail and no holes, listing it. */ +/** Empties the list of unsorted buckets, each one listed no longer. */ +const clearList = (table: HashTable): void => { + const list = table.unsortedBuckets; + for (let k = 0; k < table.unsortedListed; k++) table.listed[list[k]] = 0; + table.unsortedListed = 0; +}; + +/** Marks a sorted bucket unsorted, with an empty tail and no holes, listing it unless it's listed still. */ const unsort = (table: HashTable, bucket: number): void => { const length = lengthOf(table, bucket); table.tails[bucket] = length; table.extents[bucket] = length; table.holes[bucket] = length; table.unsorted++; + if (table.listed[bucket] === 1) return; let listed = table.unsortedListed; - // The list drops the buckets sorted since they were listed once they're most of it. + const list = table.unsortedBuckets; + // The list drops the buckets sorted since they were listed once they're most of it, so each listing costs O(1) + // amortized. if (listed >= 2 * table.unsorted + 8) { - const list = table.unsortedBuckets; let kept = 0; - for (let k = 0; k < listed; k++) if (table.tails[list[k]] >= 0) list[kept++] = list[k]; + for (let k = 0; k < listed; k++) { + if (table.tails[list[k]] >= 0) list[kept++] = list[k]; + else table.listed[list[k]] = 0; + } listed = kept; } - if (listed === table.unsortedBuckets.length) table.unsortedBuckets = grown(table.unsortedBuckets, 2 * listed, 0); + if (listed === list.length) table.unsortedBuckets = grown(list, 2 * listed, 0); table.unsortedBuckets[listed] = bucket; table.unsortedListed = listed + 1; + table.listed[bucket] = 1; }; /** Marks an unsorted bucket sorted. */ const sorted = (table: HashTable, bucket: number): void => { table.tails[bucket] = -1; - if (--table.unsorted === 0) table.unsortedListed = 0; + if (--table.unsorted === 0) clearList(table); }; /** @@ -685,8 +716,8 @@ export const sortBucket = (table: HashTable, bucket: number): void => { /** Sorts every unsorted bucket, before the table's arrays are handed to a reader that reads entries by position. */ export const sortHashTable = (table: HashTable): void => { const list = table.unsortedBuckets; - for (let k = 0; k < table.unsortedListed && table.unsorted > 0; k++) if (table.tails[list[k]] >= 0) sortBucket(table, list[k]); - table.unsortedListed = 0; + // Sorting the last unsorted one empties the list. + for (let k = 0; k < table.unsortedListed; k++) if (table.tails[list[k]] >= 0) sortBucket(table, list[k]); }; /** Forgets every unsorted bucket, before the table is built again. */ @@ -694,7 +725,7 @@ export const forgetUnsorted = (table: HashTable): void => { const list = table.unsortedBuckets; for (let k = 0; k < table.unsortedListed; k++) table.tails[list[k]] = -1; table.unsorted = 0; - table.unsortedListed = 0; + clearList(table); }; /** diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index f5722b9b..3abb3c8f 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -91,6 +91,30 @@ const stirBands = System.create({ name: "indexJoinedTest/stirBands", components: if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); }); +/** An observer system of the joined thread's labels: the serials of each reader's band, in entity order, by reader. */ +let observedBands: string[] = []; +const observeBands = System.create({ + name: "indexJoinedTest/observeBands", observer: true, + components: { read: [target], has: [bandReader, label] }, + lookup: { inBand: { index: bands, read: [serial] } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target]); + const serials: number[] = []; + for (let i = 0; i < list.length; i++) serials.push(list.at(i)[serial]); + observedBands[row[target]] = serials.join(","); +}); +/** Seeds the bands, then observes them where `holder` runs observer systems, around changes; what it saw each time. */ +const observedBandScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: (readonly string[])[] = []; + await world.run(seedBands); + for (let round = 0; round < 6; round++) { + await world.run(stirBands, { round }); + observedBands = []; + await holder.run(observeBands); + seen.push([...observedBands]); + } + return seen; +}; /** Seeds the bands, then reads them on the thread around changes; every label after each read. */ const bandScenario = async (world: CreatedWorld, holder: World): Promise => { const seen: (readonly string[])[] = []; @@ -285,6 +309,20 @@ describe("systems on a joined thread looking up through indexes", () => { await remote.dispose(); await world.dispose(); }); + it("observe a hash index's long buckets in entity order after changes left them unsorted, as the world's own observer systems do", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await observedBandScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await observedBandScenario(world, remote)).toEqual(alone); + expect(alone.every(round => round.length === 2 && round.every(text => text.split(",").length > 100))).toBe(true); + await remote.dispose(); + await world.dispose(); + }); it("release the index and its records once the last system using it there is removed", async () => { const driver = backToBack(); const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); diff --git a/packages/ecs/src/implementation/world/index-lookups.ts b/packages/ecs/src/implementation/world/index-lookups.ts index 3f5b471f..e2bbbea3 100644 --- a/packages/ecs/src/implementation/world/index-lookups.ts +++ b/packages/ecs/src/implementation/world/index-lookups.ts @@ -101,9 +101,10 @@ const madeAhead = 256; * body inlines. */ const growPool = (lookup: IndexLookup, pool: Pool, length: number, archetypeId: number): void => { - pool.ahead = length; + // A first entry that's an unsorted bucket's hole, archetype -1, names none: a later lookup makes them. const archetype = lookup.archetypeAt(archetypeId); if (archetype === undefined) return; + pool.ahead = length; const end = Math.min(length, madeAhead); for (let n = pool.rows.length; n < end; n++) bindRow(pool, n, archetypeId, archetype); }; @@ -194,7 +195,7 @@ const hashAt = function (this: HashList, position: number): object | undefined { if (bucket < 0 || !(position >= 0 && position < hash.records[record + recordLength])) return undefined; // On the world's thread, a bucket a change left unsorted is sorted by the first read by position. if (hash.unsorted !== 0 && isHashTable(hash) && hash.tails[bucket] >= 0) sortBucket(hash, bucket); - const at =(hash.records[record + recordStart] + position) * entryWords; + const at = (hash.records[record + recordStart] + position) * entryWords; return rowAt(found.lookup, found.pool, position, hash.entries[at + 1], hash.entries[at + 2]); }; diff --git a/packages/ecs/src/implementation/world/kernel-indexes.ts b/packages/ecs/src/implementation/world/kernel-indexes.ts index 4d3fec06..7c178d0a 100644 --- a/packages/ecs/src/implementation/world/kernel-indexes.ts +++ b/packages/ecs/src/implementation/world/kernel-indexes.ts @@ -138,12 +138,14 @@ export const writeIndexParameters = (bound: Pick { const { hash, sorted } = bound.index; source.since = since; if (hash !== null) { - if (hash.unsorted !== 0) sortHashTable(hash); source.slots = hash.slots; source.slotsBase = 0; source.mask = (hash.slots.length >> 1) - 1; source.records = hash.records; source.recordsBase = 0; source.hashEntries = hash.entries; source.hashEntriesBase = 0; diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 2013bd12..863c2ef6 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -157,8 +157,8 @@ A 2D editor's first tick after loading a document, against `826c45ad`, node, alt Long hash buckets left unsorted until read by position, against `1ff6c206`, node, alternating, medians of five: new `bucket move 1K/10K/100K length`, one entity moved between the two buckets of an index on a boolean and a run reading both lengths, as toggling a todo is, 9.0 → 5.7, 63.9 → 5.5 and 418 → 5.6 µs a move (two runs' fixed cost included); new `bucket move … in order`, the run reading each bucket's first ten rows, 9.4 → 6.8, 65.2 → 12.7 and 416 → 68.9 µs. Within noise: `index refresh 1M 1%` 159 → 152 ns, `unique lookup 1M`, `children lookup`, `grid neighbors 100K main` and `workers`, `sorted range 1M`, `sorted page 100K`, `order run 100K`, `order filtered 100K 1%`, `index idle`, the keyless and `lookup 1K entities` benchmarks, `index rebuild 1M main` and `workers` (7.74 → 8.19 ms, runs spread 7.2–8.4 both); `rekey 1M` 13.6 → 14.4 ms (+6%), its 10K buckets of 100 taking about six moves each a frame, which a batched merge did in one pass. - **A long bucket's change leaves a hole or joins a tail,** found by binary search over the part in entity order or by the tail position kept per entity, so moving an entity costs no shift of the bucket; its record's length counts entries, so `length` and `changed` read no order. -- **Sorting is lazy where it can be, eager where it must be:** a list's `at` on the world's thread sorts its bucket; `writeIndexParameters` and `refreshSource` sort every unsorted bucket before kernels, workers and joined threads read the arrays, since they read entries by position and can't write them. -- **A sort moves 8-byte words:** shared memory copies an element at a time, a 150K-word `copyWithin` 193 µs as `Int32Array` against 25 as `Float64Array`; a shift by an odd number of words goes through a scratch, 58 µs. `bucket move 100K in order` 222 → 68 µs. +- **Sorting is lazy where it can be, eager where it must be:** a list's `at` on the world's thread sorts its bucket; `writeIndexParameters` sorts every unsorted bucket before kernels, workers and joined threads read the arrays, since they read entries by position and can't write them. +- **A sort moves 8-byte words:** shared memory copies an element at a time, a 150K-word `copyWithin` 193 µs as `Int32Array` against 25 as `Float64Array`; a shift by an odd number of words goes through a scratch, 58 µs, once it's at least 1,024 words, the two views of it it makes costing tens of nanoseconds against a microsecond's copy; a shorter one copies a word at a time and allocates nothing. `bucket move 100K in order` 222 → 68 µs; every odd shift a word at a time, 180 µs, and `bucket move 10K in order` 12.7 → 23 µs. - **A full unsorted bucket with holes sorts in place rather than moving its segment, and one that grows gets half its length more spare slots:** `rekey 1M` 15.5 → 14.4 ms, from relocated segments, buffer growth and compactions. ## Where the gains came from From 90d5503ed8399d239a5e4e80fe1a6749cf7962ef Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 01:30:56 -0700 Subject: [PATCH 018/127] ecs: joined finalizers read their thread's object resources from its mirror A finalizer on a joined thread read its thread's object resource at the resource entity's row in the entity table, which can be ahead of the mirror: before the thread's first rebuild, when the mirror holds no rows, and when a later published sync moved the resource entity to another archetype, it read undefined. It now reads the value the thread holds: at the resource entity's row in the mirror as replayed so far, and before a rebuild by the resource row's tag, as the rebuild reads it. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 8 ------ .../ecs/src/implementation/world/mirror.ts | 26 ++++++++++++++++- packages/ecs/src/implementation/world/perf.md | 2 ++ .../world/remote-finalizers.test-helper.ts | 2 +- .../world/remote-finalizers.test.ts | 28 +++++++++++++++++++ .../implementation/world/thread-finalizers.ts | 28 +++++++++++++------ packages/ecs/threads.md | 6 ++-- 7 files changed, 80 insertions(+), 20 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 6b209735..e9001aa9 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -89,14 +89,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in `src/observe`. -## Findings from strings in shared memory - -- **A joined finalizer read a resource of its thread as undefined:** in `remote-finalizers.test.ts`, "that throw are in - a later run's report", the finalizer's `resources[tone]`, an object of its thread, is undefined for the stops of a - sync point soon after the thread joined, where the same finalizer reads it in the other cases; the fixture reads it - optionally now. The test checks no tone, so whether it was undefined before, as a string, wasn't seen. Worth finding - out whether a remote finalizer can run before its thread's resources reach its mirror. - ## Features to discuss - **Computed index keys:** an index keyed on a value computed from a component, such as a struct field diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts index 3f7f6c26..5f8541d3 100644 --- a/packages/ecs/src/implementation/world/mirror.ts +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -74,6 +74,8 @@ export type Mirror = { nextSlot: number; /** Times it rebuilt, for tests. */ rebuilds: number; + /** The archetype last found holding the resource entity, or -1. */ + resourceArchetype: number; /** * How many values of components declared `transfer` are staged and not yet settled, so the thread replays to * release those whose commands dropped once it's woken. @@ -97,7 +99,7 @@ export const createMirror = (memory: WebAssembly.Memory, block: number, thread: memory, thread, block, buffer: memory.buffer, words: new Uint32Array(0), signed: new Int32Array(0), header: new Int32Array(0), ring: new Uint32Array(0), ringEntities: new Float64Array(0), version: -1, mask: 0, position: 0, through: 0, tick: 0, keys: new Map(), starts: new Map(), archetypes: [], scratch: new Uint32Array(1024), scratchEntities: new Float64Array(0), - staged: [], head: 0, nextSlot: 0, rebuilds: 0, stagedTransfers: 0, finalize: null, + staged: [], head: 0, nextSlot: 0, rebuilds: 0, resourceArchetype: -1, stagedTransfers: 0, finalize: null, }; refresh(mirror); return mirror; @@ -576,6 +578,28 @@ const storageOf = (words: Uint32Array, record: number, component: number): numbe return -1; }; +/** + * The value this thread holds for its object resource `component`, as replayed so far, which may be behind the entity + * table: the resource entity, at `index`, is alone in its archetype, so at row 0. Its start while no row holds it. + */ +export const resourceValueOf = (mirror: Mirror, index: number, component: number, start: unknown): unknown => { + const { archetypes } = mirror; + let archetype = mirror.resourceArchetype < 0 ? undefined : archetypes[mirror.resourceArchetype]; + if (archetype === null || archetype === undefined || archetype.count === 0 || indexOf(archetype.entities[0]) !== index) { + archetype = undefined; + mirror.resourceArchetype = -1; + for (let id = 0; id < archetypes.length; id++) { + const candidate = archetypes[id]; + if (candidate === null || candidate === undefined || candidate.count === 0 || indexOf(candidate.entities[0]) !== index) continue; + archetype = candidate; + mirror.resourceArchetype = id; + break; + } + } + const column = archetype?.byComponent[component]; + return column === undefined ? start : column.values[0]; +}; + /** A staged slot's value while no sync applied it, else `settled`. */ export const stagedValue = (mirror: Mirror, slot: number, settled: unknown): unknown => { const staged = stagedOf(mirror, slot); diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 863c2ef6..396f7a00 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -134,6 +134,8 @@ Strings in shared memory, alternating against `c6bb3c83`, node, five and six rou - **Records up to 512 bytes are carved from slabs** by power-of-two size class: `string write 10K joined` 142 → 127 ns against the arena's blocks, whose size class costs a `Math.log2` per record. - **A removed row lets go only of its archetype's string storages,** listed once per archetype: `despawn objects 10K` 104 → 97.7 ns against a check of every storage. +A joined thread's finalizers reading its object resources from its mirror, alternating against `998738a3`, node, three rounds: `despawn objects 10K` 98.2–102 against 99.6–103 ns, within noise. The mirror's archetype holding the resource entity is found once per group of stops that reads one, kept and checked before it's searched for again. + Sync points waiting only for tails that read the world's memory, against `c21e8e1e`, node: `tail late snapshot 10K` 474–484 → 9.0–9.5 ns a row copied, against 8.9 for `e3c30aa1` measured beside it (its record 8.2), and in chromium 450 → 6.0 ns (its record 5.0, one or two 5 µs timer steps); within noise in both: `tail prompt`, `tail overlap`, `tail overlap waiting`. Since every run spawns its report, until 256 are, almost every sync point has something to apply, and each one settled every tail, so the run waited out the whole body of the benchmark's 4 ms tail ahead of the late one, whose head it had claimed. - **A tail reading only its own thread's objects isn't settled at a sync point,** having nothing there a sync point writes: 474 → 12.8 ns. - **The benchmark times the run alone,** reading its report after: 12.8 → 9.0 ns. Reading it through `runReported` timed a second run as well. diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index e57e11d3..6482949c 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -60,7 +60,7 @@ export const throwing = new Set(); /** On the finalizers' thread: what each stop held there, its structs before the sync point applied. */ export const release = Finalizer.create({ name: "remoteFinalizersTest/release", components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { finalized.push(row.entity); - released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone]?.text }); + released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone].text }); if (throwing.has(row[serial])) throw new Error(`remoteFinalizersTest: ${row[serial]} throws`); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index c9f21086..e5026530 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -4,6 +4,7 @@ import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Observe } from "../../observe/index.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; +import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -12,6 +13,18 @@ import { indexOf } from "./entities.js"; import { attach, despawn, eventually, finalized, marked, place, released, retone, scenario, serial, sorted, spawn, throwing, tone, widget } from "./remote-finalizers.test-helper.js"; import { runReported } from "./reports.test-helper.js"; +/** A resource of the world's thread, added once the thread joined, so the resource entity moves to another archetype. */ +const later = Symbol("remoteFinalizersTest/later"); +declare module "@adobe/ecs" { + export interface Resources { + [later]: { type: number }; + } +} +Resource.register(later, { type: { type: "number", default: 0 } }); +const bump = System.create({ name: "remoteFinalizersTest/bump", resources: { write: [later] } }, ({ resources }) => { + resources[later] += 1; +}); + // Finalizers on a joined thread, with the world and the thread on this one, joined through a MessageChannel. /** The first value an observer gives that `accept` takes. */ @@ -156,6 +169,21 @@ describe("finalizers on a joined thread", () => { await world.dispose(); expect(released.map(stop => stop.serial)).toEqual([0, 1, 2]); }); + it("read their thread's object resource as the thread holds it, from the first sync point after joining and once the resource entity moved before they ran", async () => { + reset(); + const { world, remote } = await joined(); + // Stops the thread runs as it first rebuilds, before it holds any row. + await world.run(spawn, { from: 0, count: 3 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + await world.run(retone, { tone: "soft" }); + // Not awaited: the resource entity moves to another archetype before the thread replays the stop's sync. + await Promise.all([world.run(despawn, { serials: [1] }), world.run(bump)]); + expect(await eventually(() => released.length === 2)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); // Last: the finalizers it creates stay in the program. it("created after the claim run there; one the thread can't name doesn't activate, reported once", async () => { reset(); diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index 0c42a704..1d12f954 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -5,8 +5,9 @@ import { objectLayout, viewsOf, type Layout } from '../../interface/registry/lay import { writeOf } from './archetype.js'; import { emptyViews } from './arrays.js'; import { readCell } from './cells.js'; +import { indexOf } from './entities.js'; import { logFinalizedWord, logStopsWord, nullTag, ownTag, slotTags, startTag } from './move-log.js'; -import { stagedValue, type Mirror, type Previously } from './mirror.js'; +import { resourceValueOf, stagedValue, type Mirror, type Previously } from './mirror.js'; import type { WorkerError } from './protocol.js'; import { rowBinderOf, type Row, type RowArchetype, type RowHost, type RowStorage } from './rows.js'; import { @@ -63,8 +64,8 @@ type ThreadFinalizer = { readonly constantLayouts: readonly Layout[]; readonly bind: (archetype: RowArchetype) => Row; readonly context: object; - /** Resolves its resources' cells, as of now. */ - readonly cells: () => void; + /** Resolves its resources: its thread's objects as it holds them, with `old` as they were before a rebuild. */ + readonly cells: (old: Previously | null) => void; readonly body: (row: object, context: object) => void; }; @@ -128,6 +129,9 @@ export const createThreadFinalizers = (host: Threa const keys = [...new Set(resources?.read ?? [])]; const numbers = keys.map(host.numberOf); const resolved: { storage: FinalizedStorage | undefined; row: number }[] = keys.map(() => ({ storage: undefined, row: 0 })); + // Its thread's object resources are read from the mirror, which may be behind the entity table as it replays. + const own = keys.map(key => host.layoutOf(key)?.kind === "object"); + const ownValues: unknown[] = keys.map(() => undefined); if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { @@ -135,7 +139,7 @@ export const createThreadFinalizers = (host: Threa get: () => { const { storage, row } = resolved[k]; if (storage === undefined) throw new Error(`Resource "${labelOf(key)}" isn't in this world.`); - return readCell(storage, row); + return own[k] ? ownValues[k] : readCell(storage, row); }, enumerable: true, }); @@ -150,11 +154,19 @@ export const createThreadFinalizers = (host: Threa excluded: (components.not ?? []).map(host.numberOf), bind: rowBinderOf(host.rows, host.numberOf, reads, []), context: value, - cells: () => { + cells: old => { const found = keys.length === 0 ? null : host.resourceRow(); for (let k = 0; k < keys.length; k++) { - resolved[k].storage = found?.archetype.storageById[numbers[k]]; + const storage = found?.archetype.storageById[numbers[k]]; + resolved[k].storage = storage; resolved[k].row = found?.row ?? 0; + if (!own[k] || found === null || storage === undefined) continue; + const start = mirror.starts.get(numbers[k]) ?? null; + const entity = found.archetype.entities[found.row]; + // Before a rebuild, by its tag as the rebuild reads it; else at its row in the mirror. + ownValues[k] = old === null + ? resourceValueOf(mirror, indexOf(entity), numbers[k], start) + : objectOf(storage.tags[found.row], start, entity, -1, -1, numbers[k], old); } }, body: (row, context) => untyped.body(row, context), @@ -236,7 +248,7 @@ export const createThreadFinalizers = (host: Threa } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); - finalizer.cells(); + finalizer.cells(old); for (let i = 0; i < count; i++) { row.index = i; guarded(index, finalizer, row); @@ -297,7 +309,7 @@ export const createThreadFinalizers = (host: Threa for (let index = 0; index < finalizers.length; index++) { const finalizer = finalizers[index]; if (finalizer === null || finalizer === undefined) continue; - finalizer.cells(); + finalizer.cells(null); for (let id = 0; id < host.tableCount(); id++) { const archetype = host.archetypeAt(id); if (archetype === undefined || archetype.resource || (archetype.meta && finalizer.required.length === 0)) continue; diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 4ceaf0b5..b8d7b61c 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -463,8 +463,10 @@ and is reported once in a sync point's failures. Once the thread is gone, they r entry, before the sync's entries apply, or at replay's end for a sync with no entries for it; its finalizers read the linear structs from the block, constants as they are, and its objects by each stop's tag: the start or `null` as tagged, any other at the stop's row in its mirror. After its log lapped, it runs them before it rebuilds, reading a - value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Resources are - read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Its thread's + object resources are read as it holds them: at the resource entity's row in its mirror as replayed so far, which may + be behind the entity table, and before a rebuild, its first included, by the resource row's tag, as the rebuild reads + it. Other resources are read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a `"finalizer"` failure. From 110ee8ca46d154be66daa7b3d4e70867fb1e994d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 01:59:00 -0700 Subject: [PATCH 019/127] ecs: joined finalizers read their thread's object resources only from its mirror Read-semantics fix: a joined finalizer's read of its thread's object resource now follows the documented semantics (threads.md, Finalizers on other threads): the value the thread holds, at the resource entity's row in its mirror as replayed so far, its old rows before a rebuild, and the start where no row holds it. Before b2e5e117 it could read undefined. The rebuild branch reads the old rows rather than the resource row's tag, so it reads nothing shared but the resource entity's index in the header, and needs no structure-epoch check: the world gives the thread's object resources only their start, so the old rows hold what the rebuild keeps. Tests: the resource entity moved after it was last found, and moved twice before the thread replayed. Tails' resource reads need no change, since resources are added only once tails drained; threads.md says so. Adds the string resource finding to followups.md. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 5 +++ .../ecs/src/implementation/world/mirror.ts | 10 +++-- .../world/remote-finalizers.test.ts | 45 ++++++++++++++++++- .../implementation/world/thread-finalizers.ts | 34 +++++++------- packages/ecs/threads.md | 10 +++-- 5 files changed, 78 insertions(+), 26 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index e9001aa9..415cf409 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -89,6 +89,11 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in `src/observe`. +## Findings from joined finalizers' resources + +- **A joined finalizer reads a string resource from its live record,** not a copy, where the strings of its stops are + copied into the stop list. + ## Features to discuss - **Computed index keys:** an index keyed on a value computed from a component, such as a struct field diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts index 5f8541d3..ceceaf8a 100644 --- a/packages/ecs/src/implementation/world/mirror.ts +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -2,7 +2,7 @@ import { indexOf } from './entities.js'; import { cursorField, entitiesWord, recordCount, recordEntities, recordStorages, recordWords, - remoteFlag, storageAdded, storageChanged, storageColumn, storageComponent, storageFlags, storageWords, structureWaitersWord, + remoteFlag, resourceEntityWord, storageAdded, storageChanged, storageColumn, storageComponent, storageFlags, storageWords, structureWaitersWord, structureWord, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, } from './layout-directory.js'; import { @@ -580,9 +580,13 @@ const storageOf = (words: Uint32Array, record: number, component: number): numbe /** * The value this thread holds for its object resource `component`, as replayed so far, which may be behind the entity - * table: the resource entity, at `index`, is alone in its archetype, so at row 0. Its start while no row holds it. + * table; its start while no row holds it. The resource entity is alone in its archetype, so at row 0, and never + * despawned, so its index, which the directory's header holds, names no other entity. */ -export const resourceValueOf = (mirror: Mirror, index: number, component: number, start: unknown): unknown => { +export const resourceValueOf = (mirror: Mirror, component: number, start: unknown): unknown => { + const word = Atomics.load(mirror.words, resourceEntityWord); + if (word === 0) return start; + const index = word - 1; const { archetypes } = mirror; let archetype = mirror.resourceArchetype < 0 ? undefined : archetypes[mirror.resourceArchetype]; if (archetype === null || archetype === undefined || archetype.count === 0 || indexOf(archetype.entities[0]) !== index) { diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index e5026530..b605f7e9 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -13,17 +13,29 @@ import { indexOf } from "./entities.js"; import { attach, despawn, eventually, finalized, marked, place, released, retone, scenario, serial, sorted, spawn, throwing, tone, widget } from "./remote-finalizers.test-helper.js"; import { runReported } from "./reports.test-helper.js"; -/** A resource of the world's thread, added once the thread joined, so the resource entity moves to another archetype. */ +/** Resources of the world's thread, each added once the thread joined, so the resource entity moves to another archetype. */ const later = Symbol("remoteFinalizersTest/later"); +const second = Symbol("remoteFinalizersTest/second"); +const third = Symbol("remoteFinalizersTest/third"); declare module "@adobe/ecs" { export interface Resources { [later]: { type: number }; + [second]: { type: number }; + [third]: { type: number }; } } Resource.register(later, { type: { type: "number", default: 0 } }); +Resource.register(second, { type: { type: "number", default: 0 } }); +Resource.register(third, { type: { type: "number", default: 0 } }); const bump = System.create({ name: "remoteFinalizersTest/bump", resources: { write: [later] } }, ({ resources }) => { resources[later] += 1; }); +const bumpSecond = System.create({ name: "remoteFinalizersTest/bumpSecond", resources: { write: [second] } }, ({ resources }) => { + resources[second] += 1; +}); +const bumpThird = System.create({ name: "remoteFinalizersTest/bumpThird", resources: { write: [third] } }, ({ resources }) => { + resources[third] += 1; +}); // Finalizers on a joined thread, with the world and the thread on this one, joined through a MessageChannel. @@ -184,6 +196,37 @@ describe("finalizers on a joined thread", () => { await remote.dispose(); await world.dispose(); }); + it("read their thread's object resource where the resource entity moved since it was last found, before a later write applies", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 3 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + // Replayed, not rebuilt: the resource entity is found in the mirror. + await world.run(despawn, { serials: [1] }); + expect(await eventually(() => released.length === 2)).toBe(true); + await world.run(bump); + await world.run(retone, { tone: "soft" }); + // The write after the stop's sync is a task the thread runs once it replayed that sync. + await Promise.all([world.run(despawn, { serials: [2] }), world.run(retone, { tone: "loud" })]); + expect(await eventually(() => released.length === 3)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "quiet"], [2, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); + it("read their thread's object resource once the resource entity moved twice before the thread replayed", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 2 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + await world.run(retone, { tone: "soft" }); + await Promise.all([world.run(despawn, { serials: [1] }), world.run(bumpSecond), world.run(bumpThird)]); + expect(await eventually(() => released.length === 2)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); // Last: the finalizers it creates stay in the program. it("created after the claim run there; one the thread can't name doesn't activate, reported once", async () => { reset(); diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index 1d12f954..bef98710 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -5,7 +5,6 @@ import { objectLayout, viewsOf, type Layout } from '../../interface/registry/lay import { writeOf } from './archetype.js'; import { emptyViews } from './arrays.js'; import { readCell } from './cells.js'; -import { indexOf } from './entities.js'; import { logFinalizedWord, logStopsWord, nullTag, ownTag, slotTags, startTag } from './move-log.js'; import { resourceValueOf, stagedValue, type Mirror, type Previously } from './mirror.js'; import type { WorkerError } from './protocol.js'; @@ -64,8 +63,8 @@ type ThreadFinalizer = { readonly constantLayouts: readonly Layout[]; readonly bind: (archetype: RowArchetype) => Row; readonly context: object; - /** Resolves its resources: its thread's objects as it holds them, with `old` as they were before a rebuild. */ - readonly cells: (old: Previously | null) => void; + /** Resolves its resources: its thread's objects as it holds them, any other's cell as of now. */ + readonly cells: () => void; readonly body: (row: object, context: object) => void; }; @@ -131,15 +130,18 @@ export const createThreadFinalizers = (host: Threa const resolved: { storage: FinalizedStorage | undefined; row: number }[] = keys.map(() => ({ storage: undefined, row: 0 })); // Its thread's object resources are read from the mirror, which may be behind the entity table as it replays. const own = keys.map(key => host.layoutOf(key)?.kind === "object"); + const resourceStarts = numbers.map(number => mirror.starts.get(number) ?? null); const ownValues: unknown[] = keys.map(() => undefined); + const shared = own.includes(false); if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { Object.defineProperty(values, key, { get: () => { + if (own[k]) return ownValues[k]; const { storage, row } = resolved[k]; if (storage === undefined) throw new Error(`Resource "${labelOf(key)}" isn't in this world.`); - return own[k] ? ownValues[k] : readCell(storage, row); + return readCell(storage, row); }, enumerable: true, }); @@ -154,19 +156,15 @@ export const createThreadFinalizers = (host: Threa excluded: (components.not ?? []).map(host.numberOf), bind: rowBinderOf(host.rows, host.numberOf, reads, []), context: value, - cells: old => { - const found = keys.length === 0 ? null : host.resourceRow(); + cells: () => { + const found = shared ? host.resourceRow() : null; for (let k = 0; k < keys.length; k++) { - const storage = found?.archetype.storageById[numbers[k]]; - resolved[k].storage = storage; - resolved[k].row = found?.row ?? 0; - if (!own[k] || found === null || storage === undefined) continue; - const start = mirror.starts.get(numbers[k]) ?? null; - const entity = found.archetype.entities[found.row]; - // Before a rebuild, by its tag as the rebuild reads it; else at its row in the mirror. - ownValues[k] = old === null - ? resourceValueOf(mirror, indexOf(entity), numbers[k], start) - : objectOf(storage.tags[found.row], start, entity, -1, -1, numbers[k], old); + // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. + if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); + else { + resolved[k].storage = found?.archetype.storageById[numbers[k]]; + resolved[k].row = found?.row ?? 0; + } } }, body: (row, context) => untyped.body(row, context), @@ -248,7 +246,7 @@ export const createThreadFinalizers = (host: Threa } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); - finalizer.cells(old); + finalizer.cells(); for (let i = 0; i < count; i++) { row.index = i; guarded(index, finalizer, row); @@ -309,7 +307,7 @@ export const createThreadFinalizers = (host: Threa for (let index = 0; index < finalizers.length; index++) { const finalizer = finalizers[index]; if (finalizer === null || finalizer === undefined) continue; - finalizer.cells(null); + finalizer.cells(); for (let id = 0; id < host.tableCount(); id++) { const archetype = host.archetypeAt(id); if (archetype === undefined || archetype.resource || (archetype.meta && finalizer.required.length === 0)) continue; diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index b8d7b61c..549eece3 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -465,8 +465,9 @@ and is reported once in a sync point's failures. Once the thread is gone, they r tagged, any other at the stop's row in its mirror. After its log lapped, it runs them before it rebuilds, reading a value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Its thread's object resources are read as it holds them: at the resource entity's row in its mirror as replayed so far, which may - be behind the entity table, and before a rebuild, its first included, by the resource row's tag, as the rebuild reads - it. Other resources are read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + be behind the entity table, its old rows before a rebuild, and the start where no row holds it, as before its first + rebuild. That's what the rebuild keeps: only its systems write those values, and the world gives them only their start, + when they're added or claimed. Other resources are read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a `"finalizer"` failure. @@ -598,8 +599,9 @@ its tails before it resolves, with their failures in its report. - **The tail's own objects** are read where they are: while a tail is in flight, the thread's replay stops at the sync it names, which the world stores in the log header as it posts it, so the next sync point's moves wait in the log until the tail finishes. Rows read from the copy take their objects from the mirror at the copy's rows, or, once its - log lapped and it rebuilt past that sync, by entity. Objects it writes from the copy go back to the mirror after its - body; the world marks their tags the thread's own when it copies, and stamps them changed at the tail's tick, as the + log lapped and it rebuilt past that sync, by entity. Its object resources stay at the copy's resource row, rebuilt or + not, since the resource entity moves only when resources are added, which waits for tails. Objects it writes from the + copy go back to the mirror after its body; the world marks their tags the thread's own when it copies, and stamps them changed at the tail's tick, as the task's completion names them by entity, before the thread's next task. - **What waits for a tail to finish:** the next task on its thread, and what moves rows a tail may still read outside a sync point: registration, resources added between runs, a GPU device change, and joined threads joining or leaving. From 837ab46a7d4db9c24e818e6903658a6bd229ec06 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 00:59:32 -0700 Subject: [PATCH 020/127] ecs: benchmarks of change filters finding one, a few or every changed row of 100K Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/perftest/benchmarks.ts | 2 + packages/ecs/src/perftest/filters.ts | 137 ++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/ecs/src/perftest/filters.ts diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index 73e09526..db76d24e 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -25,6 +25,7 @@ import { indexBenchmarks } from "./indexes.js"; import { editorBenchmarks } from "./editor.js"; import { stringBenchmarks } from "./strings.js"; import { structReplace, valueBenchmarks } from "./values.js"; +import { filterBenchmarks } from "./filters.js"; import { createWorld } from "../implementation/index.js"; import { workers } from "../interface/resources/workers.js"; // Last, so the bundle orders the modules the benchmarks share as it did before these imports. @@ -698,4 +699,5 @@ export const benchmarks: readonly Benchmark[] = [ ...stringBenchmarks, ...valueBenchmarks, structReplace("main", 0), + ...filterBenchmarks, ]; diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts new file mode 100644 index 00000000..4ace0afd --- /dev/null +++ b/packages/ecs/src/perftest/filters.ts @@ -0,0 +1,137 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import type { I32 } from "../interface/math/i32/index.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import { retainedRuns } from "../implementation/world/reports.js"; +import type { Benchmark } from "./benchmark.js"; + +// Change filters over one archetype of 100K rows after a writer changed one row, a few, or every one: what finding the +// rows that pass costs each system that filters, apart from running them. + +const value = Symbol("filters/value"); +const other = Symbol("filters/other"); +const at = Symbol("filters/at"); +const out0 = Symbol("filters/out0"); +const out1 = Symbol("filters/out1"); +const out2 = Symbol("filters/out2"); +const out3 = Symbol("filters/out3"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + [at]: { type: number }; + [out0]: { type: number }; + [out1]: { type: number }; + [out2]: { type: number }; + [out3]: { type: number }; + } +} +Component.register(value, { type: { type: "number" } }); +Component.register(other, { type: { type: "number" } }); +Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +Component.register(out0, { type: { type: "number" } }); +Component.register(out1, { type: { type: "number" } }); +Component.register(out2, { type: { type: "number" } }); +Component.register(out3, { type: { type: "number" } }); + +const rows = 100_000; +const filtered = Archetype.create([value, other, at, out0, out1, out2, out3]); + +const seed = System.create({ name: "filters/seed" }, ({ commands }, input: { readonly count: number }) => { + for (let i = 0; i < input.count; i++) commands.spawn(filtered)({ [value]: 0, [other]: 0, [at]: i, [out0]: 0, [out1]: 0, [out2]: 0, [out3]: 0 }); +}); + +/** Writes the value of the rows whose `at` is `first` plus a multiple of `stride`. */ +const write = System.create({ + name: "filters/write", + kernel: "cpu", + components: { read: [at], write: [value] }, +}, (row, _context, input: { readonly first: I32; readonly stride: I32 }) => { + const offset = row[at] - input.first; + if (offset >= 0 && offset % input.stride === 0) row[value] += 1; +}); + +let seen = 0; +/** Main-thread systems each filtering on `value`'s changes. */ +const readers = System.create({ + name: "filters/readers", + systems: Array.from({ length: 8 }, (_, i) => System.create({ name: `filters/reader${i}`, components: { read: [value], changed: [value] } }, row => { + seen += row[value]; + })), +}); +/** The same filtering on `value`'s or `other`'s changes, of which only `value` is written. */ +const readersOfTwo = System.create({ + name: "filters/readersOfTwo", + systems: Array.from({ length: 8 }, (_, i) => System.create({ name: `filters/readerOfTwo${i}`, components: { read: [value], changed: [value, other] } }, row => { + seen += row[value]; + })), +}); +/** Kernels each filtering on `value`'s changes, writing an output of its own. */ +const kernels = System.create({ + name: "filters/kernels", + systems: [ + System.create({ name: "filters/kernel0", kernel: "cpu", components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), + System.create({ name: "filters/kernel1", kernel: "cpu", components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), + System.create({ name: "filters/kernel2", kernel: "cpu", components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), + System.create({ name: "filters/kernel3", kernel: "cpu", components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), + ], +}); + +/** The fastest run of `measured` right after a write of every `stride`th row from a moving first row. */ +const afterWrites = async (measured: System, stride: number): Promise => { + const world: World = createWorld({ [workers]: 0 }); + await world.run(seed, { count: rows }); + await world.run(measured); + let best = Infinity; + const warm = Math.max(20, Math.ceil(retainedRuns / 2)); + for (let i = 0; i < warm + 200; i++) { + await world.run(write, { first: (i * 7919) % Math.min(stride, rows), stride }); + const start = performance.now(); + await world.run(measured); + if (i >= warm) best = Math.min(best, performance.now() - start); + } + await world.dispose(); + return best; +}; + +const cases = [ + { name: "one", stride: rows * 2, said: "one row changed" }, + { name: "few", stride: 10_000, said: "ten rows changed, 10,000 apart" }, + { name: "all", stride: 1, said: "every row changed" }, +] as const; + +export const filterBenchmarks: readonly Benchmark[] = [ + ...cases.map(({ name, stride, said }): Benchmark => ({ + name: `changed ${name} 100K`, + operation: `Eight main-thread systems filtering on \`changed\` over 100K rows after ${said}: views and derived values reacting to edits.`, + per: "systems", + n: 8, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed rows could cost nothing beyond running them." }, + run: () => afterWrites(readers, stride), + })), + { + name: "changed one 100K two filters", + operation: "Eight main-thread systems filtering on `changed` of two components over 100K rows after one row's first component changed.", + per: "systems", + n: 8, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed row could cost nothing beyond running it." }, + run: () => afterWrites(readersOfTwo, rows * 2), + }, + ...cases.map(({ name, stride, said }): Benchmark => ({ + name: `changed ${name} 100K kernels`, + operation: `Four kernels filtering on \`changed\` over 100K rows after ${said}, on the world's thread: derived columns kept current.`, + per: "systems", + n: 4, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed rows could cost nothing beyond running them." }, + run: () => afterWrites(kernels, stride), + })), +]; + +export const seenTotal = (): number => seen; From 7391fcc5a28cde06a5ca4c2fc440f43036291738 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 00:59:42 -0700 Subject: [PATCH 021/127] ecs: block ticks, the newest tick per 64 rows, so change filters skip a changed chunk's unchanged blocks Every writer of a row tick raises its block's tick beside its chunk's, in shared memory with offsets in the layout directory. Main-thread, worker and joined-thread filters skip chunks, blocks and rows each in a tight loop; WASM kernels find runs of passing blocks in an outer loop and run their rows in an inner one. Index refreshes and `up` read blocks too. Eight systems finding one changed row of 100K: 34.9 to 5.04 us in node. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 8 +- packages/ecs/followups.md | 2 - .../src/implementation/compiler/columns.ts | 8 +- .../implementation/compiler/compile-kernel.ts | 14 +- .../ecs/src/implementation/compiler/ir.ts | 4 +- .../ecs/src/implementation/compiler/print.ts | 4 +- .../ecs/src/implementation/compiler/stamps.ts | 10 +- .../implementation/compiler/transform.test.ts | 5 +- .../ecs/src/implementation/compiler/unit.ts | 11 +- .../ecs/src/implementation/world/archetype.ts | 39 ++- .../ecs/src/implementation/world/batches.ts | 6 +- .../world/change-blocks.browser.test.ts | 84 +++++++ .../world/change-blocks.cases.ts | 14 ++ .../world/change-blocks.fixture.ts | 73 ++++++ .../world/change-blocks.test.ts | 238 ++++++++++++++++++ .../src/implementation/world/check-kernel.ts | 2 +- .../implementation/world/generated-code.md | 4 +- .../ecs/src/implementation/world/indexes.ts | 21 +- .../ecs/src/implementation/world/kernel.ts | 20 +- .../ecs/src/implementation/world/kernels.ts | 11 +- .../implementation/world/layout-directory.ts | 10 +- packages/ecs/src/implementation/world/perf.md | 26 ++ .../ecs/src/implementation/world/protocol.ts | 2 +- .../src/implementation/world/ranges.test.ts | 66 +++++ .../ecs/src/implementation/world/ranges.ts | 129 +++++++--- .../implementation/world/remote-systems.ts | 5 +- packages/ecs/src/implementation/world/rows.ts | 2 +- .../ecs/src/implementation/world/runner.ts | 7 +- .../src/implementation/world/synchronize.ts | 10 +- .../ecs/src/implementation/world/tails.ts | 4 +- .../implementation/world/thread-directory.ts | 7 +- .../implementation/world/thread-finalizers.ts | 2 +- .../implementation/world/thread-systems.ts | 8 +- .../world/threads.browser.test.ts | 2 + .../src/implementation/world/threads.test.ts | 3 + packages/ecs/src/implementation/world/up.ts | 15 +- .../ecs/src/implementation/world/wasm.test.ts | 103 ++++++-- packages/ecs/src/implementation/world/wasm.ts | 173 +++++++++---- .../ecs/src/implementation/world/worker.ts | 17 +- packages/ecs/src/samples/todo/README.md | 11 +- packages/ecs/threads.md | 4 +- 41 files changed, 980 insertions(+), 204 deletions(-) create mode 100644 packages/ecs/src/implementation/world/change-blocks.browser.test.ts create mode 100644 packages/ecs/src/implementation/world/change-blocks.cases.ts create mode 100644 packages/ecs/src/implementation/world/change-blocks.fixture.ts create mode 100644 packages/ecs/src/implementation/world/change-blocks.test.ts create mode 100644 packages/ecs/src/implementation/world/ranges.test.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index d4ca9454..2527d622 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -149,7 +149,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Rows on or under a parent cycle don't run; reports don't list them yet. - **Order:** rows run depth by depth, and within a depth in groups of one archetype whose parents share one archetype, each in storage order. Groups of a depth never read each other's writes, so values don't depend on the grouping; commands and reductions follow it, deterministically. The order, each row's parent row and the children of each parent change only at sync points, since only those move rows or change references. - **Kernels read `up` straight from the parent's columns:** a call covers one group, so it binds the parent archetype's columns once, and each row reads its parent's leaf at the parent's row. A leaf's stride and offset depend only on its component's layout, so they're the same in every archetype. `up === null` holds for the whole call. - - **Filtered runs push changes down:** rows are marked from chunk ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. + - **Filtered runs push changes down:** rows are marked from chunk and block ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. - **`kernel: "gpu"` systems may declare `up`;** what `read` lists must be made of 4-byte scalars, as the rest of a GPU kernel's values are. - **In a main-thread body, `context.up` is one reused object,** valid only while its row runs, as `row` is. - **World** (`src/interface/world/`): @@ -266,11 +266,11 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **Commands:** typed parallel arrays with explicit counts, reused per system across runs; ids resolved when issued, and each list command records its shape. Kernels issue commands through `begin`/`value`/`component` by key index, allocating nothing; the compiler gives every component of a command's list a value, `undefined` for one it leaves out, which the runtime writes as the component's start. Workers write theirs into a command ring in shared memory, structs as bytes in their storages' layout and other values as tags, which the world drains at the sync point into buffers whose struct values stay bytes in the ring, copied by word as they apply (`threads.md`). - **Kernel spawns:** staged, not commands. The compiler lists each kernel's spawn lists, and each spawn calls its list's spawner with one value per component, generated per list as main-thread spawners are. The main thread stages into batches per runner, and each chunk's buffer names the rows staged while it was current; a worker stages per task and copies its batches into its command ring after the chunk's commands. The sync point commits staged rows in merge order, right after main-thread batches and before any command, so row order doesn't depend on which thread ran a chunk. Kernel spawners reserve entities in blocks, from 8 positions doubling to 4096, so workers don't contend for the cursor; each run or task ends its block, and the sync point returns the positions it left unused to the free list with their generations. - **Sync point:** plans straight over the queued buffers into reusable typed arrays, through cached archetype edges (arrays by component id), per-pair move lists, and for each list add or remove a table from source archetype to target; archetypes grow once per sync point; common layouts are written inline rather than through layout closures. Applying groups entries by source and target archetype: each pair copies the columns its archetypes share column by column for its block of rows and writes derived defaults once per column, then each entry's values are written. Rows left are removed last entry first, so an archetype moving out whole drops its rows without copying them; a row moved into a hole that another entry still leaves takes that entry's place. -- **Chunk ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk, stamped with every row tick, so change filters skip unchanged chunks. Stamps every row reaches are made once per kernel call, per chunk. +- **Chunk and block ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk and per 64-row block, both stamped with every row tick and raised by a row copied in, in shared memory beside the row ticks, so change filters skip unchanged chunks, then the unchanged blocks of a chunk that changed: finding a chunk's passing rows costs its sixteen block ticks and the rows of the blocks that changed, not its 1024 row ticks. Stamps every row reaches are made once per kernel call, per chunk and block. - **Ticks only where observed:** a component has tick columns only once some registered system filters on its `added` or `changed`, and resources always do; until then nothing allocates or stamps them, and once started they stay, bounded by the components. Columns start at the current tick, so a new system, starting with `lastRun = 0`, sees every row once. Kernels also drop `changed` stamps for components nothing watches: a count per component of the registered systems filtering on its `changed` and of the finalizers' indexes following it, an index's for the world's life. A count turning positive reloads the kernels that dropped its stamps; one falling to zero reloads nothing, and kernels drop its stamps when they next load for another reason, so a filter coming and going, as a system run once does, reloads nothing. - **What a system holds lives as long as its value:** a world keeps its schedules and runners by system value, weakly, so a system nothing else references, such as one made for a single run, is collected with its runner, command buffers and `lastRun`, while a module's systems keep theirs between runs. What a runner holds beyond its JS values (reduction partials and `up` orders in the arena, lookup directories, GPU records, its hook after each sync point, its count of watched components) is released once its system is collected, through a finalization registry whose records hold nothing of the system. Runs read a schedule's runners again only after one was replaced or removed, as placements, device changes and `remove` do; a removed one reads as a runner that runs nothing, and a registered composite holding a system forgotten since registers again on its next run, keeping its other members' runners. Ids of keys, added resources and tick columns stay, bounded by the components; the registry forgets a collected item without an id, its label with it. `lifetime.test.ts` runs 10,000 new systems, each once, and finds every table and the heap as they were. - **Kernels where WASM can't compile** (a content security policy without `'wasm-unsafe-eval'`) run their compiled JS. -- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks and rows are skipped in WASM. With SIMD, outside `up`, each aligned block of eight rows checks its ticks at once and is skipped when none passes. +- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks, blocks and rows are skipped in WASM. An outer loop skips chunks whose ticks aren't newer, reads a passing chunk's block ticks into a mask, and finds each run of passing blocks with `ctz`; an inner loop runs the run's rows, at one comparison a row. A chunk with more than half its blocks passing is one run. With SIMD, outside `up`, each aligned group of eight rows checks its ticks at once and is skipped when none passes. - **Reductions in WASM kernels:** sums, products, minimums and maximums fold consecutive rows into four lanes in turn, so a fold doesn't wait on the one before. - **Holders:** each reference component keeps the holders of each target as linked lists by entity index, updated from the plan's reference changes at Apply, so cascades find a despawned target's holders without a scan. - **Lookups in WASM kernels:** a directory per lookup in shared memory (per archetype: a match flag, column addresses, and for `changed` each watched component's tick column, 0 without ticks), rebuilt at sync points and once a registration starts ticks; finds check index and generation against the shared entity table. A lookup with `changed` reads its system's `lastRun` after the lookup parameters, -1 once a GPU kernel stamped a whole watched column since; JS kernels on workers read it there too, and a JS kernel's result holds `changed` after its values. @@ -295,7 +295,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - Each system keeps a tree by entity index (parent, level, children as linked lists), read from the journal; levels settle by walking up to a root or a settled entity, which also finds cycles. - The order: groups of one depth, archetype and parent archetype, as segments of one arena buffer with spare slots, patched in two phases by binary search and block moves within a group, so rows keep storage order. - Rebuilt in full, in O(rows), when the journal dropped entries a system hadn't read, or when more than a quarter of the order's rows would move. - - Filtered runs mark entities into lists by depth, from chunk ticks and the journal; a depth with few marks is sorted, one with many is scanned in order. + - Filtered runs mark entities into lists by depth, from chunk and block ticks and the journal; a depth with few marks is sorted, one with many is scanned in order. - WASM kernels run a group's row list, with the parent archetype's column addresses as parameters. - Workers take one depth at a time as claimed pieces, with a barrier per depth; depths too small to split run on the main thread without a wait. - The GPU runs a dispatch per group, in order, within one pass; row lists live in the heap and upload only when the order changes. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 415cf409..2b17eaf5 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -84,8 +84,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over the entity a reference names. -5. **Change filters scan the whole dirty chunk** (1,024 row ticks), once per system that filters on `changed`. - Proposal: a second level of change ticks, one per 32–64 rows. 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in `src/observe`. diff --git a/packages/ecs/src/implementation/compiler/columns.ts b/packages/ecs/src/implementation/compiler/columns.ts index e9b259bc..5deb8474 100644 --- a/packages/ecs/src/implementation/compiler/columns.ts +++ b/packages/ecs/src/implementation/compiler/columns.ts @@ -19,12 +19,14 @@ export const fieldColumn = (unit: Unit, component: string, path: Path): number = export const fieldRead = (unit: Unit, component: string, path: Path): Expression => ({ kind: "column", column: fieldColumn(unit, component, path) }); -/** A component's changed column, immediately followed by its dense chunk column. */ -export const changedColumns = (unit: Unit, component: string): { readonly column: number; readonly chunks: number } => { +/** A component's changed column, immediately followed by its dense chunk and block columns. */ +export const changedColumns = (unit: Unit, component: string): { readonly column: number; readonly chunks: number; readonly blocks: number } => { const changed = column(unit, `changed ${component}`, `{ kind: "changed", component: ${component} }`); const chunks = column(unit, `chunks ${component}`, `{ kind: "chunks", component: ${component} }`); + const blocks = column(unit, `blocks ${component}`, `{ kind: "blocks", component: ${component} }`); unit.chunks.add(chunks); - return { column: changed, chunks }; + unit.blocks.add(blocks); + return { column: changed, chunks, blocks }; }; /** A scalar leaf of a component of the row's parent, read through `up`. */ diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 4c04ad05..0c9fbba9 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -12,7 +12,7 @@ import { localName, printStatements } from './print.js'; import { toProgram } from './program.js'; import { serialize } from './serialize.js'; import { unconditionalChunks } from './stamps.js'; -import { chunkShift, type Program } from '../world/kernel.js'; +import { blockShift, chunkShift, type Program } from '../world/kernel.js'; import { rootScope } from './scope.js'; import { signature } from './signature.js'; import { rowLabel, statement } from './statement.js'; @@ -60,7 +60,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: statement({ type: "ExpressionStatement", expression: body.body, start: body.body.start, end: body.body.end }, scope, rows); } const statements = [...resolve(prelude), ...resolve(rows)]; - for (const chunks of unconditionalChunks(resolve(rows))) unit.stampedPerCall.add(chunks); + for (const [chunks, blocks] of unconditionalChunks(resolve(rows))) unit.stampedPerCall.set(chunks, blocks); const kernel = printStatements(unit, resolve(rows)); const preludeCode = printStatements(unit, resolve(prelude)); const accumulators = unit.accumulators.map(entry => entry.local); @@ -77,7 +77,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: } const contributed = new Set(unit.accumulators.flatMap(entry => reduced[entry.reduced]?.resource ?? [])); requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, module.bindings, lists); - const strided = columns.flatMap((_, index) => unit.chunks.has(index) ? [] : [index]); + const strided = columns.flatMap((_, index) => unit.chunks.has(index) || unit.blocks.has(index) ? [] : [index]); const locals = [ ...columns.map((_, index) => `const c${index} = columns[${index}];`), ...resources.map((_, index) => `const r${index} = resources[${index}];`), @@ -126,10 +126,12 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: preludeCode, ...accumulators.map((local, k) => `let ${localName(unit, local)} = partials[partial + ${k}];`), // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own chunk. - ...listed ? [] : [...unit.stampedPerCall].map(chunks => - `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`), + ...listed ? [] : [...unit.stampedPerCall].flatMap(([chunks, blocks]) => [ + `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, + `if (start < end) for (let k = start >> ${blockShift}, last = (end - 1) >> ${blockShift}; k <= last; k++) c${blocks}[k] = tick;`, + ]), listed - ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(chunks => `c${chunks}[i >> ${chunkShift}] = tick;`)].join(" ")}` + ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, blocks]) => `c${chunks}[i >> ${chunkShift}] = tick; c${blocks}[i >> ${blockShift}] = tick;`)].join(" ")}` : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, kernel, "}", diff --git a/packages/ecs/src/implementation/compiler/ir.ts b/packages/ecs/src/implementation/compiler/ir.ts index f7444e69..3a2f53b7 100644 --- a/packages/ecs/src/implementation/compiler/ir.ts +++ b/packages/ecs/src/implementation/compiler/ir.ts @@ -55,8 +55,8 @@ export type Statement = /** Declares `let` locals that every path assigns before reading. */ | { readonly kind: "declare"; readonly locals: readonly number[] } | { readonly kind: "store"; readonly column: number; readonly value: Expression } - /** Stamps a changed column at the row and its chunk column at the row's chunk. */ - | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + /** Stamps a changed column at the row, and its chunk and block columns at the row's chunk and block. */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number; readonly blocks: number } | { readonly kind: "if"; readonly test: Expression; readonly consequent: Out; readonly alternate: Out } | { readonly kind: "block"; readonly label: number; readonly body: Out } | { readonly kind: "loop"; readonly label: number; readonly body: Out } diff --git a/packages/ecs/src/implementation/compiler/print.ts b/packages/ecs/src/implementation/compiler/print.ts index 2201e6ba..9e5e48df 100644 --- a/packages/ecs/src/implementation/compiler/print.ts +++ b/packages/ecs/src/implementation/compiler/print.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift, type Path } from '../world/kernel.js'; +import { blockShift, chunkShift, type Path } from '../world/kernel.js'; import type { Expression, Statement } from './ir.js'; import { resolve, type Out } from './out.js'; import { rowLabel } from './statement.js'; @@ -69,7 +69,7 @@ const printStatement = (unit: Unit, statement: Statement, labels: Labels): strin case "store": return `c${statement.column}[x${statement.column}] = ${value(statement.value)};`; case "stamp": { const row = `c${statement.column}[x${statement.column}] = tick;`; - return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; + return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.chunks}[i >> ${chunkShift}] = tick;\nc${statement.blocks}[i >> ${blockShift}] = tick;`; } case "if": { const alternate = printStatements(unit, statement.alternate, labels); diff --git a/packages/ecs/src/implementation/compiler/stamps.ts b/packages/ecs/src/implementation/compiler/stamps.ts index 5db1c3cb..28b1e579 100644 --- a/packages/ecs/src/implementation/compiler/stamps.ts +++ b/packages/ecs/src/implementation/compiler/stamps.ts @@ -12,14 +12,14 @@ const exits = (statement: Statement): boolean => { }; /** - * Chunk columns stamped on every row: by a top-level stamp that no earlier `next` can skip. Every row in - * `[start, end)` runs the body, so these chunks can be stamped once per call instead of per row. + * Chunk columns stamped on every row, with their block columns: by a top-level stamp that no earlier `next` can skip. + * Every row in `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. */ -export const unconditionalChunks = (rows: readonly Statement[]): ReadonlySet => { - const chunks = new Set(); +export const unconditionalChunks = (rows: readonly Statement[]): ReadonlyMap => { + const chunks = new Map(); for (const statement of rows) { if (exits(statement)) break; - if (statement.kind === "stamp") chunks.add(statement.chunks); + if (statement.kind === "stamp") chunks.set(statement.chunks, statement.blocks); } return chunks; }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 91791d21..449b7d82 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { describe, expect, test } from 'vitest'; import { parseAst } from 'vite'; -import { chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; +import { blockShift, chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; import { transformKernels } from './transform.js'; import { createLinker, type LinkHost } from './link.js'; import { ownCopy } from './paths.js'; @@ -196,8 +196,10 @@ const interpret = (program: Program, columns: readonly (Float64Array | Uint32Arr case "stamp": { const column = columns[statement.column]; const chunks = columns[statement.chunks]; + const blocks = columns[statement.blocks]; if (column) column[at(statement.column, row)] = tick; if (chunks) chunks[row >> chunkShift] = tick; + if (blocks) blocks[row >> blockShift] = tick; return normal; } case "if": return execute(evaluate(statement.test) ? statement.consequent : statement.alternate); @@ -248,6 +250,7 @@ const run = (description: Compiled, rows: number, fields: Fields, options: Optio if (column.kind === "entity") return Float64Array.from({ length: rows }, (_, row) => 100 + row); if (column.kind === "changed") return new Uint32Array(rows); if (column.kind === "chunks") return new Float64Array(Math.ceil(rows / 2 ** chunkShift)); + if (column.kind === "blocks") return new Float64Array(Math.ceil(rows / 2 ** blockShift)); const component = String(column.component); const values = fields[component]?.[JSON.stringify(column.path)] ?? new Array(rows).fill(0); const stride = interleaved[component]?.stride; diff --git a/packages/ecs/src/implementation/compiler/unit.ts b/packages/ecs/src/implementation/compiler/unit.ts index 71ef3575..c222785b 100644 --- a/packages/ecs/src/implementation/compiler/unit.ts +++ b/packages/ecs/src/implementation/compiler/unit.ts @@ -31,8 +31,13 @@ export type Unit = { readonly columnIndex: Map; /** Chunk columns: dense, indexed by `i >> chunkShift` rather than a strided row index. */ readonly chunks: Set; - /** Chunk columns stamped once per call before the row loop, so their per-row stamps are dropped. */ - readonly stampedPerCall: Set; + /** Block columns: dense, indexed by `i >> blockShift`. */ + readonly blocks: Set; + /** + * Chunk columns stamped once per call before the row loop, with their block columns, so their per-row stamps are + * dropped. + */ + readonly stampedPerCall: Map; /** Columns read where a boolean's identity is observable, so they need conversion. */ readonly booleans: Set; /** Every value assigned to each local, to propagate observation. */ @@ -80,7 +85,7 @@ export const createUnit = (signature: Signature, module: Module, lists: Lists, e const up = signature.up === null ? null : counter++; if (up !== null) names.set(up, "up"); return { - signature, module, lists, link, target, columns: [], columnIndex: new Map(), chunks: new Set(), stampedPerCall: new Set(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), + signature, module, lists, link, target, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), used: { read: new Set(), write: new Set(), resources: new Set(), lookups: new Map(), up: new Set() }, up, upColumns: [], upColumnIndex: new Map(), upBooleans: new Set(), probes: [], rowLeaves: [], handles: new Set(), next: () => counter++, expression, statement, diff --git a/packages/ecs/src/implementation/world/archetype.ts b/packages/ecs/src/implementation/world/archetype.ts index 1af69b6d..a24e437b 100644 --- a/packages/ecs/src/implementation/world/archetype.ts +++ b/packages/ecs/src/implementation/world/archetype.ts @@ -2,7 +2,7 @@ import type { Entity } from '../../interface/entity/entity.js'; import type { Layout, Views } from '../../interface/registry/layout.js'; import { allocateArray, allocateViews, emptyViews, freeArray, freeViews, growArray } from './arrays.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import type { Memory } from './memory.js'; import { nullTag, startTag, type MoveLog } from './move-log.js'; import type { StringStore } from './strings.js'; @@ -40,6 +40,12 @@ export type Storage = { /** The newest `added` and `changed` tick in each chunk of `2 ** chunkShift` rows, so change filters skip unchanged chunks. */ chunkAdded: Float64Array; chunkChanged: Float64Array; + /** + * The newest `added` and `changed` tick in each block of `2 ** blockShift` rows, so change filters skip the + * unchanged blocks of a chunk that changed: a filter's scan costs what changed, not the chunk. + */ + blockAdded: Float64Array; + blockChanged: Float64Array; /** * The newest tick at which every row may have changed, as a GPU kernel's writes do, so stamping them costs * nothing per row: change filters pass the whole column when it's newer than their `lastRun`. @@ -73,21 +79,35 @@ export const writeOf = (layout: Layout): { readonly write: number; readonly unit /** Chunks covering `capacity` rows. */ const chunksFor = (capacity: number): number => Math.max(1, Math.ceil(capacity / 2 ** chunkShift)); +/** Blocks covering those chunks, so every row of a chunk has a block. */ +const blocksFor = (capacity: number): number => chunksFor(capacity) << (chunkShift - blockShift); /** What stamping a row's ticks writes: a storage's, or a joined thread's view of one. */ -export type Stamped = Pick; +export type Stamped = Pick; -/** Stamps a row's `changed` tick and its chunk's. Ticks only increase, so the chunk's is the newest. */ +/** Stamps a row's `changed` tick, its chunk's and its block's. Ticks only increase, so theirs are the newest. */ export const stampChanged = (storage: Stamped, row: number, tick: number): void => { if (!storage.ticked) return; storage.changed[row] = tick; storage.chunkChanged[row >> chunkShift] = tick; + storage.blockChanged[row >> blockShift] = tick; }; export const stampAdded = (storage: Stamped, row: number, tick: number): void => { if (!storage.ticked) return; storage.added[row] = tick; storage.chunkAdded[row >> chunkShift] = tick; + storage.blockAdded[row >> blockShift] = tick; +}; + +/** + * Raises the chunk and block ticks over rows `start` to `end - 1` to at least `tick`: for ticks written to those rows + * in bulk, or copied in from rows of other chunks. + */ +export const raiseRange = (chunks: Float64Array, blocks: Float64Array, start: number, end: number, tick: number): void => { + if (start >= end) return; + for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) if (chunks[chunk] < tick) chunks[chunk] = tick; + for (let block = start >> blockShift, last = (end - 1) >> blockShift; block <= last; block++) if (blocks[block] < tick) blocks[block] = tick; }; /** A table of the entities that have exactly `components`. Rows are dense: `0` to `count`. */ @@ -156,6 +176,8 @@ const allocateTicks = (memory: Memory, storage: Storage, capacity: number, tick: storage.changed = storage.layout.kind === "constant" ? storage.added : allocateArray(memory, Float64Array, capacity).fill(tick); storage.chunkAdded = allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); storage.chunkChanged = storage.layout.kind === "constant" ? storage.chunkAdded : allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); + storage.blockAdded = allocateArray(memory, Float64Array, blocksFor(capacity)).fill(tick); + storage.blockChanged = storage.layout.kind === "constant" ? storage.blockAdded : allocateArray(memory, Float64Array, blocksFor(capacity)).fill(tick); }; /** Views of a new column, for a linear layout's or a string's `capacity` rows; a string's handles start at 0. */ @@ -181,6 +203,8 @@ const createStorage = (memory: Memory, strings: StringStore | null, component: n changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, + blockAdded: noTicks, + blockChanged: noTicks, columnChanged: 0, remote: null, tags: noTags, @@ -262,6 +286,10 @@ const grow = (memory: Memory, archetype: Archetype, capacity = archetype.capacit const used = storage.chunkAdded.length; storage.chunkAdded = growArray(memory, storage.chunkAdded, Float64Array, chunks, used).fill(0, used); storage.chunkChanged = shared ? storage.chunkAdded : growArray(memory, storage.chunkChanged, Float64Array, chunks, used).fill(0, used); + const blocks = blocksFor(capacity); + const usedBlocks = storage.blockAdded.length; + storage.blockAdded = growArray(memory, storage.blockAdded, Float64Array, blocks, usedBlocks).fill(0, usedBlocks); + storage.blockChanged = shared ? storage.blockAdded : growArray(memory, storage.blockChanged, Float64Array, blocks, usedBlocks).fill(0, usedBlocks); } } archetype.entities = growArray(memory, archetype.entities, Float64Array, capacity, count); @@ -330,10 +358,13 @@ export const copyCell = (from: Storage, fromRow: number, to: Storage, toRow: num const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's tick is the newest of its rows, so a row moving in can raise it. + // A chunk's and a block's tick are the newest of their rows, so a row moving in can raise them. const chunk = toRow >> chunkShift; if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; + const block = toRow >> blockShift; + if (added > to.blockAdded[block]) to.blockAdded[block] = added; + if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; }; /** Removes a row by moving the last row into it. Returns the moved entity, or -1 if the removed row was last. */ diff --git a/packages/ecs/src/implementation/world/batches.ts b/packages/ecs/src/implementation/world/batches.ts index fed392c9..74cca1c8 100644 --- a/packages/ecs/src/implementation/world/batches.ts +++ b/packages/ecs/src/implementation/world/batches.ts @@ -2,7 +2,7 @@ import { ensureCapacity, type Archetype } from './archetype.js'; import type { CommandBuffer } from './buffer.js'; import { indexOf } from './entities.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { createBatch, type SpawnBatch } from './staging.js'; import { archetypeOf, type State } from './state.js'; import { gainsOf } from './structure.js'; @@ -82,9 +82,11 @@ const commitRows = (state: State, batch: SpawnBatch, first: number, n: number, t if (!storage.ticked) continue; storage.added.fill(tick, start, start + n); if (storage.changed !== storage.added) storage.changed.fill(tick, start, start + n); - // Ticks only increase, so the newest tick in each chunk the rows reach is this one. + // Ticks only increase, so the newest tick in each chunk and block the rows reach is this one. storage.chunkAdded.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); if (storage.chunkChanged !== storage.chunkAdded) storage.chunkChanged.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); + storage.blockAdded.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); + if (storage.blockChanged !== storage.blockAdded) storage.blockChanged.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); } const { entities } = state; for (let i = 0; i < n; i++) { diff --git a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts new file mode 100644 index 00000000..6dcba44a --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts @@ -0,0 +1,84 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, join } from "./create.js"; + +// A GPU kernel stamps a whole column once; before a joined thread's systems filter on it, its rows, chunks and blocks +// take the stamp, so the thread's change filters find every row, and then only rows written after. + +const value = Symbol("changeBlocksGpuTest/value"); +const at = Symbol("changeBlocksGpuTest/at"); +const note = Symbol("changeBlocksGpuTest/note"); + +declare module "@adobe/ecs" { + interface Threads { + changeBlocksGpuTest: true; + } + interface Components { + [value]: { type: number }; + [at]: { type: number }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksGpuTest" }; + } +} +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +const text = { type: "object", properties: { text: { type: "string" } } } as const; +Component.register(note, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "changeBlocksGpuTest" }); + +const rows = 4500; +let entities: Entity[] = []; +const seed = System.create({ name: "changeBlocksGpuTest/seed" }, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn([value, at, note])({ [value]: 0, [at]: i })); +}); +const raise = System.create({ name: "changeBlocksGpuTest/raise", kernel: "gpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); +const setValue = System.create({ name: "changeBlocksGpuTest/setValue", target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { + row[value] += 1; +}); +let found: number[] = []; +const there = System.create({ name: "changeBlocksGpuTest/there", components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +describe("chunk and block ticks after a GPU kernel's column stamp", () => { + it("let a joined thread's filters find every row a GPU kernel wrote, then a row the CPU wrote", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["changeBlocksGpuTest"] }); + try { + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + await world.run(seed); + await world.run(there); + await world.run(raise); + found = []; + await world.run(there); + expect(found.length).toBe(rows); + const row = 2 * 1024 + 5 * 64 + 9; + await world.run(setValue, { entity: entities[row] }); + found = []; + await world.run(there); + expect(found).toEqual([row]); + } + finally { + await remote.dispose(); + await world.dispose(); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/change-blocks.cases.ts b/packages/ecs/src/implementation/world/change-blocks.cases.ts new file mode 100644 index 00000000..f8a219e3 --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.cases.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./change-blocks.fixture.js"; + +/** Chunk and block ticks on workers: the cases, over the fixture built for node or from source. */ +export const changeBlocksCases = (load: () => Promise) => describe("chunk and block ticks on workers", () => { + for (const wasm of [false, true]) { + it(`let readers find the rows a kernel on workers wrote (${wasm ? "WASM" : "JS"} kernels)`, async () => { + const fixture = await load(); + const expected = [5, 2048 + 5 * 64 + 9, 4499]; + expect(await fixture.foundOnWorkers(wasm)).toEqual({ kernel: expected, main: expected }); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts new file mode 100644 index 00000000..d475bb20 --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -0,0 +1,73 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component } from "../../interface/component/component.js"; +import type { I32 } from "../../interface/math/i32/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; + +/** Scenarios of chunk and block ticks on workers, run built in node and from source in chromium. */ + +const value = Symbol("changeBlocksWorkersTest/value"); +const at = Symbol("changeBlocksWorkersTest/at"); +const seen = Symbol("changeBlocksWorkersTest/seen"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [at]: { type: number }; + [seen]: { type: number }; + } +} + +Component.register(value, { type: { type: "number" } }); +Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +Component.register(seen, { type: { type: "number" } }); + +const rows = 4500; + +const seed = System.create({ name: "changeBlocksWorkersTest/seed" }, ({ commands }) => { + for (let i = 0; i < rows; i++) commands.spawn([value, at, seen])({ [value]: 0, [at]: i, [seen]: 0 }); +}); + +/** Writes the rows whose `at` is one of three, stamping row by row. */ +const writeSome = System.create({ name: "changeBlocksWorkersTest/writeSome", kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { + const here = row[at]; + if (here === input.a || here === input.b || here === input.c) row[value] += 1; +}); + +/** Counts each run of the rows whose value changed. */ +const watch = System.create({ name: "changeBlocksWorkersTest/watch", kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { + row[seen] += 1; +}); + +let found: number[] = []; +const onMain = System.create({ name: "changeBlocksWorkersTest/onMain", components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); + +/** + * The rows a kernel on workers and a system on the world's thread find after a kernel on workers wrote three rows far + * apart: in the first block, a middle block of a middle chunk, and the last row. + */ +export const foundOnWorkers = async (wasm: boolean): Promise<{ readonly kernel: readonly number[]; readonly main: readonly number[] }> => { + const world = createWorld({ [workers]: 2 }, { wasm, minimumChunk: 64 }); + try { + await world.run(seed); + await world.run(watch); + await world.run(onMain); + const all = await select(world, { required: [seen] }); + const before = await readEach(world, all, seen); + await world.run(writeSome, { a: 5, b: 2048 + 5 * 64 + 9, c: rows - 1 }); + await world.run(watch); + found = []; + await world.run(onMain); + const after = await readEach(world, all, seen); + const ats = await readEach(world, all, at); + return { + kernel: all.flatMap((_, k) => after[k] !== before[k] ? [ats[k] ?? -1] : []).sort((a, b) => a - b), + main: [...found].sort((a, b) => a - b), + }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts new file mode 100644 index 00000000..f9a228d3 --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -0,0 +1,238 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Archetype } from "../../interface/archetype/archetype.js"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import type { I32 } from "../../interface/math/i32/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join } from "./create.js"; +import { blockShift, chunkShift } from "./kernel.js"; +import { readEach, select } from "./reads.test-helper.js"; + +// Change filters skip chunks and blocks whose ticks aren't newer than their `lastRun`, so every writer of a row's tick +// raises its chunk's and block's. Each case writes a few rows far apart in an archetype of many chunks, through one +// writer, and every kind of reader must find exactly those rows. + +const value = Symbol("changeBlocksTest/value"); +const at = Symbol("changeBlocksTest/at"); +const seen = Symbol("changeBlocksTest/seen"); +const group = Symbol("changeBlocksTest/group"); +const tagged = Symbol("changeBlocksTest/tagged"); +const note = Symbol("changeBlocksTest/note"); +const parent = Symbol("changeBlocksTest/parent"); + +declare module "@adobe/ecs" { + interface Threads { + changeBlocksTest: true; + } + interface Components { + [value]: { type: number }; + [at]: { type: number }; + [seen]: { type: number }; + [group]: { type: number }; + [tagged]: { type: true }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksTest" }; + [parent]: { type: Entity; reference: "owner" }; + } +} +Component.register(value, { type: { type: "number" } }); +Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +Component.register(seen, { type: { type: "number" } }); +Component.register(group, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(tagged, { type: { const: true } }); +const text = { type: "object", properties: { text: { type: "string" } } } as const; +Component.register(note, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "changeBlocksTest" }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); + +/** Rows in the archetype: five chunks, the last partial. */ +const rows = 4500; +const plain = Archetype.create([value, at, seen, group]); +const marked = Archetype.create([value, at, seen, group, tagged]); +const noted = Archetype.create([value, at, note]); + +let entities: Entity[] = []; +const seed = System.create({ name: "changeBlocksTest/seed" }, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn(plain)({ [value]: 0, [at]: i, [seen]: 0, [group]: 0 })); + // Rows an entity tagged later moves after, so it lands far into that archetype. + for (let i = rows; i < 2 * rows; i++) entities.push(commands.spawn(marked)({ [value]: 0, [at]: i, [seen]: 0, [group]: 0, [tagged]: true })); +}); + +// Writers. +const setValue = System.create({ name: "changeBlocksTest/setValue", target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { + row[value] += 1; +}); +const replace = System.create({ name: "changeBlocksTest/replace" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.add(entity, value, 7); +}); +const despawn = System.create({ name: "changeBlocksTest/despawn" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.despawn(entity); +}); +const tag = System.create({ name: "changeBlocksTest/tag" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.add(entity, tagged); +}); +const spawnOne = System.create({ name: "changeBlocksTest/spawnOne" }, ({ commands }, input: { readonly at: number }) => { + commands.spawn(plain)({ [value]: 0, [at]: input.at, [seen]: 0, [group]: 0 }); +}); +/** Writes the rows whose `at` is in a list of up to four, which stamps row by row. */ +const writeSome = System.create({ name: "changeBlocksTest/writeSome", kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { + const here = row[at]; + if (here === input.a || here === input.b || here === input.c || here === input.d) row[value] += 1; +}); +/** Writes every row, which stamps chunks and blocks once per call. */ +const writeAll = System.create({ name: "changeBlocksTest/writeAll", kernel: "cpu", components: { write: [value] } }, row => { + row[value] += 1; +}); +const setGroup = System.create({ name: "changeBlocksTest/setGroup", target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity }) => { + row[group] = 1; +}); + +// Readers. +let found: number[] = []; +const onMain = System.create({ name: "changeBlocksTest/onMain", components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const observed = System.create({ name: "changeBlocksTest/observed", observer: true, components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const ofTwo = System.create({ name: "changeBlocksTest/ofTwo", components: { read: [at], changed: [group, value] } }, row => { found.push(row[at]); }); +const inKernel = System.create({ name: "changeBlocksTest/inKernel", kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); +const inUp = System.create({ name: "changeBlocksTest/inUp", components: { read: [at], changed: [value] }, up: { through: parent, read: [value] } }, row => { found.push(row[at]); }); +const byGroup = Index.create({ name: "changeBlocksTest/byGroup", key: [group] }); +let grouped: number[] = []; +const inGroup = System.create({ name: "changeBlocksTest/inGroup", lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { + const list = lookup.byGroup(1); + grouped = Array.from({ length: list.length }, (_, i) => list.at(i).entity); +}); + +// On a joined thread, by the object they read: a writer, and readers of its writes and of the world's thread's. +const seedNoted = System.create({ name: "changeBlocksTest/seedNoted" }, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn(noted)({ [value]: 0, [at]: i })); +}); +const noteSome = System.create({ name: "changeBlocksTest/noteSome", components: { read: [at], write: [note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { + if (input.ats.includes(row[at])) row[note] = { text: "noted" }; +}); +const notesThere = System.create({ name: "changeBlocksTest/notesThere", components: { read: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const valuesThere = System.create({ name: "changeBlocksTest/valuesThere", components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); + +/** The `at` of the rows a main-thread reader ran, in order. */ +const ranOn = async (world: World, reader: System): Promise => { + found = []; + await world.run(reader); + return [...found].sort((a, b) => a - b); +}; + +/** The `at` of the rows the kernel reader ran, by the `seen` it raised. */ +const ranInKernel = async (world: World): Promise => { + const all = await select(world, { required: [seen] }); + const before = await readEach(world, all, seen); + await world.run(inKernel); + const after = await readEach(world, all, seen); + const ats = await readEach(world, all, at); + return all.flatMap((_, k) => after[k] !== before[k] ? [ats[k] ?? -1] : []).sort((a, b) => a - b); +}; + +const readers = { onMain, observed, ofTwo, inUp } as const; + +/** Every reader's rows after `write`, each having run once before, so each sees only what `write` changed. */ +const seenAfter = async (world: World, write: () => Promise): Promise<{ readonly [reader: string]: readonly number[] }> => { + for (const reader of Object.values(readers)) await world.run(reader); + await world.run(inKernel); + await write(); + const result: { [reader: string]: readonly number[] } = {}; + for (const [name, reader] of Object.entries(readers)) result[name] = await ranOn(world, reader); + result.inKernel = await ranInKernel(world); + return result; +}; + +const every = (expected: readonly number[]) => ({ onMain: expected, observed: expected, ofTwo: expected, inUp: expected, inKernel: expected }); + +/** Rows far apart: in the first block, a middle block of a middle chunk, and the last chunk's last block. */ +const spread = [5, (2 << chunkShift) + (5 << blockShift) + 9, rows - 1] as const; + +describe("chunk and block ticks", () => { + for (const wasm of [false, true]) { + const label = wasm ? "WASM" : "JS"; + const inline = () => createWorld({ [workers]: 0 }, { wasm }); + + it(`let readers find rows a main-thread system wrote (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, async () => { + for (const row of spread) await world.run(setValue, { entity: entities[row] }); + })).toEqual(every(spread)); + }); + + it(`let readers find rows a kernel wrote, row by row and once per call (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, () => world.run(writeSome, { a: spread[0], b: spread[1], c: spread[2], d: -1 }))).toEqual(every(spread)); + const all = Array.from({ length: 2 * rows }, (_, k) => k); + expect(await seenAfter(world, () => world.run(writeAll))).toEqual(every(all)); + }); + + it(`let readers find rows commands wrote, moved, swapped into and spawned (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, () => world.run(replace, { entities: spread.map(row => entities[row]) }))).toEqual(every(spread)); + // The last row, changed, is swapped into the first block's despawned row. + const last = rows - 1; + expect(await seenAfter(world, async () => { + await world.run(setValue, { entity: entities[last] }); + await world.run(despawn, { entities: [entities[3]] }); + })).toEqual(every([last])); + // A changed row moves to the end of another archetype, past its first chunks. + const moved = spread[1]; + expect(await seenAfter(world, async () => { + await world.run(setValue, { entity: entities[moved] }); + await world.run(tag, { entities: [entities[moved]] }); + })).toEqual(every([moved])); + expect(await seenAfter(world, () => world.run(spawnOne, { at: 9999 }))).toEqual(every([9999])); + }); + } + + it("let a filter of two components find a row only the other component changed in", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(ofTwo); + await world.run(setGroup, { entity: entities[spread[1]] }); + expect(await ranOn(world, ofTwo)).toEqual([spread[1]]); + }); + + it("let systems on a joined thread find rows written there and on the world's thread", async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["changeBlocksTest"] }); + try { + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + await world.run(seedNoted); + await world.run(valuesThere); + await world.run(notesThere); + for (const row of spread) await world.run(setValue, { entity: entities[row] }); + expect(await ranOn(world, valuesThere)).toEqual(spread); + await world.run(noteSome, { ats: [spread[1], spread[2]] }); + expect(await ranOn(world, notesThere)).toEqual([spread[1], spread[2]]); + } + finally { + await remote.dispose(); + await world.dispose(); + } + }); + + it("let an index refresh find a key written in a block of a chunk", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(inGroup); + expect(grouped).toEqual([]); + await world.run(setGroup, { entity: entities[spread[1]] }); + await world.run(inGroup); + expect(grouped).toEqual([entities[spread[1]]]); + }); +}); diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts index 38e974cb..da60b0cf 100644 --- a/packages/ecs/src/implementation/world/check-kernel.ts +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -42,7 +42,7 @@ export const checkKernel = (leaf: Leaf, description: KernelDescription): void => for (const column of description.columns) { if (column.kind === "entity") continue; if (!queried.has(column.component)) fail(`"${labelOf(column.component)}" isn't in its components' read or write.`); - if (column.kind === "changed" || column.kind === "chunks") { + if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks") { if (!written.has(column.component)) fail(`writes "${labelOf(column.component)}", which isn't in its components' write.`); continue; } diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md index 80a58acb..2f87df50 100644 --- a/packages/ecs/src/implementation/world/generated-code.md +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -112,9 +112,9 @@ fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) group Calls nest as builtins do: `Vec3.scale(Vec3.normalize(Vec3.cross(p, v)), Vec3.dot(p, v))` emits `cross_6 = cross(…)`, `normalize_19 = normalize(cross_6)`, `dot_26 = dot(…)` and `scale_31 = (normalize_19 * dot_26)`. -### WASM (499 bytes, excerpts from `wasm2wat`) +### WASM (557 bytes, excerpts from `wasm2wat`) -Three loops: chunk stamps, four rows at a time, then one row at a time for the rest. +Four loops: chunk stamps, block stamps, four rows at a time, then one row at a time for the rest. ```wat ;; Four rows: 48 bytes per component, three v128 loads each. diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index 6c6567dc..f7c23bd3 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -5,7 +5,8 @@ import type { Failure } from '../../interface/report/failure.js'; import type { SystemLookup } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { readCell } from './cells.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; +import { forEachNewerRow } from './ranges.js'; import { journalEnd } from './journal.js'; import { encodeStored, hashWords, keyPartsOf, missing, release as releaseInterned, retain, type KeyPart } from './index-keys.js'; import { @@ -352,14 +353,11 @@ const pend = (index: WorldIndex, entity: number, archetype: number, row: number, /** Calls `each` with every row of a storage whose `changed` tick is after `since`, a whole column a GPU kernel wrote included. */ const changedRows = (archetype: Archetype, storage: Storage, since: number, each: (row: number) => void): void => { - const whole = storage.columnChanged > since; - const { changed, chunkChanged } = storage; - for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { - if (!whole && !(chunkChanged[chunk] > since)) continue; - for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { - if (whole || changed[row] > since) each(row); - } + if (storage.columnChanged > since) { + for (let row = 0; row < archetype.count; row++) each(row); + return; } + forEachNewerRow(storage.changed, storage.chunkChanged, storage.blockChanged, archetype.count, since, each); }; /** Whether a component may have been written since `since`: by a system, or by a command's value at a sync point. */ @@ -469,10 +467,15 @@ const refresh = (state: State, index: WorldIndex, end: number, keyed: boolean, t const storage = archetype.storageById[part]; if (storage === undefined || !storage.ticked || !writtenSince(state, part, since)) continue; const whole = storage.columnChanged > since; - const { changed, chunkChanged } = storage; + const { changed, chunkChanged, blockChanged } = storage; for (let chunk = 0, chunks = Math.ceil(archetype.count / 2 ** chunkShift); chunk < chunks; chunk++) { if (!whole && !(chunkChanged[chunk] > since)) continue; for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { + // A block no row of which changed is passed over whole. + if (!whole && (row & (2 ** blockShift - 1)) === 0 && !(blockChanged[row >> blockShift] > since)) { + row += 2 ** blockShift - 1; + continue; + } if (!whole && !(changed[row] > since)) continue; count = marking ? pend(index, rows[row] >>> 0, id, row, count) : list(index, rows[row] >>> 0, id, row, count); if (count > most) return rebuildAny(state, index, tick, failures, pool); diff --git a/packages/ecs/src/implementation/world/kernel.ts b/packages/ecs/src/implementation/world/kernel.ts index 2aa23f9e..e6344376 100644 --- a/packages/ecs/src/implementation/world/kernel.ts +++ b/packages/ecs/src/implementation/world/kernel.ts @@ -20,13 +20,20 @@ export type Column = /** Float64Array of the component's changed ticks, stamped with `tick` on each write. */ | { readonly kind: "changed"; readonly component: Key } /** - * Float64Array of the component's newest changed tick per chunk of `chunkRows` rows, so change filters can skip - * unchanged chunks. Every `changed` column is followed by one, and each stamp sets both: `chunks[row >> chunkShift] = tick`. + * Float64Array of the component's newest changed tick per chunk of `2 ** chunkShift` rows, so change filters can + * skip unchanged chunks. Every `changed` column is followed by one, then by its `blocks` column. */ - | { readonly kind: "chunks"; readonly component: Key }; + | { readonly kind: "chunks"; readonly component: Key } + /** + * Float64Array of the component's newest changed tick per block of `2 ** blockShift` rows, so change filters skip + * the unchanged blocks of a chunk that changed. A stamp sets all three: the row's tick, its chunk's and its block's. + */ + | { readonly kind: "blocks"; readonly component: Key }; /** Rows per chunk of change ticks is `2 ** chunkShift`. */ export const chunkShift = 10; +/** Rows per block of change ticks is `2 ** blockShift`: a chunk holds `2 ** (chunkShift - blockShift)` blocks. */ +export const blockShift = 6; /** A leaf of a resource a kernel reduces into, folded with `operator`. */ export type Reduced = { readonly resource: Key; readonly operator: Reduction; readonly path: Path }; @@ -146,8 +153,11 @@ export namespace Program { | { readonly kind: "set"; readonly local: number; readonly value: Expression } /** `columns[column][row] = value`, with typed-array conversion. */ | { readonly kind: "store"; readonly column: number; readonly value: Expression } - /** `columns[column][row] = tick` and `columns[chunks][row >> chunkShift] = tick`, for a changed column and its chunk column. */ - | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + /** + * `columns[column][row] = tick`, `columns[chunks][row >> chunkShift] = tick` and + * `columns[blocks][row >> blockShift] = tick`, for a changed column and its chunk and block columns. + */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number; readonly blocks: number } | { readonly kind: "if"; readonly test: Expression; readonly consequent: readonly Statement[]; readonly alternate: readonly Statement[] } /** WASM `block`: a branch to it exits it. */ | { readonly kind: "block"; readonly body: readonly Statement[] } diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index a8174185..91a336a8 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -105,7 +105,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript return column; } const id = idOf(state, column.component); - if (column.kind === "changed" || column.kind === "chunks") { + if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks") { dense(); return { kind: column.kind, id, leaf: 0 }; } @@ -187,6 +187,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript if (storage === undefined) return fail(`column of "${labelOf(state.keys[source.id])}" isn't in its components.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; + if (source.kind === "blocks") return storage.blockChanged; const layout = state.layouts[source.id]; return layout.kind === "linear" ? storage.views[layout.leaves[source.leaf].view] : fail("a field column isn't linear."); }); @@ -238,12 +239,12 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript }, })); /** - * Changed and chunk columns of components no registered system filters on are muted: nothing reads those ticks, - * so the WASM drops their stamps and the JS kernel stamps one dummy element. Chunk columns of components held on - * the GPU stay, since uploads read them. + * Changed, chunk and block columns of components no registered system filters on are muted: nothing reads those + * ticks, so the WASM drops their stamps and the JS kernel stamps one dummy element. Chunk columns of components + * held on the GPU stay, since uploads read them; block columns, which only filters read, go with the row ticks. */ const stamped = (source: Extract): boolean => state.observed.has(source.id) || (source.kind === "chunks" && state.resident.has(source.id)); - const mutedNow = (): boolean[] => sources.map(source => (source.kind === "changed" || source.kind === "chunks") && !stamped(source)); + const mutedNow = (): boolean[] => sources.map(source => (source.kind === "changed" || source.kind === "chunks" || source.kind === "blocks") && !stamped(source)); const upWasmColumns: WasmColumn[] = upSources.map(source => { const layout = state.layouts[source.id]; return { view: source.leaf.view, stride: layout.kind === "linear" ? layout.stride : 0, offset: source.leaf.offset, boolean: source.leaf.scalar === "boolean", component: source.id }; diff --git a/packages/ecs/src/implementation/world/layout-directory.ts b/packages/ecs/src/implementation/world/layout-directory.ts index 47a53f2c..910f9552 100644 --- a/packages/ecs/src/implementation/world/layout-directory.ts +++ b/packages/ecs/src/implementation/world/layout-directory.ts @@ -38,8 +38,8 @@ export const structureWaitersWord = 13; /** * An archetype record: its capacity, `entities` offset, storage count, version, flags and row count, then per storage - * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, and chunk count. - * Offsets a storage doesn't have are 0. A record is allocated once, since an archetype's storages are fixed, and never + * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, chunk count, and + * `blockAdded` and `blockChanged` offsets, of `chunks << (chunkShift - blockShift)` blocks. Offsets a storage doesn't have are 0. A record is allocated once, since an archetype's storages are fixed, and never * moves. Its count is written at flushes while a joined thread may read it, for archetypes whose count changed. */ export const recordCapacity = 0; @@ -53,7 +53,7 @@ export const recordWords = 6; export const resourceFlag = 1; /** Record flags: it holds reports, which only queries that require a component see. */ export const metaFlag = 2; -export const storageWords = 8; +export const storageWords = 10; export const storageComponent = 0; export const storageFlags = 1; export const storageColumn = 2; @@ -62,6 +62,8 @@ export const storageChanged = 4; export const storageChunkAdded = 5; export const storageChunkChanged = 6; export const storageChunks = 7; +export const storageBlockAdded = 8; +export const storageBlockChanged = 9; /** * Storage flags: it keeps ticks; it has a column in linear memory, a string's of handles included; its objects are a joined thread's, whose number is * the flags from `threadShift` on, and its column holds each row's tag (`move-log.ts`). @@ -148,6 +150,8 @@ const writeArchetype = (directory: LayoutDirectory, archetype: Archetype): void record[base + storageChunkAdded] = storage.ticked ? storage.chunkAdded.byteOffset : 0; record[base + storageChunkChanged] = storage.ticked ? storage.chunkChanged.byteOffset : 0; record[base + storageChunks] = storage.chunkAdded.length; + record[base + storageBlockAdded] = storage.ticked ? storage.blockAdded.byteOffset : 0; + record[base + storageBlockChanged] = storage.ticked ? storage.blockChanged.byteOffset : 0; } Atomics.store(record, recordVersion, archetype.version); // Entered once complete, so a reader that finds it finds a whole record. diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 396f7a00..22ef6abb 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -163,6 +163,27 @@ Long hash buckets left unsorted until read by position, against `1ff6c206`, node - **A sort moves 8-byte words:** shared memory copies an element at a time, a 150K-word `copyWithin` 193 µs as `Int32Array` against 25 as `Float64Array`; a shift by an odd number of words goes through a scratch, 58 µs, once it's at least 1,024 words, the two views of it it makes costing tens of nanoseconds against a microsecond's copy; a shorter one copies a word at a time and allocates nothing. `bucket move 100K in order` 222 → 68 µs; every odd shift a word at a time, 180 µs, and `bucket move 10K in order` 12.7 → 23 µs. - **A full unsorted bucket with holes sorts in place rather than moving its segment, and one that grows gets half its length more spare slots:** `rekey 1M` 15.5 → 14.4 ms, from relocated segments, buffer growth and compactions. +Block ticks, alternating against `998738a3`, node, medians of five rounds for the filter benchmarks and three for the rest; chromium, medians of three. New benchmarks (`perftest/filters.ts`), 100K rows of one archetype after a write of one row, ten rows 10,000 apart, or every row, then eight main-thread systems or four WASM kernels filtering on `changed`, timed a run of them: + +| Benchmark | Before | Now | +|---|---|---| +| `changed one 100K`, eight systems | 34.9 µs | 5.04 µs | +| `changed few 100K` | 421 µs | 11.5 µs | +| `changed all 100K` | 8.49 ms | 4.69 ms | +| `changed one 100K two filters`, the other component unwritten | 37.6 µs | 8.42 µs | +| `changed one 100K kernels`, four WASM kernels | 5.21 µs | 4.42 µs | +| `changed few 100K kernels` | 15.2 µs | 7.33 µs | +| `changed all 100K kernels` | 504 µs | 420 µs | +| `sparse changed`, 1M, 1% scattered | 354 µs | 317 µs | +| `sparse changed clustered` | 29.3 µs | 28.0 µs | + +In chromium: `sparse changed main` 295 → 260 µs, `sparse changed workers` 70 → 60 µs, `changed few 100K` 115 → 5 µs, `changed one 100K two filters` 40 → 5 µs (timers step by 5 µs), `changed all 100K kernels` 485 → 395 µs. Readers of key and parent ticks: `up one root changed` 27.8 → 26.4 µs, `up filtered after churn` 54.5 → 51.4 µs, `rekey 1M` 13.6 → 12.4 ms, `read run changed` 14.5 → 13.3 µs. Writes within noise in node (`create`, `create flat`, `move`, `churn`, `retag`, `emit`, `emit main`, `reduce`, `chain`, `follow`, `spawn bulk`, `spawn bulk kernel`, `add tag`, `add value`, `add replace`, `despawn bulk`, `index refresh 1M 1%`, `sync point one spawn`, `move 1K`) and chromium (`move`, `chain`, `emit` and `spawn bulk kernel`, main and workers), and so are `run idle`, `run observer`, `index idle`, `observer idle 100`, `joined observer 10K` and `observe gpu entity factory`. Memory: a ticked storage's two block arrays, one for a constant, take 8 bytes per 64 rows each, 1.6% of its row ticks, and each storage's layout record two more words. + +- **Every row tick's writer raises its block's tick,** one store beside the chunk's: row writes on any thread, kernels (once per call for a block every row of the call reaches, in JS and WASM), the sync point's writes, spawns and moves, swaps into removed rows, a GPU kernel's column stamp lifted onto a joined thread's rows, and joined threads' own ticks. The arrays live in the arena beside the chunk ticks, and the layout directory gives workers and joined threads their offsets. +- **Main-thread, worker JS and joined-thread filters skip chunks, then blocks, then rows, each in a loop over one array,** and a passing range runs on by row ticks alone until a row doesn't pass; the scan was about 5 ns a row, with a branch per row on the filter count, and is now under 1 ns: `changed all 100K` 8.49 → 4.69 ms. +- **WASM kernels find runs of passing blocks in an outer loop and run their rows in an inner one,** a chunk's passing blocks read into a mask once and each run found with `ctz`, a chunk with more than half its blocks passing taken as one run; every row pays one comparison against the run's end, where it paid a test of whether it started a chunk: `changed all 100K kernels` 504 → 420 µs. +- **Index refreshes and `up` find changed key-part and filter rows by blocks too.** + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -175,6 +196,7 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ **Change filters.** - Chunk ticks, the newest tick per 1024 rows, so filters skip unchanged chunks: clustered sparse to ~0.09 ms. +- Block ticks, the newest tick per 64 rows, so filters skip a changed chunk's unchanged blocks: eight systems finding one changed row of 100K 34.9 → 5.04 µs (above). - Filters inside WASM kernels, one call per archetype instead of one per passing range: uniform sparse 3.5 → 1.15 ms, clustered to 0.02 ms. **Lookups.** @@ -306,6 +328,10 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **Copying drained columns into fresh, exactly sized arrays:** planning 2.60 → 2.45 ms in chromium, but the copies cost more than that. - **The main thread claiming pieces too:** no gain; workers that wake late are slow at first, so the main thread does about what it would alone. - **A loop per main-thread system** (eval or emitted by the plugin): 5–10% on top of fast-property rows; a micro-benchmark of 50 systems with one loop literal each measured 16%. The tuple each `row[component]` read allocates stays, and it costs more than the shared call site. Worth revisiting with compiled main-thread bodies, where reads become column loads. +- **A WASM kernel testing block starts in its one row loop,** a block's tick checked at each row that starts one, inside which a chunk's start checks the chunk's: `changed all 100K kernels` 504 → 576 µs, from a branch taken every 64 rows that the predictor misses, where the chunk test it replaced was taken every 1024. Checking blocks only at the eight-row SIMD checks, every eighth of them, still cost 7% in a microbenchmark of one kernel over 100K rows that all changed. The outer loop over runs of blocks takes it to 420 µs. +- **Every run of passing blocks as its own run in WASM,** however many of a chunk's blocks pass: 1% of 100K rows changed, scattered, one filter, in a microbenchmark, 31 µs against 24 µs for chunks alone, since a run's end costs more than the eight-row checks spend on a block between two runs. Taking a chunk with more than half its blocks passing as one run measured 23.9 µs, more than a quarter 23.9 µs and more than three quarters 32 µs; one changed row 0.21–0.25 µs against 0.33 for chunks alone at each. +- **Blocks of 32 rows:** against 64, eight systems finding one row of 100K 4.75 against 4.92 µs and ten rows 10.2 against 11.5 µs, but `sparse changed` 421 against 294 µs, a WASM kernel's chunk of 32 blocks with 1% of rows changed having fewer than half passing, so its many runs each pay their end; and twice the memory. Worth revisiting if one-row changes dominate real filters on main-thread systems and WASM finds runs another way. +- **Several filters scanned filter by filter,** each array's next newer tick found in a loop of its own and the earliest taken, in a microbenchmark of three filters: one change of 10K rows 0.40 against 0.39 µs, 1% of 10K 32.3 against 28.8 µs, 100K rows with nothing changed 0.18 against 0.38 µs. Mixed, so the filters are tested together at each index. Worth revisiting if systems filtering on several components over large archetypes that rarely change show in a profile. - **Removing a sync point's rows after all its entries, each archetype's from the highest,** so every row that stays moves at most once: churn 1M +13%, retag +6%. Sorting, the map of rows and a second pass over scattered rows cost more than the copies saved, and copying cells is only about 12% of retag; planning, the system's body and per-entry work make up the rest. Worth revisiting only with column-major moves per pair of archetypes, where removals are batched too. - **Unrolled f32 lane writers for staged spawns:** create 1M −5% for 30 more lines. Profiles attribute the caller's tuple allocations to the writer, which inflates it. Worth revisiting if spawns stop allocating tuples. - **Faster value-object spawns** (`create flat dynamic`): `Object.keys` and the caller's own spreads dominate, on a path the plugin already avoids for literal keys. Worth revisiting only if spawns from data objects matter to real workloads. diff --git a/packages/ecs/src/implementation/world/protocol.ts b/packages/ecs/src/implementation/world/protocol.ts index f2013070..fcf38496 100644 --- a/packages/ecs/src/implementation/world/protocol.ts +++ b/packages/ecs/src/implementation/world/protocol.ts @@ -5,7 +5,7 @@ import type { WasmScalar } from './wasm.js'; import type { IndexReaderSetup } from './index-readers.js'; /** Where a kernel column comes from in any archetype it matches. */ -export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks"; readonly id: number; readonly leaf: number }; +export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks" | "blocks"; readonly id: number; readonly leaf: number }; /** Component ids of one lookup, and for a lookup by entity with `changed`, those it watches. */ export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; diff --git a/packages/ecs/src/implementation/world/ranges.test.ts b/packages/ecs/src/implementation/world/ranges.test.ts new file mode 100644 index 00000000..03cd3402 --- /dev/null +++ b/packages/ecs/src/implementation/world/ranges.test.ts @@ -0,0 +1,66 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { blockShift, chunkShift } from "./kernel.js"; +import { forEachNewerRow, forEachPassingRange, type Filter } from "./ranges.js"; + +const rows = 5000; + +/** A filter over `rows` rows at tick 1, with `changed` rows at tick 9, their chunks and blocks raised. */ +const filterOf = (changed: readonly number[]): { readonly ticks: Float64Array; readonly chunks: Float64Array; readonly blocks: Float64Array } => { + const ticks = new Float64Array(rows).fill(1); + const chunks = new Float64Array((rows >> chunkShift) + 1).fill(1); + const blocks = new Float64Array(chunks.length << (chunkShift - blockShift)).fill(1); + for (const row of changed) { + ticks[row] = chunks[row >> chunkShift] = blocks[row >> blockShift] = 9; + } + return { ticks, chunks, blocks }; +}; + +const ranges = (filters: readonly Filter[], lastRun: number, start = 0, end = rows): (readonly [number, number])[] => { + const found: (readonly [number, number])[] = []; + forEachPassingRange(filters, lastRun, start, end, (first, last) => found.push([first, last])); + return found; +}; + +describe("forEachPassingRange", () => { + it("gives each maximal run of rows with a newer tick, across blocks and chunks", () => { + const filter = filterOf([3, 4, 5, 63, 64, 1023, 1024, 4999]); + expect(ranges([filter], 5)).toEqual([[3, 6], [63, 65], [1023, 1025], [4999, 5000]]); + expect(ranges([filter], 5, 4, 1024)).toEqual([[4, 6], [63, 65], [1023, 1024]]); + expect(ranges([filter], 9)).toEqual([]); + }); + + it("skips a chunk or a block whose tick isn't newer without reading its rows", () => { + const filter = filterOf([2000]); + filter.ticks[2100] = 9; + filter.ticks[3100] = 9; + // Row 2100's block and row 3100's chunk say nothing in them changed. + expect(ranges([filter], 5)).toEqual([[2000, 2001]]); + }); + + it("passes a row any of several filters passes", () => { + const first = filterOf([10, 3000]); + const second = filterOf([11, 4000]); + expect(ranges([first, second], 5)).toEqual([[10, 12], [3000, 3001], [4000, 4001]]); + expect(ranges([first, second], 5, 11, 3001)).toEqual([[11, 12], [3000, 3001]]); + }); + + it("passes every row with no filters or a lastRun below 0", () => { + const filter = filterOf([]); + expect(ranges([], 5, 7, 70)).toEqual([[7, 70]]); + expect(ranges([filter], -1, 7, 70)).toEqual([[7, 70]]); + }); +}); + +describe("forEachNewerRow", () => { + it("gives each row with a newer tick, skipping chunks and blocks whose tick isn't", () => { + const { ticks, chunks, blocks } = filterOf([0, 64, 1500, 4999]); + ticks[1600] = 9; + const found: number[] = []; + forEachNewerRow(ticks, chunks, blocks, rows, 5, row => found.push(row)); + expect(found).toEqual([0, 64, 1500, 4999]); + found.length = 0; + forEachNewerRow(ticks, chunks, blocks, 1501, 5, row => found.push(row)); + expect(found).toEqual([0, 64, 1500]); + }); +}); diff --git a/packages/ecs/src/implementation/world/ranges.ts b/packages/ecs/src/implementation/world/ranges.ts index b1f2dd2d..4653599e 100644 --- a/packages/ecs/src/implementation/world/ranges.ts +++ b/packages/ecs/src/implementation/world/ranges.ts @@ -1,45 +1,110 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; -/** One `added` or `changed` filter: per-row ticks, and the newest tick of each chunk of rows. */ -export type Filter = { readonly ticks: Float64Array; readonly chunks: Float64Array }; +/** + * One `added` or `changed` filter: per-row ticks, the newest tick of each chunk of rows, and the newest tick of each + * block of rows within a chunk. + */ +export type Filter = { readonly ticks: Float64Array; readonly chunks: Float64Array; readonly blocks: Float64Array }; + +/** Whether any filter's chunk tick of `chunk` is newer than `lastRun`. */ +const chunkNewer = (filters: readonly Filter[], chunk: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].chunks[chunk] > lastRun) return true; + return false; +}; + +const blockNewer = (filters: readonly Filter[], block: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].blocks[block] > lastRun) return true; + return false; +}; + +const rowNewer = (filters: readonly Filter[], row: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].ticks[row] > lastRun) return true; + return false; +}; + +/** + * Calls `each` with every row below `count` whose tick in `ticks` is newer than `since`, skipping chunks, then blocks, + * whose ticks aren't. + */ +export const forEachNewerRow = (ticks: Float64Array, chunks: Float64Array, blocks: Float64Array, count: number, since: number, each: (row: number) => void): void => { + for (let chunk = 0, last = (count - 1) >> chunkShift; chunk <= last; chunk++) { + if (!(chunks[chunk] > since)) continue; + for (let block = chunk << (chunkShift - blockShift), end = Math.min((chunk + 1) << (chunkShift - blockShift), ((count - 1) >> blockShift) + 1); block < end; block++) { + if (!(blocks[block] > since)) continue; + for (let row = block << blockShift, rowEnd = Math.min(count, (block + 1) << blockShift); row < rowEnd; row++) if (ticks[row] > since) each(row); + } + } +}; + +/** `forEachPassingRange` for one filter, whose loops each read one array. */ +const forEachPassingRangeOf = (filter: Filter, lastRun: number, start: number, end: number, call: (start: number, end: number) => void): void => { + const { ticks, chunks, blocks } = filter; + const lastChunk = (end - 1) >> chunkShift; + let row = start; + while (row < end) { + let chunk = row >> chunkShift; + if (!(chunks[chunk] > lastRun)) { + do chunk++; while (chunk <= lastChunk && !(chunks[chunk] > lastRun)); + if (chunk > lastChunk) return; + row = chunk << chunkShift; + } + const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); + const lastBlock = (chunkEnd - 1) >> blockShift; + let block = row >> blockShift; + if (!(blocks[block] > lastRun)) { + do block++; while (block <= lastBlock && !(blocks[block] > lastRun)); + if (block > lastBlock) { + row = chunkEnd; + continue; + } + row = block << blockShift; + } + const blockEnd = Math.min(chunkEnd, (block + 1) << blockShift); + while (row < blockEnd && !(ticks[row] > lastRun)) row++; + if (row === blockEnd) continue; + // A passing range runs on by row ticks alone, past blocks and chunks, until a row doesn't pass. + const first = row; + do row++; while (row < end && ticks[row] > lastRun); + call(first, row); + } +}; /** * Calls `call` for each maximal range of rows in `start` to `end` that passes the change filters: any tick newer - * than `lastRun`. Chunks with no newer tick are skipped without reading their rows. With no filters, or `lastRun` - * below 0, every row passes. + * than `lastRun`. Chunks, then blocks, with no newer tick are skipped without reading their rows, so finding the rows + * costs a check per chunk, one per block of a chunk that changed, and one per row of a block that changed. With no + * filters, or `lastRun` below 0, every row passes. */ export const forEachPassingRange = (filters: readonly Filter[], lastRun: number, start: number, end: number, call: (start: number, end: number) => void): void => { if (lastRun < 0 || filters.length === 0) return call(start, end); - let first = -1; - const single = filters.length === 1 ? filters[0].ticks : null; - for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) { - const chunkStart = Math.max(start, chunk << chunkShift); - const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); - let candidate = false; - for (let i = 0; i < filters.length && !candidate; i++) candidate = filters[i].chunks[chunk] > lastRun; - if (!candidate) { - if (first >= 0) { - call(first, chunkStart); - first = -1; - } - continue; + if (start >= end) return; + if (filters.length === 1) return forEachPassingRangeOf(filters[0], lastRun, start, end, call); + const lastChunk = (end - 1) >> chunkShift; + let row = start; + while (row < end) { + let chunk = row >> chunkShift; + if (!chunkNewer(filters, chunk, lastRun)) { + do chunk++; while (chunk <= lastChunk && !chunkNewer(filters, chunk, lastRun)); + if (chunk > lastChunk) return; + row = chunk << chunkShift; } - for (let row = chunkStart; row < chunkEnd; row++) { - let passes: boolean; - if (single !== null) passes = single[row] > lastRun; - else { - passes = false; - for (let i = 0; i < filters.length && !passes; i++) passes = filters[i].ticks[row] > lastRun; - } - if (passes) { - if (first < 0) first = row; - } - else if (first >= 0) { - call(first, row); - first = -1; + const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); + const lastBlock = (chunkEnd - 1) >> blockShift; + let block = row >> blockShift; + if (!blockNewer(filters, block, lastRun)) { + do block++; while (block <= lastBlock && !blockNewer(filters, block, lastRun)); + if (block > lastBlock) { + row = chunkEnd; + continue; } + row = block << blockShift; } + const blockEnd = Math.min(chunkEnd, (block + 1) << blockShift); + while (row < blockEnd && !rowNewer(filters, row, lastRun)) row++; + if (row === blockEnd) continue; + const first = row; + do row++; while (row < end && rowNewer(filters, row, lastRun)); + call(first, row); } - if (first >= 0) call(first, end); }; diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts index 737b0b0f..98a51a3f 100644 --- a/packages/ecs/src/implementation/world/remote-systems.ts +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -6,8 +6,7 @@ import type { Warning } from '../../interface/report/failure.js'; import { declarationOf, idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import { unsharedKeyOf } from './index-keys.js'; import { layoutOf } from '../../interface/registry/layout.js'; -import type { Archetype } from './archetype.js'; -import { chunkShift } from './kernel.js'; +import { raiseRange, type Archetype } from './archetype.js'; import type { LocalIds, Segment } from './command-ring.js'; import type { ToJoined } from './joining.js'; import { lineWords, type WorkerError } from './protocol.js'; @@ -623,6 +622,6 @@ export const liftColumns = (archetype: Archetype, ids: readonly number[], since: if (storage === undefined || !storage.ticked || storage.columnChanged <= since) continue; const stamp = storage.columnChanged; for (let row = 0; row < archetype.count; row++) if (storage.changed[row] < stamp) storage.changed[row] = stamp; - for (let chunk = 0; chunk <= (archetype.count - 1) >> chunkShift; chunk++) if (storage.chunkChanged[chunk] < stamp) storage.chunkChanged[chunk] = stamp; + raiseRange(storage.chunkChanged, storage.blockChanged, 0, archetype.count, stamp); } }; diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 5168d531..be16c3a5 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -19,7 +19,7 @@ import type { WorldIndex } from './indexes.js'; * What an accessor row reads and writes: a storage, or a joined thread's view of one. A joined thread's own objects * have `tags`, the world's tags of their rows, which a write marks as the thread's own value. */ -export type RowStorage = Pick; +export type RowStorage = Pick; /** What a row binds to: an archetype, or a joined thread's view of one. */ export type RowArchetype = { readonly id: number; readonly entities: Float64Array; readonly storageById: readonly (RowStorage | undefined)[] }; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index e1ad699f..1ed82036 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -379,7 +379,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { }; /** - * By archetype: its change filters, `added` then `changed`, rows and chunks, kept until its arrays are replaced; + * By archetype: its change filters, `added` then `changed`, rows, chunks and blocks, kept until its arrays are replaced; * and the WASM addresses of its columns and filters, for the WASM kernel `addressed` they were taken for, since a * kernel loaded again may stamp a tick column the last one muted. */ @@ -392,11 +392,11 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const filters = addedIds.length + changedIds.length === 0 ? noFilters : [ ...addedIds.flatMap(id => { const storage = archetype.storageById[id]; - return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }]; }), ...changedIds.flatMap(id => { const storage = archetype.storageById[id]; - return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }]; }), ]; const entry: Filtered = { version: archetype.version, filters, addresses: noAddresses, addressed: null }; @@ -456,6 +456,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { ...bound.columnsOf(archetype).map(column => column.byteOffset), ...entry.filters.map(filter => filter.ticks.byteOffset), ...entry.filters.map(filter => filter.chunks.byteOffset), + ...entry.filters.map(filter => filter.blocks.byteOffset), ]; entry.addressed = bound.wasm; } diff --git a/packages/ecs/src/implementation/world/synchronize.ts b/packages/ecs/src/implementation/world/synchronize.ts index 6777bc46..7c9d33c5 100644 --- a/packages/ecs/src/implementation/world/synchronize.ts +++ b/packages/ecs/src/implementation/world/synchronize.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Views } from '../../interface/registry/layout.js'; import { copyValue, ensureCapacity, swapRemove, writeF32, writeF32Lanes, writeF64, writeI32, writeU32, type Archetype, type Storage } from './archetype.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { addCommand, clearBuffer, despawnCommand, endOf, referenceAt, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; import { readCell, writeCell } from './cells.js'; import { commitBatches, commitStaged } from './batches.js'; @@ -282,10 +282,12 @@ const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Arche if (storage.changed !== storage.added || starts) { storage.changed[toRow] = tick; storage.chunkChanged[toRow >> chunkShift] = tick; + storage.blockChanged[toRow >> blockShift] = tick; } if (starts) { storage.added[toRow] = tick; storage.chunkAdded[toRow >> chunkShift] = tick; + storage.blockAdded[toRow >> blockShift] = tick; } } }; @@ -298,6 +300,7 @@ export const writeDefault = (state: State, storage: Storage, row: number, tick: if (!storage.ticked) return; storage.added[row] = storage.changed[row] = tick; storage.chunkAdded[row >> chunkShift] = storage.chunkChanged[row >> chunkShift] = tick; + storage.blockAdded[row >> blockShift] = storage.blockChanged[row >> blockShift] = tick; }; /** Writes the values an entry's commands give into its row, in issue order. */ @@ -416,10 +419,13 @@ const copyColumn = (from: Storage, to: Storage, plan: Plan, order: Int32Array, f const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's tick is the newest of its rows, so a row moving in can raise it. + // A chunk's and a block's tick are the newest of their rows, so a row moving in can raise them. const chunk = toRow >> chunkShift; if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; + const block = toRow >> blockShift; + if (added > to.blockAdded[block]) to.blockAdded[block] = added; + if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; } }; diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 012667d8..55b863d0 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -287,11 +287,11 @@ const filtersOf = (tail: Copied, archetype: Archetype): Filter[] => { const filters: Filter[] = []; for (const id of tail.added) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }); } for (const id of tail.changed) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }); } return filters; }; diff --git a/packages/ecs/src/implementation/world/thread-directory.ts b/packages/ecs/src/implementation/world/thread-directory.ts index 220fc0a1..c0fcae4c 100644 --- a/packages/ecs/src/implementation/world/thread-directory.ts +++ b/packages/ecs/src/implementation/world/thread-directory.ts @@ -7,9 +7,10 @@ import { tableLength } from './entities.js'; import { archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, resourceEntityWord, resourceFlag, - rowField, storageAdded, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, - storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, + rowField, storageAdded, storageBlockAdded, storageBlockChanged, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, + storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, } from './layout-directory.js'; +import { blockShift, chunkShift } from './kernel.js'; import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; import { createStringReader } from './strings.js'; import type { Mirror, Mirrored } from './mirror.js'; @@ -133,6 +134,8 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror changed: layout.kind === "constant" ? added : changed, chunkAdded: ticked ? new Float64Array(buffer, words[base + storageChunkAdded], chunks) : noTicks, chunkChanged: ticked ? new Float64Array(buffer, words[layout.kind === "constant" ? base + storageChunkAdded : base + storageChunkChanged], chunks) : noTicks, + blockAdded: ticked ? new Float64Array(buffer, words[base + storageBlockAdded], chunks << (chunkShift - blockShift)) : noTicks, + blockChanged: ticked ? new Float64Array(buffer, words[layout.kind === "constant" ? base + storageBlockAdded : base + storageBlockChanged], chunks << (chunkShift - blockShift)) : noTicks, }; }; diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index bef98710..e711606f 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -70,7 +70,7 @@ type ThreadFinalizer = { /** A storage a stop list's column holds, for a row over its stops. */ const columnStorage = (component: number, layout: Layout, views: RowStorage["views"], values: unknown[], strings: StringStore | null): FinalizedStorage => ({ - component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), ticked: false, added: noTicks, changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, tags: noTags, + component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), ticked: false, added: noTicks, changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, blockAdded: noTicks, blockChanged: noTicks, tags: noTags, }); /** diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index e7cfd8df..17e59997 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -30,7 +30,7 @@ import { createThreadFinalizers } from './thread-finalizers.js'; import { createThreadIndex, type ThreadIndex } from './thread-indexes.js'; import type { EntityViews, ThreadArchetype, ThreadDirectory, ThreadStorage } from './thread-directory.js'; import { keptSorted } from './index-keys.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { doneWord, endWord, headExecuted, headRan, progressWord, snapshotFlagsWord, snapshotLayout, snapshotOffsetWord, snapshotResourceArchetypeWord, snapshotResourceRowWord, snapshotRowsWord, snapshotCapacityWord, snapshotted, tailBlockWords, tailChunk, waitingWord, @@ -764,11 +764,11 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const filters: Filter[] = []; for (const id of addedIds) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }); } for (const id of changedIds) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }); } filterCache.set(archetype.id, { archetype, filters }); return filters; @@ -1109,10 +1109,12 @@ type Columned = Extract; /** A storage over a snapshot's column, or of values only this thread holds, with ticks of its own for `ticked` rows. */ const localStorage = (component: number, layout: Layout, views: Views, values: unknown[], ticked: number, strings: StringStore): ThreadStorage => { const chunks = ticked === 0 ? 0 : (ticked >> chunkShift) + 1; + const blocks = ticked === 0 ? 0 : (ticked >> blockShift) + 1; return { component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), tags: noTags, ticked: ticked > 0, added: ticked === 0 ? noTicks : new Float64Array(ticked), changed: ticked === 0 ? noTicks : new Float64Array(ticked), chunkAdded: chunks === 0 ? noTicks : new Float64Array(chunks), chunkChanged: chunks === 0 ? noTicks : new Float64Array(chunks), + blockAdded: blocks === 0 ? noTicks : new Float64Array(blocks), blockChanged: blocks === 0 ? noTicks : new Float64Array(blocks), }; }; diff --git a/packages/ecs/src/implementation/world/threads.browser.test.ts b/packages/ecs/src/implementation/world/threads.browser.test.ts index a7c89510..04e8181c 100644 --- a/packages/ecs/src/implementation/world/threads.browser.test.ts +++ b/packages/ecs/src/implementation/world/threads.browser.test.ts @@ -6,6 +6,7 @@ import { poolSizeCases } from "./pool-size.cases.js"; import { dispatchCases } from "./dispatch.cases.js"; import { reductionsCases } from "./reductions.cases.js"; import { observedWorkersCases } from "./observed-workers.cases.js"; +import { changeBlocksCases } from "./change-blocks.cases.js"; import { upWorkersCases } from "./up-workers.cases.js"; import { portsCases } from "./ports.cases.js"; import { commandRingsCases } from "./command-rings.cases.js"; @@ -30,6 +31,7 @@ poolSizeCases(() => import("./pool-size.fixture.js")); dispatchCases(() => import("./dispatch.fixture.js")); reductionsCases(() => import("./reductions.fixture.js")); observedWorkersCases(() => import("./observed-workers.fixture.js")); +changeBlocksCases(() => import("./change-blocks.fixture.js")); upWorkersCases(() => import("./up-workers.fixture.js")); portsCases(() => import("./ports.fixture.js")); commandRingsCases(() => import("./command-rings.fixture.js")); diff --git a/packages/ecs/src/implementation/world/threads.test.ts b/packages/ecs/src/implementation/world/threads.test.ts index c1ccb4fa..3581f207 100644 --- a/packages/ecs/src/implementation/world/threads.test.ts +++ b/packages/ecs/src/implementation/world/threads.test.ts @@ -14,6 +14,8 @@ import type * as Reductions from "./reductions.fixture.js"; import { reductionsCases } from "./reductions.cases.js"; import type * as ObservedWorkers from "./observed-workers.fixture.js"; import { observedWorkersCases } from "./observed-workers.cases.js"; +import type * as ChangeBlocks from "./change-blocks.fixture.js"; +import { changeBlocksCases } from "./change-blocks.cases.js"; import type * as UpWorkers from "./up-workers.fixture.js"; import { upWorkersCases } from "./up-workers.cases.js"; import type * as Ports from "./ports.fixture.js"; @@ -56,6 +58,7 @@ poolSizeCases((): Promise => builtFixture("pool-size")); dispatchCases((): Promise => builtFixture("dispatch")); reductionsCases((): Promise => builtFixture("reductions")); observedWorkersCases((): Promise => builtFixture("observed-workers")); +changeBlocksCases((): Promise => builtFixture("change-blocks")); upWorkersCases((): Promise => builtFixture("up-workers")); portsCases((): Promise => builtFixture("ports")); commandRingsCases((): Promise => builtFixture("command-rings")); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts index da846c3a..eff4e4c4 100644 --- a/packages/ecs/src/implementation/world/up.ts +++ b/packages/ecs/src/implementation/world/up.ts @@ -3,7 +3,7 @@ import type { Up } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { generationOf, indexOf } from './entities.js'; import { journalEnd } from './journal.js'; -import { chunkShift } from './kernel.js'; +import { forEachNewerRow } from './ranges.js'; import { idOf, type Key, type State } from './state.js'; import { allocateArray, freeArray } from './arrays.js'; import type { Memory } from './memory.js'; @@ -603,15 +603,12 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { const changedRows = (archetype: Archetype, id: number, added: boolean, since: number, each: (row: number) => void): void => { const storage = archetype.storageById[id]; if (storage === undefined) return; - const whole = storage.columnChanged > since; - const ticks = added ? storage.added : storage.changed; - const chunks = added ? storage.chunkAdded : storage.chunkChanged; - for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { - if (!whole && !(chunks[chunk] > since)) continue; - for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { - if (whole || ticks[row] > since) each(row); - } + if (storage.columnChanged > since) { + for (let row = 0; row < archetype.count; row++) each(row); + return; } + if (added) forEachNewerRow(storage.added, storage.chunkAdded, storage.blockAdded, archetype.count, since, each); + else forEachNewerRow(storage.changed, storage.chunkChanged, storage.blockChanged, archetype.count, since, each); }; /** diff --git a/packages/ecs/src/implementation/world/wasm.test.ts b/packages/ecs/src/implementation/world/wasm.test.ts index d2b72a00..6600e64c 100644 --- a/packages/ecs/src/implementation/world/wasm.test.ts +++ b/packages/ecs/src/implementation/world/wasm.test.ts @@ -138,12 +138,14 @@ describe("generateWasm with SIMD", () => { const floats = columns.map((_, k) => new Float32Array(memory.buffer, bases[k], 512)); const ticks = new Float64Array(memory.buffer, 4096 * 8, 64); const chunks = new Float64Array(memory.buffer, 4096 * 9, 4); + const blocks = new Float64Array(memory.buffer, 4096 * 10, 4); fill(floats, ticks); const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - // f64 columns are ticks, then their chunks. - runKernel(0, rows, tick, -1, ...bases.map((base, k) => columns[k].view !== "f64" ? base : columns[k].component === -2 ? 4096 * 9 : 4096 * 8)); - return { kernel, floats, ticks, chunks }; + // f64 columns are ticks, then their chunks, then their blocks. + const tickBase = (component: number): number => component === -3 ? 4096 * 10 : component === -2 ? 4096 * 9 : 4096 * 8; + runKernel(0, rows, tick, -1, ...bases.map((base, k) => columns[k].view !== "f64" ? base : tickBase(columns[k].component))); + return { kernel, floats, ticks, chunks, blocks }; }; const column = (component: number, stride: number, offset: number): WasmColumn => ({ view: "f32", stride, offset, boolean: false, component }); const read = (k: number): Expression => ({ kind: "column", column: k }); @@ -201,7 +203,7 @@ describe("generateWasm with SIMD", () => { column: leaf, value: add(add(local(leaf), { kind: "binary", operator: "*", left: local(leaves + leaf), right: { kind: "args", path: [] } }), { kind: "number", value: leaf + 1 }), })), - { kind: "stamp", column: 2 * leaves, chunks: 2 * leaves + 1 }, + { kind: "stamp", column: 2 * leaves, chunks: 2 * leaves + 1, blocks: 2 * leaves + 2 }, ], }; const columns: WasmColumn[] = [ @@ -209,6 +211,7 @@ describe("generateWasm with SIMD", () => { ...Array.from({ length: leaves }, (_, leaf) => column(1, stride, 4 * leaf)), { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }, ]; const kernel = generateWasm(program, columns, () => "number"); if (kernel === null) throw new Error("expected a kernel"); @@ -221,7 +224,7 @@ describe("generateWasm with SIMD", () => { const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); const [start, end] = [3, 22]; - runKernel(start, end, 7, -1, ...columns.slice(0, leaves).map(() => 4096), ...columns.slice(0, leaves).map(() => 8192), 16384, 20480, 0.5); + runKernel(start, end, 7, -1, ...columns.slice(0, leaves).map(() => 4096), ...columns.slice(0, leaves).map(() => 8192), 16384, 20480, 24576, 0.5); const row = (index: number): number => Math.floor(index / leaves); expect([...p]).toEqual([...p].map((_, i) => row(i) >= start && row(i) < end ? i + (2 * i + 100) * 0.5 + (i % leaves) + 1 : i)); expect([...ticks]).toEqual([...ticks].map((_, i) => i >= start && i < end ? 7 : 1)); @@ -230,11 +233,12 @@ describe("generateWasm with SIMD", () => { it("blends stores and tick stamps under an if, per lane", () => { const positive: Expression = { kind: "binary", operator: ">", left: read(0), right: { kind: "number", value: 0 } }; - const program: Program = { locals: 0, body: [{ kind: "if", test: positive, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3 }], alternate: [] }] }; + const program: Program = { locals: 0, body: [{ kind: "if", test: positive, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3, blocks: 4 }], alternate: [] }] }; const ticksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }; const chunksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }; + const blocksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }; const inputs = [1, -1, 2, -2, 0, 3, NaN, 4, -5, 6]; - const { kernel, floats, ticks, chunks } = runVector(program, [column(0, 4, 0), column(1, 4, 0), ticksColumn, chunksColumn], ([input, out], tickValues) => { + const { kernel, floats, ticks, chunks, blocks } = runVector(program, [column(0, 4, 0), column(1, 4, 0), ticksColumn, chunksColumn, blocksColumn], ([input, out], tickValues) => { input.set(inputs); out.fill(-7); tickValues.fill(1); @@ -243,6 +247,7 @@ describe("generateWasm with SIMD", () => { expect([...floats[1].subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? x : -7)); expect([...ticks.subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? 7 : 1)); expect(chunks[0]).toBe(7); + expect(blocks[0]).toBe(7); }); it("stays scalar for operations without an exact f32x4 equivalent", () => { @@ -253,7 +258,7 @@ describe("generateWasm with SIMD", () => { }); describe("generateWasm with change filters", () => { - it("runs only rows with a newer tick, skipping chunks whose tick isn't newer", () => { + it("runs only rows with a newer tick, skipping chunks and blocks whose tick isn't newer", () => { for (const simd of [false, true]) { // out = in, for rows whose filter tick is newer than lastRun 5. const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f32", stride: 4, offset: 0, boolean: false, component: 1 }]; @@ -266,46 +271,98 @@ describe("generateWasm with change filters", () => { const output = new Float32Array(memory.buffer, 16384, rows).fill(-1); const ticks = new Float64Array(memory.buffer, 32768, rows); const chunks = new Float64Array(memory.buffer, 65536, 3); - // Chunk 0's chunk tick is stale-new (8) but its rows are old; chunk 1 has three new rows; chunk 2 is old throughout. + const blocks = new Float64Array(memory.buffer, 66560, 48); + // Chunk 0's chunk and block ticks are stale-new (8) but its rows are old; chunk 1 has three new rows in + // blocks marked new; chunk 2 is old throughout. chunks.set([8, 9, 3]); - for (const row of [1030, 1500, 2047]) ticks[row] = 9; + blocks.fill(8, 0, 16).fill(3, 16); + for (const row of [1030, 1500, 2047]) { + ticks[row] = 9; + blocks[row >> 6] = 9; + } + ticks[1100] = 9; ticks[2050] = 9; const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof run !== "function") throw new Error("no run export"); - run(0, rows, 10, 5, 0, 16384, 32768, 65536); + run(0, rows, 10, 5, 0, 16384, 32768, 65536, 66560); const written = [...output].flatMap((value, row) => value === -1 ? [] : [row]); - // Row 2050 has a new tick, but its chunk says nothing in it changed, so it's skipped by design. + // Rows 1100 and 2050 have new ticks, but their block or chunk says nothing in it changed, so they're skipped by design. expect([simd, kernel.vectorized, written]).toEqual([simd, false, [1030, 1500, 2047]]); + // From a start inside a block, that block's tick is still checked. + output.fill(-1); + run(1029, 1040, 10, 5, 0, 16384, 32768, 65536, 66560); + expect([...output].flatMap((value, row) => value === -1 ? [] : [row])).toEqual([1030]); // lastRun below 0 passes everything. output.fill(-1); - run(0, rows, 10, -1, 0, 16384, 32768, 65536); + run(0, rows, 10, -1, 0, 16384, 32768, 65536, 66560); expect(output.every((value, row) => value === row)).toBe(true); } }); }); -describe("generateWasm chunk stamps", () => { +describe("generateWasm with several change filters", () => { + it("runs rows either filter passes, a chunk with most blocks passing as one run", () => { + for (const simd of [false, true]) { + const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f32", stride: 4, offset: 0, boolean: false, component: 1 }]; + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: { kind: "column", column: 0 } }] }, columns, () => "number", simd, 2); + if (kernel === null) throw new Error("expected a kernel"); + const rows = 2100; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); + const input = new Float32Array(memory.buffer, 0, rows); + for (let row = 0; row < rows; row++) input[row] = row; + const output = new Float32Array(memory.buffer, 16384, rows).fill(-1); + // Each filter's rows, chunks and blocks. + const ticks = [new Float64Array(memory.buffer, 32768, rows).fill(1), new Float64Array(memory.buffer, 65536 + 4096, rows).fill(1)]; + const chunks = [new Float64Array(memory.buffer, 65536, 3).fill(1), new Float64Array(memory.buffer, 65536 + 64, 3).fill(1)]; + const blocks = [new Float64Array(memory.buffer, 65536 + 1024, 48).fill(1), new Float64Array(memory.buffer, 65536 + 2048, 48).fill(1)]; + const mark = (filter: number, row: number): void => { + ticks[filter][row] = chunks[filter][row >> 10] = blocks[filter][row >> 6] = 9; + }; + // Chunk 0: the first filter marks ten blocks of sixteen, so the chunk runs whole, and a row in an + // unmarked block with a new tick runs too. Chunk 1: each filter marks a row; a new tick in an unmarked + // block is skipped. + for (let block = 0; block < 10; block++) mark(0, block * 64 + 1); + ticks[1][900] = 9; + chunks[1][0] = 9; + mark(0, 1030); + mark(1, 1500); + ticks[1][1700] = 9; + const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, rows, 10, 5, 0, 16384, 32768, 65536 + 4096, 65536, 65536 + 64, 65536 + 1024, 65536 + 2048); + const written = [...output].flatMap((value, row) => value === -1 ? [] : [row]); + expect([simd, written]).toEqual([simd, [...Array.from({ length: 10 }, (_, block) => block * 64 + 1), 900, 1030, 1500]]); + } + }); +}); + +describe("generateWasm chunk and block stamps", () => { const columns: WasmColumn[] = [ { view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }, ]; - const run = (program: Program, start: number, end: number): number[] => { + /** The blocks whose tick is 7. */ + const stampedBlocks = (blocks: Float64Array): number[] => [...blocks].flatMap((tick, block) => tick === 7 ? [block] : []); + const run = (program: Program, start: number, end: number): { readonly chunks: number[]; readonly blocks: number[] } => { const kernel = generateWasm(program, columns, () => "number"); if (kernel === null) throw new Error("expected a kernel"); const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); new Float32Array(memory.buffer, 0, 4096).fill(1); const chunks = new Float64Array(memory.buffer, 65536, 4); + const blocks = new Float64Array(memory.buffer, 66560, 64); const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - runKernel(start, end, 7, -1, 0, 32768, 65536); - return [...chunks]; + runKernel(start, end, 7, -1, 0, 32768, 65536, 66560); + return { chunks: [...chunks], blocks: stampedBlocks(blocks) }; }; - const stamp: Program.Statement = { kind: "stamp", column: 1, chunks: 2 }; + const stamp: Program.Statement = { kind: "stamp", column: 1, chunks: 2, blocks: 3 }; + const range = (first: number, last: number): number[] => Array.from({ length: last - first + 1 }, (_, k) => first + k); - it("stamps every chunk of the range once when every row stamps", () => { - expect(run({ locals: 0, body: [stamp] }, 1000, 2100)).toEqual([7, 7, 7, 0]); - expect(run({ locals: 0, body: [stamp] }, 5, 5)).toEqual([0, 0, 0, 0]); + it("stamps every chunk and block of the range once when every row stamps", () => { + expect(run({ locals: 0, body: [stamp] }, 1000, 2100)).toEqual({ chunks: [7, 7, 7, 0], blocks: range(1000 >> 6, 2099 >> 6) }); + expect(run({ locals: 0, body: [stamp] }, 5, 5)).toEqual({ chunks: [0, 0, 0, 0], blocks: [] }); }); it("stamps per row when a row can end before its stamp", () => { @@ -323,10 +380,12 @@ describe("generateWasm chunk stamps", () => { const values = new Float32Array(memory.buffer, 0, 4096).fill(1); values.fill(2, 1024, 2048); const chunks = new Float64Array(memory.buffer, 65536, 4); + const blocks = new Float64Array(memory.buffer, 66560, 64); const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - runKernel(0, 3000, 7, -1, 0, 32768, 65536); + runKernel(0, 3000, 7, -1, 0, 32768, 65536, 66560); expect([...chunks]).toEqual([7, 0, 7, 0]); + expect(stampedBlocks(blocks)).toEqual([...range(0, 15), ...range(32, 2999 >> 6)]); }); }); diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts index fe6846f8..1079eb39 100644 --- a/packages/ecs/src/implementation/world/wasm.ts +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift, type Program } from './kernel.js'; +import { blockShift, chunkShift, type Program } from './kernel.js'; import type { Reduction } from '../../interface/resource/reduction.js'; import type { View } from '../../interface/registry/layout.js'; import { withoutIntrinsics } from './intrinsics.js'; @@ -133,7 +133,7 @@ const op = { i32Const: 0x41, f64Const: 0x44, i32ReinterpretF32: 0xbc, i64ReinterpretF64: 0xbd, i32LtU: 0x49, i32GtU: 0x4b, i32LeU: 0x4d, i32GtS: 0x4a, f64Load2: 0x2b, i32Eqz: 0x45, i32GeS: 0x4e, f64Eq: 0x61, f64Ne: 0x62, f64Lt: 0x63, f64Gt: 0x64, f64Le: 0x65, f64Ge: 0x66, - i32Eq: 0x46, i32LtS: 0x48, i32LeS: 0x4c, i32Clz: 0x67, i32Add: 0x6a, i32Sub: 0x6b, i32Mul: 0x6c, i32And: 0x71, i32Or: 0x72, i32Xor: 0x73, i32Shl: 0x74, i32ShrS: 0x75, i32ShrU: 0x76, + i32Eq: 0x46, i32LtS: 0x48, i32LeS: 0x4c, i32Clz: 0x67, i32Ctz: 0x68, i32Popcnt: 0x69, i32Add: 0x6a, i32Sub: 0x6b, i32Mul: 0x6c, i32And: 0x71, i32Or: 0x72, i32Xor: 0x73, i32Shl: 0x74, i32ShrS: 0x75, i32ShrU: 0x76, f64Abs: 0x99, f64Neg: 0x9a, f64Ceil: 0x9b, f64Floor: 0x9c, f64Trunc: 0x9d, f64Sqrt: 0x9f, f64Add: 0xa0, f64Sub: 0xa1, f64Mul: 0xa2, f64Div: 0xa3, f64Min: 0xa4, f64Max: 0xa5, i32WrapI64: 0xa7, i32GeU: 0x4f, i32Ne: 0x47, i64Const: 0x42, i64ShrU: 0x88, i64RemS: 0x81, f64ConvertI64S: 0xb9, f64Copysign: 0xa6, f32DemoteF64: 0xb6, f64ConvertI32S: 0xb7, f64ConvertI32U: 0xb8, f64PromoteF32: 0xbb, @@ -352,16 +352,19 @@ export const generateWasm = ( }; while (inferIntegral(program.body)); - // Parameters: start, end, tick, lastRun, column addresses, filter row tick addresses, filter chunk tick addresses, scalars, and with reductions the partials address. + // Parameters: start, end, tick, lastRun, column addresses, filter row, chunk and block tick addresses, scalars, and with reductions the partials address. const columnParameter = (column: number): number => 4 + column; const filterRowParameter = (filter: number): number => 4 + columns.length + filter; const filterChunkParameter = (filter: number): number => 4 + columns.length + filters + filter; + const filterBlockParameter = (filter: number): number => 4 + columns.length + 2 * filters + filter; + /** Parameters before the lookups': the fixed four, the columns' and the filters'. */ + const beforeLookups = 4 + columns.length + 3 * filters; /** * With lookups: the entity table (archetype, row and generation addresses, then its length), then each lookup's * directory address and record count, all as f64 so workers pass them like scalars. */ const lookupParameters = lookups.length === 0 ? 0 : 4 + 2 * lookups.length + (watching ? 1 : 0); - const lookupParameter = (index: number): number => 4 + columns.length + 2 * filters + index; + const lookupParameter = (index: number): number => beforeLookups + index; /** For `up`: the rows' and their parents' rows' addresses, whether there's a parent, then each parent column's address. */ const upMode = program.up === true; const upParameters = upMode ? 3 + upColumns.length : 0; @@ -373,9 +376,9 @@ export const generateWasm = ( const index = indexLookups[lookup]; if (index !== null && index !== undefined) indexParameters += indexParameterCount(index); } - const indexParameter = (lookup: number, index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexOffsets[lookup] + index; - const upParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + index; - const scalarParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters + index; + const indexParameter = (lookup: number, index: number): number => beforeLookups + lookupParameters + indexOffsets[lookup] + index; + const upParameter = (index: number): number => beforeLookups + lookupParameters + indexParameters + index; + const scalarParameter = (index: number): number => beforeLookups + lookupParameters + indexParameters + upParameters + index; const code: number[] = []; const extraLocals: number[] = []; let localBase = 0; @@ -611,31 +614,34 @@ export const generateWasm = ( }; /** - * Chunk columns stamped by every row: top-level stamps with no `next` before them, in an unfiltered kernel. - * Their chunks are stamped once per call, for the whole range, instead of on each row. + * Chunk and block columns stamped by every row, by the shift from a row to its element: top-level stamps with no + * `next` before them, in an unfiltered kernel. Their chunks and blocks are stamped once per call, for the whole + * range, instead of on each row. */ - const everyRow = new Set(); - // Listed rows needn't be consecutive, so each stamps its own chunk. + const everyRow = new Map(); + // Listed rows needn't be consecutive, so each stamps its own chunk and block. if (filters === 0 && !upMode) { const ends = (statement: Statement): boolean => statement.kind === "next" || (statement.kind === "if" && (statement.consequent.some(ends) || statement.alternate.some(ends))) || ((statement.kind === "block" || statement.kind === "loop") && statement.body.some(ends)); for (const statement of program.body) { if (ends(statement)) break; - if (statement.kind === "stamp" && columns[statement.chunks].muted !== true) everyRow.add(statement.chunks); + if (statement.kind !== "stamp") continue; + if (columns[statement.chunks].muted !== true) everyRow.set(statement.chunks, chunkShift); + if (columns[statement.blocks].muted !== true) everyRow.set(statement.blocks, blockShift); } } - /** Stamps every chunk from `start`'s to `end - 1`'s, for the chunk columns every row stamps. */ + /** Stamps every chunk or block from `start`'s to `end - 1`'s, for the chunk and block columns every row stamps. */ const stampRangeChunks = (): void => { - for (const chunks of everyRow) { + for (const [dense, shift] of everyRow) { const chunk = scratch(i32Type); const last = scratch(i32Type); - // if (start < end) for (chunk = start >> shift; chunk <= (end - 1) >> shift; chunk++) chunks[chunk] = tick + // if (start < end) for (chunk = start >> shift; chunk <= (end - 1) >> shift; chunk++) dense[chunk] = tick code.push(op.localGet, 0, op.localGet, 1, op.i32LtS, op.if, 0x40); - code.push(op.localGet, 0, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(chunk)); - code.push(op.localGet, 1, op.i32Const, 1, op.i32Sub, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(last)); + code.push(op.localGet, 0, op.i32Const, ...signed(shift), op.i32ShrU, op.localSet, ...unsigned(chunk)); + code.push(op.localGet, 1, op.i32Const, 1, op.i32Sub, op.i32Const, ...signed(shift), op.i32ShrU, op.localSet, ...unsigned(last)); code.push(op.loop, 0x40); - code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(chunk), op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + code.push(op.localGet, ...unsigned(columnParameter(dense)), op.localGet, ...unsigned(chunk), op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); code.push(op.localGet, ...unsigned(chunk), op.i32Const, 1, op.i32Add, op.localTee, ...unsigned(chunk), op.localGet, ...unsigned(last), op.i32LeS, op.brIf, 0); code.push(op.end, op.end); } @@ -1235,12 +1241,15 @@ export const generateWasm = ( emitRecordLeaf(row, 1, { view: "f64", stride: 8, offset: 0, boolean: false }); }; - /** Sets the chunk tick of the row `rowOffset` rows past the current one, unless every row's chunk is stamped up front. */ - const stampChunk = (chunks: number, rowOffset: number): void => { - if (everyRow.has(chunks)) return; - code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(rowLocal)); + /** + * Sets the chunk or block tick, by `shift`, of the row `rowOffset` rows past the current one, unless it's muted + * or every row's is stamped up front. + */ + const stampDense = (dense: number, shift: number, rowOffset: number): void => { + if (columns[dense].muted === true || everyRow.has(dense)) return; + code.push(op.localGet, ...unsigned(columnParameter(dense)), op.localGet, ...unsigned(rowLocal)); if (rowOffset !== 0) code.push(op.i32Const, ...signed(rowOffset), op.i32Add); - code.push(op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + code.push(op.i32Const, ...signed(shift), op.i32ShrU, op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); }; const statement = (current: Statement): void => { @@ -1250,8 +1259,9 @@ export const generateWasm = ( // A chunk column stays when its row column is muted, for a component held on the GPU. case "stamp": if (columns[current.column].muted !== true) store(current.column, "tick"); - if (columns[current.chunks].muted === true) return; - return stampChunk(current.chunks, 0); + stampDense(current.chunks, chunkShift, 0); + stampDense(current.blocks, blockShift, 0); + return; case "if": emit(current.test); emitTruthy(typeOf(current.test)); @@ -1469,10 +1479,11 @@ export const generateWasm = ( code.push(...simd(vop.store), 3, ...unsigned(16 * half)); } } - if (columns[current.chunks].muted === true) return; - // The group may straddle two chunks. Under a mask this can stamp a chunk no lane changed, which only costs a scan. - stampChunk(current.chunks, 0); - stampChunk(current.chunks, 3); + // The group may straddle two chunks or blocks. Under a mask this can stamp one no lane changed, which only costs a scan. + stampDense(current.chunks, chunkShift, 0); + stampDense(current.chunks, chunkShift, 3); + stampDense(current.blocks, blockShift, 0); + stampDense(current.blocks, blockShift, 3); return; } case "if": { @@ -1911,7 +1922,7 @@ export const generateWasm = ( }; try { - const parameterCount = 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters; + const parameterCount = beforeLookups + lookupParameters + indexParameters + upParameters; collectFinds(program.body); /** Leaves 1 if any filter's tick at `(row >> shift) * 8` in its `parameter` array is newer than `lastRun`. */ const anyNewer = (parameter: (filter: number) => number, shift: number): void => { @@ -1922,36 +1933,88 @@ export const generateWasm = ( if (filter > 0) code.push(op.i32Or); } }; - /** At a chunk's first row, skips the whole chunk unless a filter's chunk tick is newer. */ - const skipChunks = (): void => { - const next = scratch(i32Type); - code.push(op.localGet, ...unsigned(rowLocal), op.localGet, 0, op.i32Eq, op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(2 ** chunkShift - 1), op.i32And, op.i32Eqz, op.i32Or); - code.push(op.if, 0x40); + /** + * i32 locals: the row after the current chunk; the row after the current run of passing blocks, both `start` + * before the first; the current chunk's blocks that pass, a bit each; and two temporaries. + */ + let chunkEnd = 0; + let blockEnd = 0; + let passingBlocks = 0; + let ahead = 0; + let skipped = 0; + const startChunksAndBlocks = (): void => { + chunkEnd = scratch(i32Type); + blockEnd = scratch(i32Type); + passingBlocks = scratch(i32Type); + ahead = scratch(i32Type); + skipped = scratch(i32Type); + code.push(op.localGet, 0, op.localTee, ...unsigned(chunkEnd), op.localSet, ...unsigned(blockEnd)); + }; + const get = (local: number): number[] => [op.localGet, ...unsigned(local)]; + const set = (local: number): number[] => [op.localSet, ...unsigned(local)]; + /** Leaves `min(end, ((row >> shift) + count) << shift)`, with `count` on the stack first. */ + const runEnd = (shift: number, target: number): void => { + code.push(...get(rowLocal), op.i32Const, ...signed(shift), op.i32ShrU, op.i32Add, op.i32Const, ...signed(shift), op.i32Shl, ...set(target)); + code.push(...get(target), op.localGet, 1, ...get(target), op.localGet, 1, op.i32LtS, op.select, ...set(target)); + }; + const blocksPerChunk = 2 ** (chunkShift - blockShift); + /** + * In the outer row loop, at `row`, the first row after the last run of passing blocks: past the current chunk, + * skips chunks no filter's chunk tick says changed, and notes which blocks of the next one pass, a bit each; + * then skips the blocks that don't pass, and sets `blockEnd` to the end of the run of blocks that do, which the + * inner loop runs. A chunk with more than half its blocks passing is one run: ending a run costs more than the + * eight-row checks spend on a block between runs. + */ + const findRun = (): void => { + code.push(...get(rowLocal), ...get(chunkEnd), op.i32GeS, op.if, 0x40); + code.push(op.i32Const, 1); + runEnd(chunkShift, chunkEnd); anyNewer(filterChunkParameter, chunkShift); - code.push(op.i32Eqz, op.if, 0x40); - // row = min(end, start of the next chunk) - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 1, op.i32Add, op.i32Const, ...signed(chunkShift), op.i32Shl, op.localSet, ...unsigned(next)); - code.push(op.localGet, ...unsigned(next), op.localGet, 1, op.localGet, ...unsigned(next), op.localGet, 1, op.i32LtS, op.select, op.localSet, ...unsigned(rowLocal)); - code.push(op.br, 2, op.end, op.end); + code.push(op.i32Eqz, op.if, 0x40, ...get(chunkEnd), ...set(rowLocal), op.br, 2, op.end); + // The chunk's blocks that pass, from its first block, `(row >> chunkShift) << (chunkShift - blockShift)`. + for (let block = 0; block < blocksPerChunk; block++) { + for (let filter = 0; filter < filters; filter++) { + code.push(...get(filterBlockParameter(filter)), ...get(rowLocal), op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, ...signed(chunkShift - blockShift), op.i32Shl); + code.push(op.i32Const, ...signed(block), op.i32Add, op.i32Const, 3, op.i32Shl, op.i32Add, op.f64Load, 3, 0, op.localGet, 3, op.f64Gt); + if (filter > 0) code.push(op.i32Or); + } + if (block > 0) code.push(op.i32Const, ...signed(block), op.i32Shl, ...get(passingBlocks), op.i32Or); + code.push(...set(passingBlocks)); + } + code.push(...get(passingBlocks), op.i32Popcnt, op.i32Const, ...signed(blocksPerChunk / 2), op.i32GtS, op.if, 0x40); + code.push(op.i32Const, ...signed((2 ** blocksPerChunk - 1) | 0), ...set(passingBlocks), op.end); + code.push(op.end); + // ahead = passingBlocks >> (row's block in its chunk); none ahead skips the rest of the chunk. + code.push(...get(passingBlocks), ...get(rowLocal), op.i32Const, ...signed(blockShift), op.i32ShrU, op.i32Const, ...signed(blocksPerChunk - 1), op.i32And, op.i32ShrU, op.localTee, ...unsigned(ahead)); + code.push(op.i32Eqz, op.if, 0x40, ...get(chunkEnd), ...set(rowLocal), op.br, 1, op.end); + // Skips to the next passing block, then runs to the end of the passing blocks after it. + code.push(...get(ahead), op.i32Ctz, op.localTee, ...unsigned(skipped), op.if, 0x40); + code.push(...get(skipped), ...get(rowLocal), op.i32Const, ...signed(blockShift), op.i32ShrU, op.i32Add, op.i32Const, ...signed(blockShift), op.i32Shl, ...set(rowLocal)); + code.push(op.end); + code.push(...get(ahead), ...get(skipped), op.i32ShrU, op.i32Const, ...signed(-1), op.i32Xor, op.i32Ctz); + runEnd(blockShift, blockEnd); + code.push(...get(blockEnd), ...get(chunkEnd), ...get(blockEnd), ...get(chunkEnd), op.i32LtS, op.select, ...set(blockEnd)); + // A row moved to a later block goes round the outer loop, which checks it against `end`. + code.push(...get(skipped), op.brIf, 0); }; - /** Rows a block skip checks at once: four f64x2 loads of each filter's row ticks. */ - const blockRows = 8; + /** Rows an eight-row skip checks at once: four f64x2 loads of each filter's row ticks. */ + const eightRows = 8; /** - * With SIMD, at a row that's a multiple of `blockRows`, skips that many rows when none passes a filter, testing - * two ticks per comparison. Only aligned rows check, so rows that do pass pay one check per block. + * With SIMD, at a row that's a multiple of `eightRows`, skips that many rows when none passes a filter, testing + * two ticks per comparison. Only aligned rows check, so rows that do pass pay one check per eight. */ - const skipBlocks = (): void => { - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows - 1, op.i32And, op.i32Eqz); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localGet, 1, op.i32LeS, op.i32And, op.if, 0x40); + const skipEights = (): void => { + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows - 1, op.i32And, op.i32Eqz); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows, op.i32Add, op.localGet, 1, op.i32LeS, op.i32And, op.if, 0x40); for (let filter = 0; filter < filters; filter++) { - for (let pair = 0; pair < blockRows / 2; pair++) { + for (let pair = 0; pair < eightRows / 2; pair++) { code.push(op.localGet, ...unsigned(filterRowParameter(filter)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); code.push(...simd(vop.load), 3, ...unsigned(16 * pair), op.localGet, 3, ...simd(vop.f64Splat), ...simd(vop.f64Gt)); if (filter > 0 || pair > 0) code.push(...simd(vop.or)); } } code.push(...simd(vop.anyTrue), op.i32Eqz, op.if, 0x40); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localSet, ...unsigned(rowLocal)); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows, op.i32Add, op.localSet, ...unsigned(rowLocal)); code.push(op.br, 2, op.end, op.end); }; // Scalars are discovered while generating, so code is generated first with locals placed after a provisional scalar count. @@ -1986,6 +2049,7 @@ export const generateWasm = ( if (lookupParameters > 0) convertLookupParameters(); if (indexParameters > 0) convertIndexParameters(); stampRangeChunks(); + if (filters > 0) startChunksAndBlocks(); if (mode === "rows") rowsLoop(); if (mode === "lanes") { const plan = planLanes(); @@ -1999,12 +2063,16 @@ export const generateWasm = ( } code.push(op.block, 0x40, op.loop, 0x40); code.push(op.localGet, ...unsigned(slot), op.localGet, 1, op.i32GeS, op.brIf, 1); + // With change filters, an outer loop finds each run of passing blocks and an inner one runs its rows. + if (filters > 0) { + findRun(); + code.push(op.block, 0x40, op.loop, 0x40, ...get(rowLocal), ...get(blockEnd), op.i32GeS, op.brIf, 1); + } if (upMode) { code.push(op.localGet, ...unsigned(upParameter(0)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(rowLocal)); code.push(op.localGet, ...unsigned(upParameter(1)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(parentLocal)); } - if (filters > 0) skipChunks(); - if (filters > 0 && simdEnabled && !upMode) skipBlocks(); + if (filters > 0 && simdEnabled && !upMode) skipEights(); code.push(op.block, 0x40); // A row that no filter passes goes to the next. if (filters > 0) { @@ -2028,6 +2096,7 @@ export const generateWasm = ( }); code.push(op.localGet, ...unsigned(slot), op.i32Const, 1, op.i32Add, op.localSet, ...unsigned(slot), op.br, 0); code.push(op.end, op.end); + if (filters > 0) code.push(op.br, 0, op.end, op.end); rowLocal = slot; accumulators.forEach((accumulator, k) => { const extra = lanes[k]; @@ -2064,7 +2133,7 @@ export const generateWasm = ( if (scalars.length !== discovered) throw unsupported; const vectorized = mode !== "scalar"; - const parameterTypes = [i32Type, i32Type, f64Type, f64Type, ...columns.map(() => i32Type), ...new Array(2 * filters).fill(i32Type), ...new Array(lookupParameters).fill(f64Type), ...new Array(indexParameters).fill(f64Type), ...new Array(upParameters).fill(i32Type), ...scalars.map(() => f64Type), ...accumulators.length > 0 ? [i32Type] : []]; + const parameterTypes = [i32Type, i32Type, f64Type, f64Type, ...columns.map(() => i32Type), ...new Array(3 * filters).fill(i32Type), ...new Array(lookupParameters).fill(f64Type), ...new Array(indexParameters).fill(f64Type), ...new Array(upParameters).fill(i32Type), ...scalars.map(() => f64Type), ...accumulators.length > 0 ? [i32Type] : []]; const localTypesList = [...new Array(program.locals).fill(f64Type)]; // Locals in order: program locals, then extras in creation order; the row index was the first extra. const declared = [...localTypesList.map(type => [1, type]), ...extraLocals.map(type => [1, type])]; diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index b0ed1928..9f2b276b 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -3,7 +3,7 @@ import { buildKernel, buildViews, phases, readPlan, runBuildStep } from './index import type { Entity } from '../../interface/entity/entity.js'; import { createBlock, endBlock, locate, reserveFrom, tableLength, type Block } from './entities.js'; import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; -import type { Kernel, KernelCommands, KernelModule } from './kernel.js'; +import { blockShift, chunkShift, type Kernel, type KernelCommands, type KernelModule } from './kernel.js'; import { layoutOf, viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; import { emptyViews } from './arrays.js'; import { claimPiece } from './claims.js'; @@ -16,7 +16,7 @@ import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; import { endpointChannel, scopeChannel } from './port.js'; import { archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, - recordCapacity, recordEntities, recordStorages, recordVersion, recordWords, rowField, storageAdded, storageChanged, storageChunkAdded, + recordCapacity, recordEntities, recordStorages, recordVersion, recordWords, rowField, storageAdded, storageBlockAdded, storageBlockChanged, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, tickedFlag, } from './layout-directory.js'; @@ -27,6 +27,8 @@ type StorageViews = { readonly changed: Float64Array; readonly chunkAdded: Float64Array; readonly chunkChanged: Float64Array; + readonly blockAdded: Float64Array; + readonly blockChanged: Float64Array; }; /** A worker's views of one archetype's columns, as of its record's `version`. */ @@ -161,6 +163,8 @@ const viewArchetype = (record: number, version: number): ArchetypeViews => { changed: ticked ? new Float64Array(memoryBuffer, words[base + storageChanged], capacity) : noTicks, chunkAdded: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkAdded], words[base + storageChunks]) : noTicks, chunkChanged: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkChanged], words[base + storageChunks]) : noTicks, + blockAdded: ticked ? new Float64Array(memoryBuffer, words[base + storageBlockAdded], words[base + storageChunks] << (chunkShift - blockShift)) : noTicks, + blockChanged: ticked ? new Float64Array(memoryBuffer, words[base + storageBlockChanged], words[base + storageChunks] << (chunkShift - blockShift)) : noTicks, }); } return { version, entities: new Float64Array(memoryBuffer, words[record + recordEntities], capacity), storages }; @@ -248,15 +252,15 @@ const readerArchetype = (id: number) => { /** The one element a muted tick column's stores land on. */ const mutedColumn = new Float64Array(1); -/** A kernel's change filters in an archetype: `added` then `changed`, rows and chunks. */ +/** A kernel's change filters in an archetype: `added` then `changed`, rows, chunks and blocks. */ const filtersOf = (loaded: LoadedKernel, archetype: ArchetypeViews): Filter[] => [ ...loaded.setup.added.flatMap(id => { const storage = archetype.storages.get(id); - return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }]; }), ...loaded.setup.changed.flatMap(id => { const storage = archetype.storages.get(id); - return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }]; }), ]; @@ -268,12 +272,13 @@ const bind = (loaded: LoadedKernel, archetypeId: number, archetype: ArchetypeVie if (storage === undefined) throw new Error(`Archetype ${archetypeId} lacks component ${source.id}.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; + if (source.kind === "blocks") return storage.blockChanged; const layout = layouts[source.id]; if (layout.kind !== "linear") throw new Error(`Component ${source.id} isn't linear.`); return storage.views[layout.leaves[source.leaf].view]; }); const filters = filtersOf(loaded, archetype); - const addresses = [...columns.map(column => column.byteOffset), ...filters.map(filter => filter.ticks.byteOffset), ...filters.map(filter => filter.chunks.byteOffset)]; + const addresses = [...columns.map(column => column.byteOffset), ...filters.map(filter => filter.ticks.byteOffset), ...filters.map(filter => filter.chunks.byteOffset), ...filters.map(filter => filter.blocks.byteOffset)]; return { views: archetype, columns, filters, addresses }; }; diff --git a/packages/ecs/src/samples/todo/README.md b/packages/ecs/src/samples/todo/README.md index 58f5520d..449c27dc 100644 --- a/packages/ecs/src/samples/todo/README.md +++ b/packages/ecs/src/samples/todo/README.md @@ -144,16 +144,19 @@ What the current API does and doesn't offer the DOM path, each with the change i 0.02–0.10 ms, which is the CPU coming back from idle, not the ECS. Toggling one todo of 10K took 62 µs, of which the profile gave 64% to keeping `completion/byDone`'s two buckets of 5K entries in entity order, which the next finding removes, and 16% to `forEachPassingRange` (`ranges.ts`), the change filters scanning the 1024 row ticks of - the one chunk that changed, for each observer system with `changed`. The rest is the sync point, the observer pass - and the DOM writes, about 13 µs. + the one chunk that changed, for each observer system with `changed`, which the finding after it removes. The rest is + the sync point, the observer pass and the DOM writes, about 13 µs. - **A low-cardinality hash index costs O(1) amortized per change:** toggling one todo moves it between `byDone`'s buckets, which are left unsorted, its entry leaving a hole in one and joining a tail of the other, until a list is read by position (`index-hash.ts`). In node, a toggle and a run reading both buckets' lengths, as the summary does, take 5.5, 5.3 and 5.5 µs at 1K, 10K and 100K todos (`bucket move … length`), against 9.1, 64 and 423 µs with every bucket kept in entity order; reading each bucket's first rows after it, as `clearCompleted` reads the done ones, sorts both buckets first: 6.2, 12.7 and 69 µs (`bucket move … in order`), against 9.4, 65 and 418. -- **A change filter scans a whole chunk for one changed row,** 1024 ticks for each system with `changed`. *Proposal:* - a second level of ticks per 32 or 64 rows, or a list of the rows changed per chunk. +- **A change filter finds one changed row by its block:** it reads the chunk's sixteen block ticks and the 64 row ticks + of the block that changed, not the chunk's 1024. In a profile of 40,000 toggles of one todo of 10K, the change + filters take 5.0% of samples, against 6.9% scanning the chunk, most of it the chunk checks of observer systems + filtering on two or three components; in node, eight systems filtering on one changed row of 100K take 5.0 µs a + run, against 34.9 µs scanning the chunk (`changed one 100K`). *No change needed.* - **Nothing awaits the next tick.** Tests watch `clock` changes through an observer, after a first run that passes it regardless, and a world ticks only while a system that isn't an observer is added. *Proposal:* none for consumers; a test helper in `src/observe` would do. diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 549eece3..7048a099 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -181,8 +181,8 @@ All derived from declarations and, for terminal systems, the plugin's analysis o - **Archetype records:** the table holds, by the world's archetype number, the offset of that archetype's record. Each record is allocated once, since an archetype's storages are fixed, and never moves; only the table grows. A record holds `capacity`, the `entities` offset, a version, its flags and its count, and per storage its component number, its - flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets and its - chunk count; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource + flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets, its + chunk count and its block-tick offsets, sixteen blocks a chunk; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource archetype has a record too, flagged, and the header holds the resource entity's index. - **Entity-table record:** the `cursor` offset, which never moves, the free list's offset and capacity, the `archetype`, `row` and `generation` offsets and capacity, and a version. The table's length is `cursor[2]`. Its From 13eb6d6f5024e7ed922ba03a5a6a92bc0e264dce Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:26:02 -0700 Subject: [PATCH 022/127] ecs: tests for every block stamp, block before chunk in every writer, and no filters in an up kernel's WASM Each writer stores a row's tick, then its block's, then its chunk's, the reverse of the order filters read them. New tests fail with each remaining block stamp removed: WASM four-row stamps, including a group straddling two blocks from an unaligned start; `added` blocks raised by a swap and stamped by a command's add; a JS `up` kernel's listed stamps. A WASM `up` kernel given change filters now throws rather than stepping the wrong row. `stampAdded`, unused, is gone, and perf.md states block memory for small archetypes. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../implementation/compiler/compile-kernel.ts | 6 +-- .../ecs/src/implementation/compiler/print.ts | 2 +- .../ecs/src/implementation/world/archetype.ts | 27 ++++++------ .../ecs/src/implementation/world/batches.ts | 4 +- .../world/change-blocks.test.ts | 42 +++++++++++++++++++ .../implementation/world/generated-code.md | 2 +- .../implementation/world/layout-directory.ts | 5 ++- packages/ecs/src/implementation/world/perf.md | 2 +- .../src/implementation/world/synchronize.ts | 14 +++---- .../ecs/src/implementation/world/wasm.test.ts | 34 +++++++++++++++ packages/ecs/src/implementation/world/wasm.ts | 10 +++-- packages/ecs/threads.md | 5 ++- 13 files changed, 116 insertions(+), 39 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 2527d622..3f5cd18d 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -266,7 +266,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **Commands:** typed parallel arrays with explicit counts, reused per system across runs; ids resolved when issued, and each list command records its shape. Kernels issue commands through `begin`/`value`/`component` by key index, allocating nothing; the compiler gives every component of a command's list a value, `undefined` for one it leaves out, which the runtime writes as the component's start. Workers write theirs into a command ring in shared memory, structs as bytes in their storages' layout and other values as tags, which the world drains at the sync point into buffers whose struct values stay bytes in the ring, copied by word as they apply (`threads.md`). - **Kernel spawns:** staged, not commands. The compiler lists each kernel's spawn lists, and each spawn calls its list's spawner with one value per component, generated per list as main-thread spawners are. The main thread stages into batches per runner, and each chunk's buffer names the rows staged while it was current; a worker stages per task and copies its batches into its command ring after the chunk's commands. The sync point commits staged rows in merge order, right after main-thread batches and before any command, so row order doesn't depend on which thread ran a chunk. Kernel spawners reserve entities in blocks, from 8 positions doubling to 4096, so workers don't contend for the cursor; each run or task ends its block, and the sync point returns the positions it left unused to the free list with their generations. - **Sync point:** plans straight over the queued buffers into reusable typed arrays, through cached archetype edges (arrays by component id), per-pair move lists, and for each list add or remove a table from source archetype to target; archetypes grow once per sync point; common layouts are written inline rather than through layout closures. Applying groups entries by source and target archetype: each pair copies the columns its archetypes share column by column for its block of rows and writes derived defaults once per column, then each entry's values are written. Rows left are removed last entry first, so an archetype moving out whole drops its rows without copying them; a row moved into a hole that another entry still leaves takes that entry's place. -- **Chunk and block ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk and per 64-row block, both stamped with every row tick and raised by a row copied in, in shared memory beside the row ticks, so change filters skip unchanged chunks, then the unchanged blocks of a chunk that changed: finding a chunk's passing rows costs its sixteen block ticks and the rows of the blocks that changed, not its 1024 row ticks. Stamps every row reaches are made once per kernel call, per chunk and block. +- **Chunk and block ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk and per 64-row block, both stamped with every row tick and raised by a row copied in, in shared memory beside the row ticks, so change filters skip unchanged chunks, then the unchanged blocks of a chunk that changed: finding a chunk's passing rows costs its sixteen block ticks and the rows of the blocks that changed, not its 1024 row ticks. Writers store a row's tick, then its block's, then its chunk's, the reverse of the order filters read them. Stamps every row reaches are made once per kernel call, per block and chunk. - **Ticks only where observed:** a component has tick columns only once some registered system filters on its `added` or `changed`, and resources always do; until then nothing allocates or stamps them, and once started they stay, bounded by the components. Columns start at the current tick, so a new system, starting with `lastRun = 0`, sees every row once. Kernels also drop `changed` stamps for components nothing watches: a count per component of the registered systems filtering on its `changed` and of the finalizers' indexes following it, an index's for the world's life. A count turning positive reloads the kernels that dropped its stamps; one falling to zero reloads nothing, and kernels drop its stamps when they next load for another reason, so a filter coming and going, as a system run once does, reloads nothing. - **What a system holds lives as long as its value:** a world keeps its schedules and runners by system value, weakly, so a system nothing else references, such as one made for a single run, is collected with its runner, command buffers and `lastRun`, while a module's systems keep theirs between runs. What a runner holds beyond its JS values (reduction partials and `up` orders in the arena, lookup directories, GPU records, its hook after each sync point, its count of watched components) is released once its system is collected, through a finalization registry whose records hold nothing of the system. Runs read a schedule's runners again only after one was replaced or removed, as placements, device changes and `remove` do; a removed one reads as a runner that runs nothing, and a registered composite holding a system forgotten since registers again on its next run, keeping its other members' runners. Ids of keys, added resources and tick columns stay, bounded by the components; the registry forgets a collected item without an id, its label with it. `lifetime.test.ts` runs 10,000 new systems, each once, and finds every table and the heap as they were. - **Kernels where WASM can't compile** (a content security policy without `'wasm-unsafe-eval'`) run their compiled JS. diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 0c9fbba9..605ff984 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -125,13 +125,13 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: ...locals, preludeCode, ...accumulators.map((local, k) => `let ${localName(unit, local)} = partials[partial + ${k}];`), - // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own chunk. + // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own block and chunk. ...listed ? [] : [...unit.stampedPerCall].flatMap(([chunks, blocks]) => [ - `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, `if (start < end) for (let k = start >> ${blockShift}, last = (end - 1) >> ${blockShift}; k <= last; k++) c${blocks}[k] = tick;`, + `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, ]), listed - ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, blocks]) => `c${chunks}[i >> ${chunkShift}] = tick; c${blocks}[i >> ${blockShift}] = tick;`)].join(" ")}` + ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, blocks]) => `c${blocks}[i >> ${blockShift}] = tick; c${chunks}[i >> ${chunkShift}] = tick;`)].join(" ")}` : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, kernel, "}", diff --git a/packages/ecs/src/implementation/compiler/print.ts b/packages/ecs/src/implementation/compiler/print.ts index 9e5e48df..90f6514e 100644 --- a/packages/ecs/src/implementation/compiler/print.ts +++ b/packages/ecs/src/implementation/compiler/print.ts @@ -69,7 +69,7 @@ const printStatement = (unit: Unit, statement: Statement, labels: Labels): strin case "store": return `c${statement.column}[x${statement.column}] = ${value(statement.value)};`; case "stamp": { const row = `c${statement.column}[x${statement.column}] = tick;`; - return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.chunks}[i >> ${chunkShift}] = tick;\nc${statement.blocks}[i >> ${blockShift}] = tick;`; + return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.blocks}[i >> ${blockShift}] = tick;\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; } case "if": { const alternate = printStatements(unit, statement.alternate, labels); diff --git a/packages/ecs/src/implementation/world/archetype.ts b/packages/ecs/src/implementation/world/archetype.ts index a24e437b..5f954509 100644 --- a/packages/ecs/src/implementation/world/archetype.ts +++ b/packages/ecs/src/implementation/world/archetype.ts @@ -85,29 +85,26 @@ const blocksFor = (capacity: number): number => chunksFor(capacity) << (chunkShi /** What stamping a row's ticks writes: a storage's, or a joined thread's view of one. */ export type Stamped = Pick; -/** Stamps a row's `changed` tick, its chunk's and its block's. Ticks only increase, so theirs are the newest. */ +/** + * Stamps a row's `changed` tick, its block's and its chunk's. Ticks only increase, so theirs are the newest. Every + * writer stores the row, then the block, then the chunk, the reverse of the order filters read them, so a filter on + * another thread that finds a chunk newer finds its block and row newer too. + */ export const stampChanged = (storage: Stamped, row: number, tick: number): void => { if (!storage.ticked) return; storage.changed[row] = tick; - storage.chunkChanged[row >> chunkShift] = tick; storage.blockChanged[row >> blockShift] = tick; -}; - -export const stampAdded = (storage: Stamped, row: number, tick: number): void => { - if (!storage.ticked) return; - storage.added[row] = tick; - storage.chunkAdded[row >> chunkShift] = tick; - storage.blockAdded[row >> blockShift] = tick; + storage.chunkChanged[row >> chunkShift] = tick; }; /** - * Raises the chunk and block ticks over rows `start` to `end - 1` to at least `tick`: for ticks written to those rows + * Raises the block and chunk ticks over rows `start` to `end - 1` to at least `tick`: for ticks written to those rows * in bulk, or copied in from rows of other chunks. */ export const raiseRange = (chunks: Float64Array, blocks: Float64Array, start: number, end: number, tick: number): void => { if (start >= end) return; - for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) if (chunks[chunk] < tick) chunks[chunk] = tick; for (let block = start >> blockShift, last = (end - 1) >> blockShift; block <= last; block++) if (blocks[block] < tick) blocks[block] = tick; + for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) if (chunks[chunk] < tick) chunks[chunk] = tick; }; /** A table of the entities that have exactly `components`. Rows are dense: `0` to `count`. */ @@ -358,13 +355,13 @@ export const copyCell = (from: Storage, fromRow: number, to: Storage, toRow: num const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's and a block's tick are the newest of their rows, so a row moving in can raise them. - const chunk = toRow >> chunkShift; - if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; - if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; + // A block's and a chunk's tick are the newest of their rows, so a row moving in can raise them. const block = toRow >> blockShift; if (added > to.blockAdded[block]) to.blockAdded[block] = added; if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; + const chunk = toRow >> chunkShift; + if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; + if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; }; /** Removes a row by moving the last row into it. Returns the moved entity, or -1 if the removed row was last. */ diff --git a/packages/ecs/src/implementation/world/batches.ts b/packages/ecs/src/implementation/world/batches.ts index 74cca1c8..07166eb5 100644 --- a/packages/ecs/src/implementation/world/batches.ts +++ b/packages/ecs/src/implementation/world/batches.ts @@ -83,10 +83,10 @@ const commitRows = (state: State, batch: SpawnBatch, first: number, n: number, t storage.added.fill(tick, start, start + n); if (storage.changed !== storage.added) storage.changed.fill(tick, start, start + n); // Ticks only increase, so the newest tick in each chunk and block the rows reach is this one. - storage.chunkAdded.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); - if (storage.chunkChanged !== storage.chunkAdded) storage.chunkChanged.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); storage.blockAdded.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); if (storage.blockChanged !== storage.blockAdded) storage.blockChanged.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); + storage.chunkAdded.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); + if (storage.chunkChanged !== storage.chunkAdded) storage.chunkChanged.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); } const { entities } = state; for (let i = 0; i < n; i++) { diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index f9a228d3..add37fb4 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -23,6 +23,8 @@ const group = Symbol("changeBlocksTest/group"); const tagged = Symbol("changeBlocksTest/tagged"); const note = Symbol("changeBlocksTest/note"); const parent = Symbol("changeBlocksTest/parent"); +const extra = Symbol("changeBlocksTest/extra"); +const height = Symbol("changeBlocksTest/height"); declare module "@adobe/ecs" { interface Threads { @@ -36,6 +38,8 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; [note]: { type: { readonly text: string } | null; thread: "changeBlocksTest" }; [parent]: { type: Entity; reference: "owner" }; + [extra]: { type: number }; + [height]: { type: number }; } } Component.register(value, { type: { type: "number" } }); @@ -46,6 +50,8 @@ Component.register(tagged, { type: { const: true } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; Component.register(note, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "changeBlocksTest" }); Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(extra, { type: { type: "number" } }); +Component.register(height, { type: { type: "number" } }); /** Rows in the archetype: five chunks, the last partial. */ const rows = 4500; @@ -235,4 +241,40 @@ describe("chunk and block ticks", () => { await world.run(inGroup); expect(grouped).toEqual([entities[spread[1]]]); }); + + it("let an added filter find a component a command added, and that row once swapped into an old block", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seedExtra); + await world.run(addedExtra); + // Rows `rows` and `rows + 1` gain the component and land after the archetype's rows, in its last block. + await world.run(addExtra, { entity: entities[rows] }); + expect(await ranOn(world, addedExtra)).toEqual([rows]); + // Its last row, just added, moves into the first block's despawned row. + await world.run(addExtra, { entity: entities[rows + 1] }); + await world.run(despawn, { entities: [entities[3]] }); + expect(await ranOn(world, addedExtra)).toEqual([rows + 1]); + }); + + it("let filters find what a JS kernel reading up wrote on every row", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm: false }); + await world.run(seedExtra); + await world.run(heights); + await world.run(raiseHeights); + expect((await ranOn(world, heights)).length).toBe(rows + 2); + }); +}); + +// Rows that have `extra` from the start, then two that gain it; and a JS `up` kernel writing every row's height. +const seedExtra = System.create({ name: "changeBlocksTest/seedExtra" }, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn([at, extra, height])({ [at]: i, [extra]: 0, [height]: 0 })); + for (let i = rows; i < rows + 2; i++) entities.push(commands.spawn([at, height])({ [at]: i, [height]: 0 })); +}); +const addExtra = System.create({ name: "changeBlocksTest/addExtra" }, ({ commands }, input: { readonly entity: Entity }) => { + commands.add(input.entity, extra, 1); +}); +const addedExtra = System.create({ name: "changeBlocksTest/addedExtra", components: { read: [at], added: [extra] } }, row => { found.push(row[at]); }); +const raiseHeights = System.create({ name: "changeBlocksTest/raiseHeights", kernel: "cpu", components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { + row[height] = up === null ? 1 : up[height] + 1; }); +const heights = System.create({ name: "changeBlocksTest/heights", components: { read: [at], changed: [height] } }, row => { found.push(row[at]); }); diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md index 2f87df50..e56be80a 100644 --- a/packages/ecs/src/implementation/world/generated-code.md +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -114,7 +114,7 @@ Calls nest as builtins do: `Vec3.scale(Vec3.normalize(Vec3.cross(p, v)), Vec3.do ### WASM (557 bytes, excerpts from `wasm2wat`) -Four loops: chunk stamps, block stamps, four rows at a time, then one row at a time for the rest. +Four loops: block stamps, chunk stamps, four rows at a time, then one row at a time for the rest. ```wat ;; Four rows: 48 bytes per component, three v128 loads each. diff --git a/packages/ecs/src/implementation/world/layout-directory.ts b/packages/ecs/src/implementation/world/layout-directory.ts index 910f9552..b5535023 100644 --- a/packages/ecs/src/implementation/world/layout-directory.ts +++ b/packages/ecs/src/implementation/world/layout-directory.ts @@ -39,8 +39,9 @@ export const structureWaitersWord = 13; /** * An archetype record: its capacity, `entities` offset, storage count, version, flags and row count, then per storage * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, chunk count, and - * `blockAdded` and `blockChanged` offsets, of `chunks << (chunkShift - blockShift)` blocks. Offsets a storage doesn't have are 0. A record is allocated once, since an archetype's storages are fixed, and never - * moves. Its count is written at flushes while a joined thread may read it, for archetypes whose count changed. + * `blockAdded` and `blockChanged` offsets, of `chunks << (chunkShift - blockShift)` blocks. Offsets a storage doesn't + * have are 0. A record is allocated once, since an archetype's storages are fixed, and never moves. Its count is + * written at flushes while a joined thread may read it, for archetypes whose count changed. */ export const recordCapacity = 0; export const recordEntities = 1; diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 22ef6abb..71e35280 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -177,7 +177,7 @@ Block ticks, alternating against `998738a3`, node, medians of five rounds for th | `sparse changed`, 1M, 1% scattered | 354 µs | 317 µs | | `sparse changed clustered` | 29.3 µs | 28.0 µs | -In chromium: `sparse changed main` 295 → 260 µs, `sparse changed workers` 70 → 60 µs, `changed few 100K` 115 → 5 µs, `changed one 100K two filters` 40 → 5 µs (timers step by 5 µs), `changed all 100K kernels` 485 → 395 µs. Readers of key and parent ticks: `up one root changed` 27.8 → 26.4 µs, `up filtered after churn` 54.5 → 51.4 µs, `rekey 1M` 13.6 → 12.4 ms, `read run changed` 14.5 → 13.3 µs. Writes within noise in node (`create`, `create flat`, `move`, `churn`, `retag`, `emit`, `emit main`, `reduce`, `chain`, `follow`, `spawn bulk`, `spawn bulk kernel`, `add tag`, `add value`, `add replace`, `despawn bulk`, `index refresh 1M 1%`, `sync point one spawn`, `move 1K`) and chromium (`move`, `chain`, `emit` and `spawn bulk kernel`, main and workers), and so are `run idle`, `run observer`, `index idle`, `observer idle 100`, `joined observer 10K` and `observe gpu entity factory`. Memory: a ticked storage's two block arrays, one for a constant, take 8 bytes per 64 rows each, 1.6% of its row ticks, and each storage's layout record two more words. +In chromium: `sparse changed main` 295 → 260 µs, `sparse changed workers` 70 → 60 µs, `changed few 100K` 115 → 5 µs, `changed one 100K two filters` 40 → 5 µs (timers step by 5 µs), `changed all 100K kernels` 485 → 395 µs. Readers of key and parent ticks: `up one root changed` 27.8 → 26.4 µs, `up filtered after churn` 54.5 → 51.4 µs, `rekey 1M` 13.6 → 12.4 ms, `read run changed` 14.5 → 13.3 µs. Writes within noise in node (`create`, `create flat`, `move`, `churn`, `retag`, `emit`, `emit main`, `reduce`, `chain`, `follow`, `spawn bulk`, `spawn bulk kernel`, `add tag`, `add value`, `add replace`, `despawn bulk`, `index refresh 1M 1%`, `sync point one spawn`, `move 1K`) and chromium (`move`, `chain`, `emit` and `spawn bulk kernel`, main and workers), and so are `run idle`, `run observer`, `index idle`, `observer idle 100`, `joined observer 10K` and `observe gpu entity factory`. Memory: a ticked storage has two block arrays, one for a constant, each 128 bytes per chunk of its capacity, so at least 128 bytes: 1.6% of its row ticks once its capacity is 1024 rows or more, and as much as them at the initial capacity of 16 rows. Each storage's layout record takes two more words. - **Every row tick's writer raises its block's tick,** one store beside the chunk's: row writes on any thread, kernels (once per call for a block every row of the call reaches, in JS and WASM), the sync point's writes, spawns and moves, swaps into removed rows, a GPU kernel's column stamp lifted onto a joined thread's rows, and joined threads' own ticks. The arrays live in the arena beside the chunk ticks, and the layout directory gives workers and joined threads their offsets. - **Main-thread, worker JS and joined-thread filters skip chunks, then blocks, then rows, each in a loop over one array,** and a passing range runs on by row ticks alone until a row doesn't pass; the scan was about 5 ns a row, with a branch per row on the filter count, and is now under 1 ns: `changed all 100K` 8.49 → 4.69 ms. diff --git a/packages/ecs/src/implementation/world/synchronize.ts b/packages/ecs/src/implementation/world/synchronize.ts index 7c9d33c5..fe40b98f 100644 --- a/packages/ecs/src/implementation/world/synchronize.ts +++ b/packages/ecs/src/implementation/world/synchronize.ts @@ -281,13 +281,13 @@ const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Arche // A constant's `changed` ticks are its `added` ticks, and its value can't change: only a new start stamps it. if (storage.changed !== storage.added || starts) { storage.changed[toRow] = tick; - storage.chunkChanged[toRow >> chunkShift] = tick; storage.blockChanged[toRow >> blockShift] = tick; + storage.chunkChanged[toRow >> chunkShift] = tick; } if (starts) { storage.added[toRow] = tick; - storage.chunkAdded[toRow >> chunkShift] = tick; storage.blockAdded[toRow >> blockShift] = tick; + storage.chunkAdded[toRow >> chunkShift] = tick; } } }; @@ -299,8 +299,8 @@ export const writeDefault = (state: State, storage: Storage, row: number, tick: else storage.tags[row] = startTag; if (!storage.ticked) return; storage.added[row] = storage.changed[row] = tick; - storage.chunkAdded[row >> chunkShift] = storage.chunkChanged[row >> chunkShift] = tick; storage.blockAdded[row >> blockShift] = storage.blockChanged[row >> blockShift] = tick; + storage.chunkAdded[row >> chunkShift] = storage.chunkChanged[row >> chunkShift] = tick; }; /** Writes the values an entry's commands give into its row, in issue order. */ @@ -419,13 +419,13 @@ const copyColumn = (from: Storage, to: Storage, plan: Plan, order: Int32Array, f const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's and a block's tick are the newest of their rows, so a row moving in can raise them. - const chunk = toRow >> chunkShift; - if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; - if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; + // A block's and a chunk's tick are the newest of their rows, so a row moving in can raise them. const block = toRow >> blockShift; if (added > to.blockAdded[block]) to.blockAdded[block] = added; if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; + const chunk = toRow >> chunkShift; + if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; + if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; } }; diff --git a/packages/ecs/src/implementation/world/wasm.test.ts b/packages/ecs/src/implementation/world/wasm.test.ts index 6600e64c..dcf80767 100644 --- a/packages/ecs/src/implementation/world/wasm.test.ts +++ b/packages/ecs/src/implementation/world/wasm.test.ts @@ -250,6 +250,31 @@ describe("generateWasm with SIMD", () => { expect(blocks[0]).toBe(7); }); + /** Runs `out = in` with its stamps where `in > 0`, rows `start` to `end` of 2048, four at a time; the blocks then stamped. */ + const stampedFourAtATime = (start: number, end: number, positive: readonly number[]): number[] => { + const test: Expression = { kind: "binary", operator: ">", left: read(0), right: { kind: "number", value: 0 } }; + const program: Program = { locals: 0, body: [{ kind: "if", test, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3, blocks: 4 }], alternate: [] }] }; + const dense = (component: number): WasmColumn => ({ view: "f64", stride: 8, offset: 0, boolean: false, component }); + const kernel = generateWasm(program, [column(0, 4, 0), column(1, 4, 0), dense(-1), dense(-2), dense(-3)], () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + expect(kernel.vectorized).toBe(true); + const memory = new WebAssembly.Memory({ initial: 2, maximum: 65536, shared: true }); + const input = new Float32Array(memory.buffer, 0, 2048).fill(-1); + for (const row of positive) input[row] = 1; + const blocks = new Float64Array(memory.buffer, 65536 + 1024, 32); + const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(start, end, 7, -1, 0, 8192, 16384, 65536, 65536 + 1024); + return [...blocks].flatMap((tick, block) => tick === 7 ? [block] : []); + }; + + it("stamps the blocks of rows a conditional write reaches four rows at a time", () => { + // 300 rows are 75 whole groups, so no row runs one at a time. + expect(stampedFourAtATime(0, 300, [200])).toContain(200 >> 6); + // From row 1 the last group, rows 61 to 64, straddles blocks 0 and 1, and only its last row writes. + expect(stampedFourAtATime(1, 65, [64])).toEqual([0, 1]); + }); + it("stays scalar for operations without an exact f32x4 equivalent", () => { const remainder: Expression = { kind: "binary", operator: "%", left: read(0), right: { kind: "number", value: 2 } }; const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: remainder }] }, [column(0, 4, 0), column(1, 4, 0)], () => "number"); @@ -300,6 +325,15 @@ describe("generateWasm with change filters", () => { }); }); +describe("generateWasm with up", () => { + it("refuses change filters, which its runner applies by listing only passing rows", () => { + const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }]; + const program: Program = { locals: 0, up: true, body: [{ kind: "store", column: 0, value: { kind: "number", value: 1 } }] }; + expect(() => generateWasm(program, columns, () => "number", true, 1)).toThrow("takes no change filters"); + expect(generateWasm(program, columns, () => "number", true, 0)).not.toBeNull(); + }); +}); + describe("generateWasm with several change filters", () => { it("runs rows either filter passes, a chunk with most blocks passing as one run", () => { for (const simd of [false, true]) { diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts index 1079eb39..167ce43c 100644 --- a/packages/ecs/src/implementation/world/wasm.ts +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -367,6 +367,8 @@ export const generateWasm = ( const lookupParameter = (index: number): number => beforeLookups + index; /** For `up`: the rows' and their parents' rows' addresses, whether there's a parent, then each parent column's address. */ const upMode = program.up === true; + // Its runner passes an `up` kernel only the rows that pass, and the filter loops step the row, not its list slot. + if (upMode && filters > 0) throw new Error("A WASM kernel reading `up` takes no change filters: its runner lists only the rows that pass."); const upParameters = upMode ? 3 + upColumns.length : 0; /** Each lookup through an index's parameters, after the lookup parameters: `indexParameterCount` of them, as f64. */ const indexOffsets: number[] = []; @@ -627,8 +629,8 @@ export const generateWasm = ( for (const statement of program.body) { if (ends(statement)) break; if (statement.kind !== "stamp") continue; - if (columns[statement.chunks].muted !== true) everyRow.set(statement.chunks, chunkShift); if (columns[statement.blocks].muted !== true) everyRow.set(statement.blocks, blockShift); + if (columns[statement.chunks].muted !== true) everyRow.set(statement.chunks, chunkShift); } } /** Stamps every chunk or block from `start`'s to `end - 1`'s, for the chunk and block columns every row stamps. */ @@ -1259,8 +1261,8 @@ export const generateWasm = ( // A chunk column stays when its row column is muted, for a component held on the GPU. case "stamp": if (columns[current.column].muted !== true) store(current.column, "tick"); - stampDense(current.chunks, chunkShift, 0); stampDense(current.blocks, blockShift, 0); + stampDense(current.chunks, chunkShift, 0); return; case "if": emit(current.test); @@ -1480,10 +1482,10 @@ export const generateWasm = ( } } // The group may straddle two chunks or blocks. Under a mask this can stamp one no lane changed, which only costs a scan. - stampDense(current.chunks, chunkShift, 0); - stampDense(current.chunks, chunkShift, 3); stampDense(current.blocks, blockShift, 0); stampDense(current.blocks, blockShift, 3); + stampDense(current.chunks, chunkShift, 0); + stampDense(current.chunks, chunkShift, 3); return; } case "if": { diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 7048a099..db95f388 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -182,8 +182,9 @@ All derived from declarations and, for terminal systems, the plugin's analysis o record is allocated once, since an archetype's storages are fixed, and never moves; only the table grows. A record holds `capacity`, the `entities` offset, a version, its flags and its count, and per storage its component number, its flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets, its - chunk count and its block-tick offsets, sixteen blocks a chunk; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource - archetype has a record too, flagged, and the header holds the resource entity's index. + chunk count and its block-tick offsets, sixteen blocks a chunk; a remote storage's column is its tags. Offsets are + `Uint32`, since memory reaches 4 GiB. The resource archetype has a record too, flagged, and the header holds the + resource entity's index. - **Entity-table record:** the `cursor` offset, which never moves, the free list's offset and capacity, the `archetype`, `row` and `generation` offsets and capacity, and a version. The table's length is `cursor[2]`. Its arrays move whenever they grow, even without memory growing, since a grown array's old block is reused. From 7a9e72f046e4fffbf475795f446e2a8d006ab038 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:40:05 -0700 Subject: [PATCH 023/127] ecs: retry a joined thread's task when a sync point applied during its rebuild A thread whose log lapped rebuilds its mirror under the structure epoch. A rebuild that found the epoch moved, odd when it began or changed by the end, gave up, and the task retried only if the epoch was still odd when it checked. A sync point that finished in between left the task never run, with nothing to wake the thread: the world waited on the task forever (a joined tail's first task, under load). `rebuild` now says "moved" for that, and the task retries once the epoch is even. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 5 -- packages/ecs/src/implementation/world/join.ts | 2 +- .../ecs/src/implementation/world/mirror.ts | 21 ++++--- .../src/implementation/world/move-log.test.ts | 56 ++++++++++++++++++- .../implementation/world/thread-systems.ts | 15 ++++- 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 2b17eaf5..b337ebab 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -109,11 +109,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Performance -- **A joined thread's first tail task once never finished,** under heavy machine load (two test suites at once): the - world waited on `channel.idle()` with task 1 posted and not done (`threads.browser.test.ts`, tails "see one tick's - values"). It didn't recur in 25 runs without the extra load, and the base failed once on another threads test in the - same comparison. Worth a stress run of the joined-thread suites under load. - - **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next record. - **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 84838892..28bbd8fa 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -410,7 +410,7 @@ export const joinWorld = (port: Parameters[0], options: { readon current: () => { if (replayed === null) return true; const state = replay(replayed); - return state !== "held" && (state !== "lapped" || rebuild(replayed)); + return state !== "held" && (state !== "lapped" || rebuild(replayed) === "rebuilt"); }, }); if (message.tasks !== null && mirror !== null) { diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts index ceceaf8a..0d73e87d 100644 --- a/packages/ecs/src/implementation/world/mirror.ts +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -39,6 +39,12 @@ type Staged = { readonly slot: number; readonly entity: number; readonly compone /** Whether replaying is done, waits for a component's key to be named, or found the log lapped or replaced. */ export type Replayed = "current" | "held" | "lapped"; +/** + * Whether rebuilding is done; found a sync point applying before or during it, so it can rebuild once the structure + * epoch is even; or waits for a component's key to be named or a finalizer to be set up. + */ +export type Rebuilt = "rebuilt" | "moved" | "held"; + /** A joined thread's mirror of the world's rows holding its objects. */ export type Mirror = { readonly memory: WebAssembly.Memory; @@ -480,14 +486,13 @@ const previousValue = (old: readonly (Mirrored | null | undefined)[], entity: nu /** * Rebuilds every column from the layout directory, as of the last published sync, under the structure epoch: rows * from each archetype's `entities` and count, values by the tag each row's storage holds, a staged slot's taken, any - * other the thread's own kept by entity, and ticks from the world's. Returns false while a sync point applies, or a - * component it holds has no key named yet, having changed nothing. + * other the thread's own kept by entity, and ticks from the world's. Changes nothing unless it's `"rebuilt"`. */ -export const rebuild = (mirror: Mirror): boolean => { +export const rebuild = (mirror: Mirror): Rebuilt => { refresh(mirror); const { words, signed, header, buffer } = mirror; const epoch = Atomics.load(signed, structureWord); - if ((epoch & 1) === 1) return false; + if ((epoch & 1) === 1) return "moved"; const version = Atomics.load(header, logVersionWord); const sync = Atomics.load(header, logSyncWord) >>> 0; const published = Atomics.load(header, logPublishedWord) >>> 0; @@ -502,7 +507,7 @@ export const rebuild = (mirror: Mirror): boolean => { indexed = true; } return previousValue(old, entity, component, start); - })) return false; + })) return "held"; const archetypes: (Mirrored | null)[] = []; /** Staged slots the rebuild takes, settled only once it's complete. */ const taken: number[] = []; @@ -514,7 +519,7 @@ export const rebuild = (mirror: Mirror): boolean => { continue; } const columns = columnsOf(mirror, record); - if (columns === undefined) return false; + if (columns === undefined) return "held"; if (columns === null) { archetypes.push(null); continue; @@ -553,7 +558,7 @@ export const rebuild = (mirror: Mirror): boolean => { archetypes.push(archetype); } // An exchange, so every read above completes before it. - if (Atomics.add(signed, structureWord, 0) !== epoch) return false; + if (Atomics.add(signed, structureWord, 0) !== epoch) return "moved"; for (const slot of taken) { const staged = stagedOf(mirror, slot); if (staged !== undefined) staged.live = false; @@ -566,7 +571,7 @@ export const rebuild = (mirror: Mirror): boolean => { viewRing(mirror); settle(mirror); mirror.rebuilds++; - return true; + return "rebuilt"; }; /** A storage's first word in a record, by its component. */ diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index c3131eb7..acd9c1de 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -11,6 +11,10 @@ import { createMoveLog, ownTag, slotValue, type MoveLog } from "./move-log.js"; import { claimThreads } from "./remote.js"; import { createState, defaultOf, idOf, startTicking, type State } from "./state.js"; import { applySync, prepareSync, synchronize } from "./synchronize.js"; +import { logSyncWord } from "./move-log.js"; +import { createThreadDirectory } from "./thread-directory.js"; +import { createThreadSystems } from "./thread-systems.js"; +import { completionCapacityWord, completionOffsetWord, payloadCapacityWord, payloadCount, payloadOffsetWord, payloadSequence, payloadSync, ringBaseWord, ringCapacityWord, taskBlockWords, taskDoneWord, taskPostedWord } from "./thread-tasks.js"; // The move log on one thread: a world whose objects of thread "moveLogTest" a mirror fills, against an inline world given // the same commands, compared after every sync point by each entity's id. @@ -81,7 +85,7 @@ const inline = (): State => { /** Brings the mirror up to the last sync point, rebuilding when its log lapped it. */ const follow = (mirror: Mirror): void => { const replayed = replay(mirror); - if (replayed === "lapped") expect(rebuild(mirror)).toBe(true); + if (replayed === "lapped") expect(rebuild(mirror)).toBe("rebuilt"); else expect(replayed).toBe("current"); }; @@ -324,4 +328,54 @@ describe("the move log", () => { synchronize(state); expect(words[record + recordCount]).toBe(4); }); + it("runs a thread's task once its rebuild failed for a sync point applied during it, with no wake after", async () => { + const { state, mirror, log } = threaded(4); + const { commands, target } = worldCommands(state); + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + follow(mirror); + // The thread's task block, posting a task of no systems: it runs once the mirror is current. + const { memory } = state; + const block = memory.allocate(taskBlockWords * 4); + const control = new Int32Array(memory.memory.buffer, block, taskBlockWords); + const payload = memory.allocate(256); + const completion = memory.allocate(256); + const ring = memory.allocate(1 << 16); + Atomics.store(control, payloadOffsetWord, payload); + Atomics.store(control, payloadCapacityWord, 256); + Atomics.store(control, completionOffsetWord, completion); + Atomics.store(control, completionCapacityWord, 256); + Atomics.store(control, ringBaseWord, ring); + Atomics.store(control, ringCapacityWord, 1 << 16); + const systems = createThreadSystems({ + memory: memory.memory, mirror, directory: createThreadDirectory(memory.memory, mirror), control, claims: new Set(["moveLogTest"]), + worldWaits: true, post: () => {}, keyNumber: () => 0, numbered: () => 0, + }); + // More entries than the log holds, so the thread rebuilds. + for (let k = 0; k < 8; k++) commands.add(entity, amount, k); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + // A sync point applies while the thread rebuilds, after it read the structure epoch, and none follows it. + const through = mirror.finalize; + let applied = false; + mirror.finalize = (sync, old) => { + if (!applied) { + applied = true; + commands.add(entity, amount, 9); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + } + return through === null || through(sync, old); + }; + const words = new Float64Array(memory.memory.buffer, payload, 32); + words[payloadSequence] = 1; + words[payloadSync] = Atomics.load(log.header, logSyncWord) >>> 0; + words[payloadCount] = 0; + Atomics.store(control, taskPostedWord, 1); + expect(systems.process()).toBe(false); + expect(applied).toBe(true); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(Atomics.load(control, taskDoneWord)).toBe(1); + }); }); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 17e59997..7a1bbb9d 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -359,11 +359,17 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { })); }; + /** Whether the last rebuild found a sync point applying before or during it, so it can rebuild once that's applied. */ + let moved = false; /** Brings the mirror up to the last published sync, which runs the finalizers of its stops; false while it can't. */ const current = (): boolean => { + moved = false; const replayed = replay(mirror); if (replayed === "held") return false; - return replayed !== "lapped" || rebuild(mirror); + if (replayed !== "lapped") return true; + const rebuilt = rebuild(mirror); + moved = rebuilt === "moved"; + return rebuilt === "rebuilt"; }; /** Whether a task can run: its systems known here, its input arrived, and the mirror through its sync. */ @@ -428,10 +434,13 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return control; }; - /** A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has. */ + /** + * A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has: at once when the + * sync point applied during the rebuild, as nothing else may wake this thread before the task is done. + */ let structureWaited = false; const retryAfterStructure = (): void => { - if (structureWaited || !applying(mirror)) return; + if (structureWaited || (!moved && !applying(mirror))) return; structureWaited = true; void waitStructure(mirror).then(() => { structureWaited = false; From 318dc436fef7db69d56c72d82d6f992c90906f8e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:40:05 -0700 Subject: [PATCH 024/127] ecs: never cancel an observer post its thread claimed before a writer copied it A writer copying the rest of a post exchanged its progress word for `snapshotted`, which a sync point then took for a post no claim reached and cancelled, while the thread, between its first chunk and its next claim, still ran it: the run stopped short or read the next post's block. The copy now adds `snapshotted` to the word, so it still says a claim came first. Co-Authored-By: Claude Opus 5.5 --- .../world/joined-observer.test.ts | 58 +++++++++++++++++++ .../src/implementation/world/tail-blocks.ts | 10 +++- .../ecs/src/implementation/world/tails.ts | 10 ++-- packages/ecs/threads.md | 6 +- 4 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 packages/ecs/src/implementation/world/joined-observer.test.ts diff --git a/packages/ecs/src/implementation/world/joined-observer.test.ts b/packages/ecs/src/implementation/world/joined-observer.test.ts new file mode 100644 index 00000000..6b7856f2 --- /dev/null +++ b/packages/ecs/src/implementation/world/joined-observer.test.ts @@ -0,0 +1,58 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import { isLeaf } from "../../interface/registry/schedule.js"; +import { System } from "../../interface/system/system.js"; +import { createBuffer } from "./buffer.js"; +import { createCommands } from "./commands.js"; +import { cancelled, createJoinedObserverRunner, createObserverTarget, postObserver, settleObserverKeys, settlePosts } from "./joined-observer.js"; +import { createState, idOf } from "./state.js"; +import { synchronize } from "./synchronize.js"; +import { doneWord, progressWord, snapshotted, tailChunk } from "./tail-blocks.js"; + +// The world's side of an observer system on a joined thread, with this test standing for the thread: its claims and +// done word, in the order a loaded machine may give them. + +const amount = Symbol("joinedObserverTest/amount"); +declare module "@adobe/ecs" { + export interface Components { + [amount]: { type: number }; + } +} +Component.register(amount, { type: { type: "number" } }); + +/** What the observer system read, were it run: this test stands for the thread, so it never runs. */ +const seen: number[] = []; +const watch = System.create({ name: "joinedObserverTest/watch", observer: true, components: { read: [amount] } }, row => { seen.push(row[amount]); }); + +describe("an observer system on a joined thread", () => { + it("is waited for, not cancelled, by a sync point after a writer copied the rest of a post the thread claimed partway", async () => { + const state = createState(true); + const target = { buffer: createBuffer() }; + const commands = createCommands(state, target); + for (let k = 0; k < 200; k++) commands.spawn([amount])({ [amount]: k }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + const leaf = definitionOf(watch); + if (leaf === undefined || !isLeaf(leaf)) throw new Error("The observer system has no leaf."); + const observer = createJoinedObserverRunner(state, leaf, createObserverTarget(1, "joinedObserverTest", () => {}, () => {})).joined; + if (observer === null) throw new Error("The runner stands for no observer system on a joined thread."); + expect(postObserver(state, [], [observer], 0, null)).toBeNull(); + const { control } = observer; + // The thread claims the head and first rows, and reads them. + expect(Atomics.add(control, progressWord, tailChunk)).toBe(0); + Atomics.store(control, doneWord, tailChunk); + // A writer of what it reads copies the rest; the thread hasn't claimed again yet. + expect(settleObserverKeys(state, [idOf(state, amount)], [])).toBeNull(); + const settled = settlePosts(state); + expect(settled).not.toBeNull(); + // The thread's next claim reads the rest from the copy, and it finishes. + const claimed = Atomics.add(control, progressWord, tailChunk); + expect(claimed >= snapshotted && claimed < cancelled).toBe(true); + Atomics.store(control, doneWord, observer.end); + Atomics.notify(control, doneWord); + await settled; + expect(state.observer.posts).toEqual([]); + }); +}); diff --git a/packages/ecs/src/implementation/world/tail-blocks.ts b/packages/ecs/src/implementation/world/tail-blocks.ts index 08b80c8b..cb8326d0 100644 --- a/packages/ecs/src/implementation/world/tail-blocks.ts +++ b/packages/ecs/src/implementation/world/tail-blocks.ts @@ -6,8 +6,9 @@ import { lineWords } from './protocol.js'; // // A tail's work is numbered: position 0 is its head, which checks its conditions and resolves its resources, and // positions from 1 are the rows of its matched archetypes in order. The tail claims positions in chunks by adding to -// its progress word; the world exchanges that word for `snapshotted` once it copied the passing rows from the position -// it read, so a claim that returns `snapshotted` or more reads the rest from the copy. +// its progress word; the world adds `snapshotted` to that word once it copied the passing rows from the position it +// read, so a claim that returns `snapshotted` or more reads the rest from the copy, and the word still says whether a +// claim came before the copy. /** The positions the tail claimed, which it adds to and the world exchanges; and the end of its work, 1 plus its rows. */ export const progressWord = 0; @@ -28,7 +29,10 @@ export const snapshotCapacityWord = 2 * lineWords + 6; export const tailBlockWords = 3 * lineWords; export const tailBlockBytes = tailBlockWords * 4; -/** What the progress word holds once the world copied the rest: claims add to it and stay past it. */ +/** + * What the world adds to the progress word once it copied the rest, `snapshotted` itself when no claim came first: + * claims add to it and stay past it. + */ export const snapshotted = 0x40000000; /** Positions a claim takes: the most the world waits for once a writer is ready. */ export const tailChunk = 64; diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 55b863d0..237424d8 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -399,7 +399,7 @@ const markOwn = (state: State, tail: Copied, claimed: number): void => { /** * Before a writer of what a tail reads: unless it read everything, copies the rest from the position it reached, - * exchanges its progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + * adds `snapshotted` to its progress, and waits for the chunk it claimed. Null when nothing is left to wait for. */ const resolve = (state: State, tail: Tail): Promise | null => { settledTail(state, tail); @@ -407,8 +407,8 @@ const resolve = (state: State, tail: Tail): Promise | null => { }; /** - * Unless the reader read everything, copies the rest of what it reads from the position it reached, exchanges its - * progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + * Unless the reader read everything, copies the rest of what it reads from the position it reached, adds `snapshotted` + * to its progress, and waits for the chunk it claimed. Null when nothing is left to wait for. */ export const copyRest = (state: State, tail: Copied): Promise | null => { const { control, end } = tail; @@ -417,7 +417,9 @@ export const copyRest = (state: State, tail: Copied): Promise | null => { const from = Atomics.load(control, progressWord); runs.length = 0; if (from < end) writeSnapshot(state, tail, from); - const claimed = Atomics.exchange(control, progressWord, snapshotted); + // Added rather than exchanged, so a claim before the copy stays in the word: a post a claim reached is never taken + // for one none did (`cancel` in joined-observer.ts). + const claimed = Atomics.add(control, progressWord, snapshotted); tailWriters.copies++; // Rows the tail claimed while this copied it reads where they are. if (claimed < end) markOwn(state, tail, claimed); diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index db95f388..95f26f3a 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -587,9 +587,9 @@ its tails before it resolves, with their failures in its report. - **Fallback snapshot.** Sometimes a conflicting writer is ready before the tail has finished its reads, as when main is late. The world's thread then evaluates the tail's head as the tail would, copies the declared linear struct reads of the passing rows from the position the tail reached, with their position, archetype, row and entity, and its - linear resources, a string's handles among them, into a tail buffer, a column at a time; exchanges the progress word for a sentinel; and waits until - the tail is done through what it had claimed, at most one chunk, spinning briefly before it waits. A claim that - returns the sentinel reads the rest from the copy, rows from its own position on. The values are the same either + linear resources, a string's handles among them, into a tail buffer, a column at a time; adds a sentinel to the progress word, which so still says + whether a claim came first; and waits until the tail is done through what it had claimed, at most one chunk, spinning + briefly before it waits. A claim that returns the sentinel or more reads the rest from the copy, rows from its own position on. The values are the same either way, so this stays deterministic. A prompt main never copies. A string's handle in the copy names its record as of the copy, which outlives the tail: a tick posts its tails once the last tick's are done, and a run waits for them, so a tail ends before the second sync point after the one it reads, and a record let go is freed only then. The copy lets the world's work after its writer From 60947dcb498deea23c5aeed3cf3fa8cd91f8bdcc Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:40:05 -0700 Subject: [PATCH 025/127] ecs: report a lent worker its owner ends as terminated by its owner The worker's own end, which ending it causes, could be reported first, as a node worker's exit is when it comes before `terminate` resolves. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/port.test.ts | 23 +++++++++++++++++++ packages/ecs/src/implementation/world/port.ts | 7 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 packages/ecs/src/implementation/world/port.test.ts diff --git a/packages/ecs/src/implementation/world/port.test.ts b/packages/ecs/src/implementation/world/port.test.ts new file mode 100644 index 00000000..17408626 --- /dev/null +++ b/packages/ecs/src/implementation/world/port.test.ts @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { serveLent } from "./port.js"; + +describe("a lent worker's owner", () => { + it("reports a worker it ends as terminated by its owner, though the worker's own end is heard of first", async () => { + let died: ((error: Error) => void) | null = null; + // As a node worker's `exit` can be, before its `terminate` resolves. + const worker = { + onDeath: (handler: (error: Error) => void) => { died = handler; }, + terminate: async () => { died?.(new Error("A world worker exited with code 1.")); }, + }; + const { port1, port2 } = new MessageChannel(); + const reported = new Promise(resolve => { + port2.addEventListener("message", event => resolve(event.data)); + port2.start(); + }); + await serveLent(worker, port1).terminate(); + expect(await reported).toEqual({ kind: "death", message: "A world worker was terminated by its owner." }); + port1.close(); + port2.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/port.ts b/packages/ecs/src/implementation/world/port.ts index ce5acf70..9aef2b4f 100644 --- a/packages/ecs/src/implementation/world/port.ts +++ b/packages/ecs/src/implementation/world/port.ts @@ -127,10 +127,11 @@ export const serveLent = (worker: Pick, "onDeath" | "term control.post({ kind: "death", message: error.message }); }); const end = async (): Promise => { - await worker.terminate(); - if (gone) return; + // Gone before it's ended, so the death reported is this, not the worker's end it causes, which may come first. + const reported = gone; gone = true; - control.post({ kind: "death", message: "A world worker was terminated by its owner." }); + await worker.terminate(); + if (!reported) control.post({ kind: "death", message: "A world worker was terminated by its owner." }); }; control.onMessage(async () => { gone = true; From 6d0fd1c03f6a067b0b1945137c92ac5299796aac Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:59:09 -0700 Subject: [PATCH 026/127] ecs: retry a task's rebuild only for its own reason, after a turn of the event loop The retry's reason was kept in a flag any replay set, the finalizers' included, so a task waiting for its input, after a rebuild outside a task found a sync point applying, retried in an endless chain of microtasks and the input's message never arrived. The task now takes the reason from its own readiness, and a rebuild that found a sync point applied during it retries after a turn of the event loop, so sync points overlapping every rebuild can't keep the thread from it. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 6 + .../ecs/src/implementation/world/mirror.ts | 3 +- .../src/implementation/world/move-log.test.ts | 123 ++++++++++++------ .../src/implementation/world/tail-blocks.ts | 2 +- .../ecs/src/implementation/world/tails.ts | 3 +- .../implementation/world/thread-systems.ts | 61 +++++---- packages/ecs/threads.md | 7 +- 7 files changed, 138 insertions(+), 67 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index b337ebab..23de5f17 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -92,6 +92,12 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **A joined finalizer reads a string resource from its live record,** not a copy, where the strings of its stops are copied into the stop list. +## Findings from the joined-thread stress run + +- **A lent worker's death has two reports that race on the side it's lent to:** its port's `close` and its owner's + message, so which message a run's failure carries varies (`ports.cases.ts` accepts either). Worth deciding whether + the owner's report should win, for one message per way a worker ends. + ## Features to discuss - **Computed index keys:** an index keyed on a value computed from a component, such as a struct field diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts index 0d73e87d..266055f2 100644 --- a/packages/ecs/src/implementation/world/mirror.ts +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -486,7 +486,8 @@ const previousValue = (old: readonly (Mirrored | null | undefined)[], entity: nu /** * Rebuilds every column from the layout directory, as of the last published sync, under the structure epoch: rows * from each archetype's `entities` and count, values by the tag each row's storage holds, a staged slot's taken, any - * other the thread's own kept by entity, and ticks from the world's. Changes nothing unless it's `"rebuilt"`. + * other the thread's own kept by entity, and ticks from the world's. Short of `"rebuilt"`, its columns are as they + * were, though the finalizers of stops through the sync it read may have run first. */ export const rebuild = (mirror: Mirror): Rebuilt => { refresh(mirror); diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index acd9c1de..4cda1454 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -7,14 +7,16 @@ import { createCommands } from "./commands.js"; import { asEntity, indexOf, locate as locateHere } from "./entities.js"; import { recordCount, structureWord, tableOffsetWord } from "./layout-directory.js"; import { applying, cellOf, createMirror, nameComponent, rebuild, replay, stage, waitStructure, type Mirror } from "./mirror.js"; -import { createMoveLog, ownTag, slotValue, type MoveLog } from "./move-log.js"; +import { createMoveLog, logSyncWord, ownTag, slotValue, type MoveLog } from "./move-log.js"; import { claimThreads } from "./remote.js"; import { createState, defaultOf, idOf, startTicking, type State } from "./state.js"; import { applySync, prepareSync, synchronize } from "./synchronize.js"; -import { logSyncWord } from "./move-log.js"; import { createThreadDirectory } from "./thread-directory.js"; import { createThreadSystems } from "./thread-systems.js"; -import { completionCapacityWord, completionOffsetWord, payloadCapacityWord, payloadCount, payloadOffsetWord, payloadSequence, payloadSync, ringBaseWord, ringCapacityWord, taskBlockWords, taskDoneWord, taskPostedWord } from "./thread-tasks.js"; +import { + completionCapacityWord, completionOffsetWord, payloadCapacityWord, payloadCount, payloadInput, payloadOffsetWord, payloadSequence, payloadSync, ringBaseWord, + ringCapacityWord, taskBlockWords, taskDoneWord, taskPostedWord, +} from "./thread-tasks.js"; // The move log on one thread: a world whose objects of thread "moveLogTest" a mirror fills, against an inline world given // the same commands, compared after every sync point by each entity's id. @@ -229,6 +231,49 @@ const property = (seed: number, minimum: number, steps: number): { readonly comp return { compared, rebuilds: mirror.rebuilds }; }; +/** + * A thread's systems over a mirror the log lapped, so its next task rebuilds it: `post` posts a task of no systems, + * which runs once the mirror is current, its input by message when `input`; `refreshes` counts its tries. + */ +const lapped = () => { + const { state, mirror, log } = threaded(4); + const { commands, target } = worldCommands(state); + const queue = (): void => { state.queued.push({ buffer: target.buffer, guarded: true }); }; + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + queue(); + synchronize(state); + follow(mirror); + const { memory } = state; + const control = new Int32Array(memory.memory.buffer, memory.allocate(taskBlockWords * 4), taskBlockWords); + const payload = memory.allocate(256); + Atomics.store(control, payloadOffsetWord, payload); + Atomics.store(control, payloadCapacityWord, 256); + Atomics.store(control, completionOffsetWord, memory.allocate(256)); + Atomics.store(control, completionCapacityWord, 256); + Atomics.store(control, ringBaseWord, memory.allocate(1 << 16)); + Atomics.store(control, ringCapacityWord, 1 << 16); + const directory = createThreadDirectory(memory.memory, mirror); + let refreshed = 0; + const counted = { ...directory, refresh: () => { refreshed++; return directory.refresh(); } }; + const systems = createThreadSystems({ + memory: memory.memory, mirror, directory: counted, control, claims: new Set(["moveLogTest"]), + worldWaits: true, post: () => {}, keyNumber: () => 0, numbered: () => 0, + }); + // More entries than the log holds. + for (let k = 0; k < 8; k++) commands.add(entity, amount, k); + queue(); + synchronize(state); + const post = (input: boolean): void => { + const words = new Float64Array(memory.memory.buffer, payload, 32); + words[payloadSequence] = 1; + words[payloadSync] = Atomics.load(log.header, logSyncWord) >>> 0; + words[payloadCount] = 0; + words[payloadInput] = input ? 1 : 0; + Atomics.store(control, taskPostedWord, 1); + }; + return { state, mirror, entity, commands, queue, systems, post, done: () => Atomics.load(control, taskDoneWord) === 1, refreshes: () => refreshed }; +}; + describe("the move log", () => { it("gives a joined thread's columns the inline world's values and ticks after every sync point, replaying its log", () => { for (const seed of [1, 2, 3, 4]) { @@ -329,33 +374,7 @@ describe("the move log", () => { expect(words[record + recordCount]).toBe(4); }); it("runs a thread's task once its rebuild failed for a sync point applied during it, with no wake after", async () => { - const { state, mirror, log } = threaded(4); - const { commands, target } = worldCommands(state); - const entity = commands.spawn([uid, label])({ [uid]: 1 }); - state.queued.push({ buffer: target.buffer, guarded: true }); - synchronize(state); - follow(mirror); - // The thread's task block, posting a task of no systems: it runs once the mirror is current. - const { memory } = state; - const block = memory.allocate(taskBlockWords * 4); - const control = new Int32Array(memory.memory.buffer, block, taskBlockWords); - const payload = memory.allocate(256); - const completion = memory.allocate(256); - const ring = memory.allocate(1 << 16); - Atomics.store(control, payloadOffsetWord, payload); - Atomics.store(control, payloadCapacityWord, 256); - Atomics.store(control, completionOffsetWord, completion); - Atomics.store(control, completionCapacityWord, 256); - Atomics.store(control, ringBaseWord, ring); - Atomics.store(control, ringCapacityWord, 1 << 16); - const systems = createThreadSystems({ - memory: memory.memory, mirror, directory: createThreadDirectory(memory.memory, mirror), control, claims: new Set(["moveLogTest"]), - worldWaits: true, post: () => {}, keyNumber: () => 0, numbered: () => 0, - }); - // More entries than the log holds, so the thread rebuilds. - for (let k = 0; k < 8; k++) commands.add(entity, amount, k); - state.queued.push({ buffer: target.buffer, guarded: true }); - synchronize(state); + const { state, mirror, entity, commands, queue, systems, post, done } = lapped(); // A sync point applies while the thread rebuilds, after it read the structure epoch, and none follows it. const through = mirror.finalize; let applied = false; @@ -363,19 +382,49 @@ describe("the move log", () => { if (!applied) { applied = true; commands.add(entity, amount, 9); - state.queued.push({ buffer: target.buffer, guarded: true }); + queue(); synchronize(state); } return through === null || through(sync, old); }; - const words = new Float64Array(memory.memory.buffer, payload, 32); - words[payloadSequence] = 1; - words[payloadSync] = Atomics.load(log.header, logSyncWord) >>> 0; - words[payloadCount] = 0; - Atomics.store(control, taskPostedWord, 1); + post(false); expect(systems.process()).toBe(false); expect(applied).toBe(true); await new Promise(resolve => setTimeout(resolve, 5)); - expect(Atomics.load(control, taskDoneWord)).toBe(1); + expect(done()).toBe(true); + }); + it("runs a thread's task once the sync point its rebuild found applying is applied", async () => { + const { state, mirror, entity, commands, queue, systems, post, done } = lapped(); + commands.add(entity, amount, 9); + queue(); + expect(prepareSync(state)).toBe(true); + expect(rebuild(mirror)).toBe("moved"); + post(false); + expect(systems.process()).toBe(false); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(done()).toBe(false); + applySync(state); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(done()).toBe(true); + }); + it("keeps a thread's event loop while its task waits for its input, after a rebuild outside a task found a sync point applying", async () => { + const { state, mirror, entity, commands, queue, systems, post, done, refreshes } = lapped(); + commands.add(entity, amount, 9); + queue(); + expect(prepareSync(state)).toBe(true); + // Its finalizers replay, and the rebuild finds the sync point applying. + mirror.stagedTransfers = 1; + systems.finalize(); + applySync(state); + // A task whose input is posted after it: nothing runs it until the input arrives. + post(true); + expect(systems.process()).toBe(false); + const before = refreshes(); + for (let k = 0; k < 200; k++) await Promise.resolve(); + const tries = refreshes() - before; + systems.input(1, { given: true }); + expect(done()).toBe(true); + // None while the thread's event loop goes on, so the input's message can arrive. + expect(tries).toBe(0); }); }); diff --git a/packages/ecs/src/implementation/world/tail-blocks.ts b/packages/ecs/src/implementation/world/tail-blocks.ts index cb8326d0..867e3ffc 100644 --- a/packages/ecs/src/implementation/world/tail-blocks.ts +++ b/packages/ecs/src/implementation/world/tail-blocks.ts @@ -10,7 +10,7 @@ import { lineWords } from './protocol.js'; // read, so a claim that returns `snapshotted` or more reads the rest from the copy, and the word still says whether a // claim came before the copy. -/** The positions the tail claimed, which it adds to and the world exchanges; and the end of its work, 1 plus its rows. */ +/** The positions the tail claimed, which it and the world add to; and the end of its work, 1 plus its rows. */ export const progressWord = 0; export const endWord = 1; /** The position the tail finished reading through, which it stores after each chunk; whether the world waits for it. */ diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 237424d8..8b28ba92 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -418,7 +418,8 @@ export const copyRest = (state: State, tail: Copied): Promise | null => { runs.length = 0; if (from < end) writeSnapshot(state, tail, from); // Added rather than exchanged, so a claim before the copy stays in the word: a post a claim reached is never taken - // for one none did (`cancel` in joined-observer.ts). + // for one none did (`cancel` in joined-observer.ts). Claims past it, a chunk at a time, keep the word below + // `cancelled` there, `snapshotted` plus at most the end and a chunk, as ends are far below their difference. const claimed = Atomics.add(control, progressWord, snapshotted); tailWriters.copies++; // Rows the tail claimed while this copied it reads where they are. diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 7a1bbb9d..6ab3431c 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -99,6 +99,9 @@ type CommandKey = { /** The system that ran a task's commands, for their checks' errors. */ type Running = { error: unknown }; +/** Whether a task's mirror is current, or why not (`bringCurrent`). */ +type Readiness = "current" | "moved" | "held"; + /** * Runs the systems the world places on this thread, as tasks it posts in the task block: each task's systems in order, * after replaying the mirror through the task's sync, writing each system's commands and spawns as a segment of the @@ -359,24 +362,25 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { })); }; - /** Whether the last rebuild found a sync point applying before or during it, so it can rebuild once that's applied. */ - let moved = false; - /** Brings the mirror up to the last published sync, which runs the finalizers of its stops; false while it can't. */ - const current = (): boolean => { - moved = false; + /** + * Brings the mirror up to the last published sync, which runs the finalizers of its stops: `"moved"` when its + * rebuild found a sync point applying before or during it, so it can rebuild once that's applied, and `"held"` + * while it waits for something else. + */ + const bringCurrent = (): Readiness => { const replayed = replay(mirror); - if (replayed === "held") return false; - if (replayed !== "lapped") return true; + if (replayed === "held") return "held"; + if (replayed !== "lapped") return "current"; const rebuilt = rebuild(mirror); - moved = rebuilt === "moved"; - return rebuilt === "rebuilt"; + return rebuilt === "rebuilt" ? "current" : rebuilt; }; + const current = (): boolean => bringCurrent() === "current"; /** Whether a task can run: its systems known here, its input arrived, and the mirror through its sync. */ - const ready = (payload: Float64Array, count: number): boolean => { - for (let k = 0; k < count; k++) if (runners[payload[payloadSystems + k * systemFields + systemIndex]] === undefined) return false; - if (payload[payloadInput] !== 0 && !inputs.has(payload[payloadSequence])) return false; - return current(); + const ready = (payload: Float64Array, count: number): Readiness => { + for (let k = 0; k < count; k++) if (runners[payload[payloadSystems + k * systemFields + systemIndex]] === undefined) return "held"; + if (payload[payloadInput] !== 0 && !inputs.has(payload[payloadSequence])) return "held"; + return bringCurrent(); }; /** Views of the payload and completion, kept while the world keeps them where they are. */ @@ -435,17 +439,25 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { }; /** - * A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has: at once when the - * sync point applied during the rebuild, as nothing else may wake this thread before the task is done. + * A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has. One whose rebuild + * found a sync point applied during it runs again after a turn of the event loop: nothing else may wake this thread + * before the task is done, and sync points overlapping every rebuild mustn't keep it from its event loop. */ let structureWaited = false; - const retryAfterStructure = (): void => { - if (structureWaited || (!moved && !applying(mirror))) return; - structureWaited = true; - void waitStructure(mirror).then(() => { - structureWaited = false; - process(); - }); + const retried = (): void => { + structureWaited = false; + process(); + }; + const retryAfterStructure = (moved: boolean): void => { + if (structureWaited) return; + if (applying(mirror)) { + structureWaited = true; + void waitStructure(mirror).then(retried); + } + else if (moved) { + structureWaited = true; + setTimeout(retried, 0); + } }; const runTask = (posted: number): void => { @@ -454,8 +466,9 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { const count = payload[payloadCount]; const sequence = payload[payloadSequence]; pending = sequence; - if (!ready(payload, count)) { - retryAfterStructure(); + const readiness = ready(payload, count); + if (readiness !== "current") { + retryAfterStructure(readiness === "moved"); return; } pending = -1; diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 95f26f3a..39777cd2 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -587,9 +587,10 @@ its tails before it resolves, with their failures in its report. - **Fallback snapshot.** Sometimes a conflicting writer is ready before the tail has finished its reads, as when main is late. The world's thread then evaluates the tail's head as the tail would, copies the declared linear struct reads of the passing rows from the position the tail reached, with their position, archetype, row and entity, and its - linear resources, a string's handles among them, into a tail buffer, a column at a time; adds a sentinel to the progress word, which so still says - whether a claim came first; and waits until the tail is done through what it had claimed, at most one chunk, spinning - briefly before it waits. A claim that returns the sentinel or more reads the rest from the copy, rows from its own position on. The values are the same either + linear resources, a string's handles among them, into a tail buffer, a column at a time; adds a sentinel to the + progress word, which then still shows whether a claim came first; and waits until the tail is done through what it + had claimed, at most one chunk, spinning briefly before it waits. A claim that returns the sentinel or more reads the + rest from the copy, rows from its own position on. The values are the same either way, so this stays deterministic. A prompt main never copies. A string's handle in the copy names its record as of the copy, which outlives the tail: a tick posts its tails once the last tick's are done, and a run waits for them, so a tail ends before the second sync point after the one it reads, and a record let go is freed only then. The copy lets the world's work after its writer From f4b94b550064d1cd1cff277029f015ed060f5293 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:30:15 -0700 Subject: [PATCH 027/127] ecs: a pool worker runs its window's tasks before it leaves the closed window A worker checked for new tasks, then for a close. A task the pool posted between the two, then closed the window before the worker saw it, was left behind: the worker left, and its next window runs only tasks of its own epoch, so the task never ran and the world hung on it (worker dispatch "fast, message, fast kernels in one tier", under load). On a close the worker now checks for tasks again first. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/world/worker.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index 9f2b276b..3589a9c1 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -471,7 +471,12 @@ const runWindow = (message: Extract): void => { Atomics.notify(control, done); continue; } - if (Atomics.load(control, epoch) !== message.epoch) return; + // Closed, it leaves once it ran this window's tasks: the pool posts a task before it closes the window, so a task + // posted since the check above is seen now, and runs above if it's this window's. + if (Atomics.load(control, epoch) !== message.epoch) { + if (next < Atomics.load(control, posted)) continue; + return; + } // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. const deadline = performance.now() + 0.05; while (Atomics.load(control, signal) === seen && performance.now() < deadline); From 5d99e258510f8dfef11cff5be8a536b190bb762a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:40:15 -0700 Subject: [PATCH 028/127] ecs: test a pool worker's window and a task's wait on the structure epoch A pool worker's window loop moves to `worker-window.ts`, taking its views, its epoch and the task runner, so a test plays the pool between the worker's look for tasks and its look at the epoch. A task whose rebuild found a sync point applying is checked to try nothing until the sync point is applied. Co-Authored-By: Claude Opus 5.5 --- .../src/implementation/world/move-log.test.ts | 5 +- .../world/worker-window.test.ts | 43 ++++++++++++ .../src/implementation/world/worker-window.ts | 43 ++++++++++++ .../ecs/src/implementation/world/worker.ts | 70 ++++++------------- 4 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 packages/ecs/src/implementation/world/worker-window.test.ts create mode 100644 packages/ecs/src/implementation/world/worker-window.ts diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index 4cda1454..54c1372d 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -394,15 +394,18 @@ describe("the move log", () => { expect(done()).toBe(true); }); it("runs a thread's task once the sync point its rebuild found applying is applied", async () => { - const { state, mirror, entity, commands, queue, systems, post, done } = lapped(); + const { state, mirror, entity, commands, queue, systems, post, done, refreshes } = lapped(); commands.add(entity, amount, 9); queue(); expect(prepareSync(state)).toBe(true); expect(rebuild(mirror)).toBe("moved"); post(false); expect(systems.process()).toBe(false); + const tried = refreshes(); await new Promise(resolve => setTimeout(resolve, 5)); expect(done()).toBe(false); + // It waits on the structure epoch, trying nothing until the sync point is applied. + expect(refreshes()).toBe(tried); applySync(state); await new Promise(resolve => setTimeout(resolve, 5)); expect(done()).toBe(true); diff --git a/packages/ecs/src/implementation/world/worker-window.test.ts b/packages/ecs/src/implementation/world/worker-window.test.ts new file mode 100644 index 00000000..d0590739 --- /dev/null +++ b/packages/ecs/src/implementation/world/worker-window.test.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { controlWords, done, epoch, posted, ringSize, taskWords } from "./protocol.js"; +import { runWindow } from "./worker-window.js"; + +describe("a pool worker's window", () => { + it("runs a task the pool posted just before closing it, after the worker last looked for tasks", () => { + const control = new Int32Array(new SharedArrayBuffer(controlWords * 4)); + const statuses = new Int32Array(new SharedArrayBuffer(ringSize * 4)); + const payloads = new Float64Array(new SharedArrayBuffer(ringSize * taskWords * 8)); + const windowEpoch = 2; + control[epoch] = windowEpoch; + // The pool, between the worker's look at `posted` and its look at the epoch: it posts a task of this window, + // then closes it. + const load = Atomics.load; + let lookedForTasks = false; + let played = false; + const playPool = (array: Int32Array, index: number): number => { + const value = load(array, index); + if (array !== control) return value; + if (index === posted) lookedForTasks = true; + else if (index === epoch && lookedForTasks && !played) { + played = true; + payloads[6] = windowEpoch; + Atomics.store(control, posted, 1); + return Atomics.add(control, epoch, 1) + 1; + } + return value; + }; + const original = Object.getOwnPropertyDescriptor(Atomics, "load"); + let ran = 0; + try { + Object.defineProperty(Atomics, "load", { value: playPool, configurable: true, writable: true }); + runWindow(control, statuses, payloads, windowEpoch, () => { ran++; }); + } + finally { + if (original !== undefined) Object.defineProperty(Atomics, "load", original); + } + expect(played).toBe(true); + expect(ran).toBe(1); + expect(Atomics.load(control, done)).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/world/worker-window.ts b/packages/ecs/src/implementation/world/worker-window.ts new file mode 100644 index 00000000..5d3755b6 --- /dev/null +++ b/packages/ecs/src/implementation/world/worker-window.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { done, epoch, posted, ringSize, signal, taskWords } from './protocol.js'; + +/** + * A pool worker's window (`pool.ts`): runs the tasks of its ring, each by `runTask` with its payload's base, which + * throws when the task fails, blocking between them, until the pool closes the window or posts a task of a later one; + * then it returns, so the worker handles its messages. `control`, `statuses` and `payloads` are the worker's views of + * the pool's state; `windowEpoch` is the epoch the window opened in. + */ +export const runWindow = (control: Int32Array, statuses: Int32Array, payloads: Float64Array, windowEpoch: number, runTask: (base: number) => void): void => { + let next = Atomics.load(control, done); + for (;;) { + const seen = Atomics.load(control, signal); + if (next < Atomics.load(control, posted)) { + const ring = next % ringSize; + const base = ring * taskWords; + // A task from a later window waits until this worker has handled the messages sent before it. + if (payloads[base + 6] !== windowEpoch) return; + let status = next + 1; + try { + runTask(base); + } + catch { + status = -status; + } + Atomics.store(statuses, ring, status); + next++; + Atomics.store(control, done, next); + Atomics.notify(control, done); + continue; + } + // Closed, it leaves once it ran this window's tasks: the pool posts a task before it closes the window, so a task + // posted since the check above is seen now, and runs above if it's this window's. + if (Atomics.load(control, epoch) !== windowEpoch) { + if (next < Atomics.load(control, posted)) continue; + return; + } + // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. + const deadline = performance.now() + 0.05; + while (Atomics.load(control, signal) === seen && performance.now() < deadline); + if (Atomics.load(control, signal) === seen) Atomics.wait(control, signal, seen); + } +}; diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index 3589a9c1..944a4afd 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -7,7 +7,8 @@ import { blockShift, chunkShift, type Kernel, type KernelCommands, type KernelMo import { layoutOf, viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; import { emptyViews } from './arrays.js'; import { claimPiece } from './claims.js'; -import { controlWords, done, epoch, idleKernel, lineWords, posted, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, signal, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; +import { controlWords, idleKernel, lineWords, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; +import { runWindow } from './worker-window.js'; import { beginCommand, createRingWriter, encoderOf, endSegment, noExtent, objectStart, putComponent, putStart, putValue, startSegment, type Encoder, type Extent } from './command-ring.js'; import { forEachPassingRange, type Filter } from './ranges.js'; import { indexParameterCount, instantiateWasm, scalarValues, type WasmRun } from './wasm.js'; @@ -429,59 +430,30 @@ const runBuildTask = (payloads: Float64Array, base: number, buffer: ArrayBufferL * Runs tasks from this worker's ring in shared memory, blocking between them, until the main thread asks it to leave; * then it returns to handling messages. */ -const runWindow = (message: Extract): void => { +const openWindow = (message: Extract): void => { const buffer = required(memory, "memory").buffer; // The pool's control state never moves, so these views last the window. const control = new Int32Array(buffer, message.control + message.worker * controlWords * 4, controlWords); const statuses = new Int32Array(buffer, message.statuses + message.worker * ringSize * 4, ringSize); const payloads = new Float64Array(buffer, message.payloads + message.worker * ringSize * taskWords * 8, ringSize * taskWords); - let next = Atomics.load(control, done); - for (;;) { - const seen = Atomics.load(control, signal); - if (next < Atomics.load(control, posted)) { - const ring = next % ringSize; - const base = ring * taskWords; - // A task from a later window waits until this worker has handled the messages sent before it. - if (payloads[base + 6] !== message.epoch) return; - let status = next + 1; - try { - refreshMemory(); - const kernel = payloads[base]; - if (kernel === buildKernel) runBuildTask(payloads, base, buffer, message); - else if (kernel !== idleKernel) { - const loaded = kernels.get(kernel); - if (loaded === undefined) throw new Error("Worker can't run this task."); - const line = payloads[base + 8]; - const participants = payloads[base + 11]; - const scalarCount = payloads[base + 7]; - const partials = loaded.setup.reduces ? payloads[base + taskFields + scalarCount] : -1; - const partialBytes = loaded.setup.reduces ? payloads[base + taskFields + scalarCount + 1] : 0; - runWasm(loaded, payloads[base + 1], payloads[base + 2], payloads[base + 3], payloads[base + 4], payloads[base + 5], - Array.from(payloads.subarray(base + taskFields, base + taskFields + scalarCount)), - line < 0 ? null : { words: new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords), self: payloads[base + 10], participants }, - payloads[base + 9], partials ?? -1, partialBytes ?? 0); - } - } - catch { - status = -status; - } - Atomics.store(statuses, ring, status); - next++; - Atomics.store(control, done, next); - Atomics.notify(control, done); - continue; - } - // Closed, it leaves once it ran this window's tasks: the pool posts a task before it closes the window, so a task - // posted since the check above is seen now, and runs above if it's this window's. - if (Atomics.load(control, epoch) !== message.epoch) { - if (next < Atomics.load(control, posted)) continue; - return; + runWindow(control, statuses, payloads, message.epoch, base => { + refreshMemory(); + const kernel = payloads[base]; + if (kernel === buildKernel) runBuildTask(payloads, base, buffer, message); + else if (kernel !== idleKernel) { + const loaded = kernels.get(kernel); + if (loaded === undefined) throw new Error("Worker can't run this task."); + const line = payloads[base + 8]; + const participants = payloads[base + 11]; + const scalarCount = payloads[base + 7]; + const partials = loaded.setup.reduces ? payloads[base + taskFields + scalarCount] : -1; + const partialBytes = loaded.setup.reduces ? payloads[base + taskFields + scalarCount + 1] : 0; + runWasm(loaded, payloads[base + 1], payloads[base + 2], payloads[base + 3], payloads[base + 4], payloads[base + 5], + Array.from(payloads.subarray(base + taskFields, base + taskFields + scalarCount)), + line < 0 ? null : { words: new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords), self: payloads[base + 10], participants }, + payloads[base + 9], partials ?? -1, partialBytes ?? 0); } - // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. - const deadline = performance.now() + 0.05; - while (Atomics.load(control, signal) === seen && performance.now() < deadline); - if (Atomics.load(control, signal) === seen) Atomics.wait(control, signal, seen); - } + }); }; /** Where messages come from and replies go: this worker's own scope, until a lent worker's owner gives it a port. */ @@ -503,7 +475,7 @@ const handle = async (message: ToWorker): Promise => { return; } if (message.kind === "window") { - runWindow(message); + openWindow(message); return; } if (message.kind === "layout") { From a03cfe005d467c35628d32dfcd0413c0c3c93afe Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:19:55 -0700 Subject: [PATCH 029/127] ecs: joined threads' index lookups check archetypes once a run, not per row A row a lookup gave on a joined thread loaded its archetype's view, the mirror's entry and the record's version on every read. Each flush that writes an existing archetype's record again now adds one to a header word, and the thread's directory moves a count on once a run when that word, its mirror's archetypes or its own views changed; rows stay bound while the count does, as on the world's thread. `joined observer 10K` 485 -> 438 and 480 -> 417 us a tick, `joined select 10K factory` 524 -> 498 us. Rows also compare the view they were bound to, not only its version, so a row bound before the thread's log lapped no longer reads the rebuilt mirror's old columns (red before, green now). Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/followups.md | 2 - .../implementation/world/index-joined.test.ts | 92 +++++++++++++++++++ .../src/implementation/world/index-lookups.ts | 42 +++++---- .../implementation/world/layout-directory.ts | 13 ++- packages/ecs/src/implementation/world/perf.md | 6 +- .../implementation/world/thread-directory.ts | 37 +++++++- .../implementation/world/thread-indexes.ts | 4 +- .../implementation/world/thread-observer.ts | 2 +- .../implementation/world/thread-systems.ts | 8 +- packages/ecs/threads.md | 6 +- 11 files changed, 176 insertions(+), 38 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 3f5cd18d..30fe3543 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -287,7 +287,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets kept in entity order and long ones left unsorted, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. - Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits. - - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. + - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and rows check one count of replaced arrays rather than their archetype's version, on a joined thread settled once a run: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. - Kernels compile each lookup call into a probe the world binds to its index's layout; WASM probes in straight-line code, reading the index's arrays at addresses each run's parameters give, on the world's thread and pool workers; joined threads read a record of them the world writes before each task: `grid neighbors 100K` 3.7 µs a row on the world's thread, 179 ns a row in a kernel across the pool. - `order`: main-thread bodies walk the index's entries; filtered runs sort passing rows' positions while few pass and scan past an eighth; kernels run lists of rows as `up`'s do: `order run 100K` 31 ns a row against 28 in storage order. - **`up`:** diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 23de5f17..50b6216c 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -117,8 +117,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next record. -- **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the - thread's run (see perf.md "Next"). ## Earlier open work diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 3abb3c8f..8aadce65 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -27,6 +27,7 @@ const name = Symbol("indexJoinedTest/name"); const title = Symbol("indexJoinedTest/title"); const band = Symbol("indexJoinedTest/band"); const bandReader = Symbol("indexJoinedTest/bandReader"); +const marked = Symbol("indexJoinedTest/marked"); declare module "@adobe/ecs" { export interface Threads { @@ -44,6 +45,7 @@ declare module "@adobe/ecs" { [title]: { type: string }; [band]: { type: number }; [bandReader]: { type: true }; + [marked]: { type: true }; } } Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); @@ -60,6 +62,7 @@ Component.register(name, { type: text, thread: "indexJoinedTest" }); Component.register(title, { type: { type: "string" } }); Component.register(band, { type: { type: "integer", minimum: 0, maximum: 1 } }); Component.register(bandReader, { type: { const: true } }); +Component.register(marked, { type: { const: true } }); const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); @@ -276,6 +279,81 @@ const joinedStatus = (world: CreatedWorld): Promise => new Promise(resolve }); }); +/** Entities a keyless and a unique index hold, each with this thread's label and a string title. */ +const seedHeld = System.create({ name: "indexJoinedTest/seedHeld" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, weight, label, title])({ [serial]: k, [weight]: k % 5, [label]: blank, [title]: `t${k}` }); +}); +/** On the joined thread: every label, by serial and round. */ +const relabelHeld = System.create({ name: "indexJoinedTest/relabelHeld", components: { read: [serial], write: [label], has: [title] } }, (row, _context, input: { readonly round: number }) => { + row[label] = { text: `${row[serial]}.${input.round}` }; +}); +/** On the world's thread: every weight and title, by round. */ +const rewriteHeld = System.create({ name: "indexJoinedTest/rewriteHeld", components: { read: [serial], write: [weight, title] } }, (row, _context, input: { readonly round: number }) => { + row[weight] = row[serial] * 10 + input.round; + row[title] = `t${row[serial]}.${input.round}`; +}); +/** Moves every held entity to the other archetype, marked on one run and not on the next. */ +const markHeld = System.create({ name: "indexJoinedTest/markHeld", components: { has: [title], not: [marked] } }, (row, { commands }) => { + commands.add(row.entity, marked); +}); +const unmarkHeld = System.create({ name: "indexJoinedTest/unmarkHeld", components: { has: [title, marked] } }, (row, { commands }) => { + commands.remove(row.entity, marked); +}); +const held = Index.create({ name: "indexJoinedTest/held", key: [], components: { has: [serial, weight, label, title] } }); +const heldSerials = Index.create({ name: "indexJoinedTest/heldSerials", key: [serial], unique: true, components: { has: [weight, label, title] } }); +/** What `observeHeld` read last. */ +let observedHeld = ""; +/** An observer system reading every held row through the keyless index, and serial 3's through the unique one. */ +const observeHeld = System.create({ + name: "indexJoinedTest/observeHeld", observer: true, + lookup: { all: { index: held, read: [serial, weight, label, title] }, bySerial: { index: heldSerials, read: [weight, label, title] } }, +}, ({ lookup }) => { + const all = lookup.all(); + const read: string[] = []; + for (let i = 0; i < all.length; i++) { + const row = all.at(i); + read.push(`${row[serial]}:${row[weight]}:${row[label].text}:${row[title]}`); + } + const one = lookup.bySerial(3); + observedHeld = `${read.sort().join(",")}|${one === null ? "none" : `${one[weight]}:${one[label].text}:${one[title]}`}`; +}); +/** + * Observes the held rows where `holder` runs observer systems, around what makes the rows a lookup gave stale: arrays + * grown, values and strings rewritten, the thread's labels rewritten, and every row moved past what its log holds. + */ +const heldScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: string[] = []; + const observe = async (): Promise => { + await holder.run(observeHeld); + seen.push(observedHeld); + }; + await world.run(seedHeld, { from: 0, count: 8 }); + await world.run(relabelHeld, { round: 0 }); + await observe(); + // Past the archetype's capacity, without growing the memory. + await world.run(seedHeld, { from: 8, count: 12 }); + await observe(); + await world.run(rewriteHeld, { round: 1 }); + await world.run(relabelHeld, { round: 1 }); + await observe(); + await world.run(seedHeld, { from: 20, count: 600 }); + await observe(); + // Both archetypes and the reports' grown, so lapping the log again replaces no arrays: only the mirror's rebuild + // makes views again. + const lap = async (): Promise => { + for (let k = 0; k < 30; k++) await world.run(k % 2 === 0 ? markHeld : unmarkHeld); + await world.run(dropReports); + }; + await lap(); + await observe(); + await lap(); + await observe(); + await world.run(relabelHeld, { round: 2 }); + await world.run(rewriteHeld, { round: 2 }); + await observe(); + return seen; +}; + describe("systems on a joined thread looking up through indexes", () => { it("find what the world's own systems find: hash buckets, unique rows, sorted ranges and changes", async () => { const inline = createWorld({ [workers]: 0 }); @@ -323,6 +401,20 @@ describe("systems on a joined thread looking up through indexes", () => { await remote.dispose(); await world.dispose(); }); + it("read rows as of each run's sync point once arrays grew, values, strings and the thread's objects changed, and its log lapped", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await heldScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await heldScenario(world, remote)).toEqual(alone); + expect(alone.at(-1)).toContain("|32:3.2:t3.2"); + await remote.dispose(); + await world.dispose(); + }); it("release the index and its records once the last system using it there is removed", async () => { const driver = backToBack(); const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); diff --git a/packages/ecs/src/implementation/world/index-lookups.ts b/packages/ecs/src/implementation/world/index-lookups.ts index e2bbbea3..83a85063 100644 --- a/packages/ecs/src/implementation/world/index-lookups.ts +++ b/packages/ecs/src/implementation/world/index-lookups.ts @@ -41,10 +41,10 @@ export type IndexLookup = { readonly watched: readonly number[]; readonly since: Since; /** - * How many times the world's archetypes' arrays were replaced, while which rows bound to them stay current: on the - * world's thread. Null on a joined thread, whose rows check their archetype's version each time. + * How many times archetypes' arrays were replaced, while which rows bound to them stay current: the world's count on + * its thread; on a joined thread, its directory's, moved on once a run finds any archetype's views changed. */ - readonly replaced: { readonly count: number } | null; + readonly replaced: { readonly count: number }; }; /** The lookup behind each function bodies are given, for the plugin's call sites. */ @@ -59,6 +59,8 @@ type Pool = { readonly rows: (Row | undefined)[]; readonly archetypes: number[]; readonly versions: number[]; + /** By position: the archetype it was bound to, since a joined thread's view of one is made again when it changes. */ + readonly bound: (LookupArchetype | undefined)[]; readonly make: (archetype: RowArchetype) => Row; /** The lookup's `replaced` count when every row was last checked against its archetype's version. */ replaced: number; @@ -72,7 +74,7 @@ const makers = new WeakMap Row>(); const poolOf = (lookup: IndexLookup): Pool => { let make = makers.get(lookup); if (make === undefined) makers.set(lookup, make = sharedRows(lookup.rows, lookup.reads, lookup.readIds)); - return { rows: [], archetypes: [], versions: [], make, replaced: lookup.replaced?.count ?? 0, ahead: 0 }; + return { rows: [], archetypes: [], versions: [], bound: [], make, replaced: lookup.replaced.count, ahead: 0 }; }; /** Binds row `n` of a pool to an archetype, making it first if the pool has none there yet. */ @@ -83,12 +85,14 @@ const bindRow = (pool: Pool, n: number, archetypeId: number, archetype: LookupAr rows.push(undefined); pool.archetypes.push(-1); pool.versions.push(-1); + pool.bound.push(undefined); } let found = rows[n]; if (found === undefined) found = rows[n] = pool.make(archetype); else found.bind(archetype); pool.archetypes[n] = archetypeId; pool.versions[n] = archetype.version; + pool.bound[n] = archetype; return found; }; @@ -109,11 +113,14 @@ const growPool = (lookup: IndexLookup, pool: Pool, length: number, archetypeId: for (let n = pool.rows.length; n < end; n++) bindRow(pool, n, archetypeId, archetype); }; -/** Binds again the rows whose archetype's arrays were replaced since the pool last checked, on the world's thread. */ +/** Whether row `n` of a pool is bound to an archetype as it is now: the same view, its arrays not replaced since. */ +const boundTo = (pool: Pool, n: number, archetype: LookupArchetype): boolean => pool.bound[n] === archetype && pool.versions[n] === archetype.version; + +/** Binds again the rows whose archetype's arrays were replaced since the pool last checked. */ const refreshPool = (lookup: IndexLookup, pool: Pool, replaced: { readonly count: number }): void => { for (let n = 0; n < pool.rows.length; n++) { const archetype = pool.rows[n] === undefined ? undefined : lookup.archetypeAt(pool.archetypes[n]); - if (archetype !== undefined && archetype.version !== pool.versions[n]) bindRow(pool, n, pool.archetypes[n], archetype); + if (archetype !== undefined && !boundTo(pool, n, archetype)) bindRow(pool, n, pool.archetypes[n], archetype); } pool.replaced = replaced.count; }; @@ -121,27 +128,22 @@ const refreshPool = (lookup: IndexLookup, pool: Pool, replaced: { readonly count /** Binds row `n` again to an entry's archetype, or makes it, when its archetype or that's arrays changed. */ const reboundAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { const { replaced } = lookup; - if (replaced !== null && pool.replaced !== replaced.count) refreshPool(lookup, pool, replaced); + if (pool.replaced !== replaced.count) refreshPool(lookup, pool, replaced); const archetype = lookup.archetypeAt(archetypeId); if (archetype === undefined) return undefined; const current = pool.rows[n]; - const found = current !== undefined && pool.archetypes[n] === archetypeId && pool.versions[n] === archetype.version ? current : bindRow(pool, n, archetypeId, archetype); + const found = current !== undefined && pool.archetypes[n] === archetypeId && boundTo(pool, n, archetype) ? current : bindRow(pool, n, archetypeId, archetype); found.index = row; return found.value; }; -/** Whether row `n` of a pool is bound to its archetype's current arrays, on a joined thread. */ -const currentVersion = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number): boolean => - pool.versions[n] === lookup.archetypeAt(archetypeId)?.version; - /** - * Points row `n` of a pool at an entry's archetype and row, binding it again only when its archetype or that's arrays - * changed: on the world's thread, when any archetype's were replaced since the pool last checked. + * Points row `n` of a pool at an entry's archetype and row, binding it again only when its archetype changed, or any + * archetype's arrays were replaced since the pool last checked. */ const rowAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { const found = pool.rows[n]; - const { replaced } = lookup; - if (found === undefined || pool.archetypes[n] !== archetypeId || (replaced === null ? !currentVersion(lookup, pool, n, archetypeId) : pool.replaced !== replaced.count)) return reboundAt(lookup, pool, n, archetypeId, row); + if (found === undefined || pool.archetypes[n] !== archetypeId || pool.replaced !== lookup.replaced.count) return reboundAt(lookup, pool, n, archetypeId, row); found.index = row; return found.value; }; @@ -311,8 +313,8 @@ const generatedFind = (lookup: IndexLookup, hash: HashView, shape: Shape, list: ...lookup.unique ? [ `if (bucket < 0 || records[bucket * ${hash.stride} + ${recordLength}] === 0) return null;`, `const at = records[bucket * ${hash.stride} + ${recordStart}] * ${entryWords};`, - "const archetypeId = table.entries[at + 1], archetype = archetypeAt(archetypeId), row = pool.rows[0];", - "if (row === undefined || archetype === undefined || pool.archetypes[0] !== archetypeId || pool.versions[0] !== archetype.version) return rowAt(lookup, pool, 0, archetypeId, table.entries[at + 2]);", + "const archetypeId = table.entries[at + 1], row = pool.rows[0];", + "if (row === undefined || pool.archetypes[0] !== archetypeId || pool.replaced !== replaced.count) return rowAt(lookup, pool, 0, archetypeId, table.entries[at + 2]);", "row.index = table.entries[at + 2];", "return row.value;", ] : [ @@ -323,9 +325,9 @@ const generatedFind = (lookup: IndexLookup, hash: HashView, shape: Shape, list: "};", ].join("\n"); try { - const factory = new Function("key", "table", "found", "list", "pool", "lookup", "rowAt", "growPool", "encodeValue", "parts", "orderF32", "orderF64", "f32", "f64", "u32", "u8", "archetypeAt", body); + const factory = new Function("key", "table", "found", "list", "pool", "lookup", "rowAt", "growPool", "encodeValue", "parts", "orderF32", "orderF64", "f32", "f64", "u32", "u8", "replaced", body); // The factory returns the function its body above makes. - const made: (...values: unknown[]) => unknown = factory(new Uint32Array(Math.max(1, words)), hash, found, list, found.pool, lookup, rowAt, growPool, encodeValue, index.parts, orderF32, orderF64, f32, f64, u32, u8, lookup.archetypeAt); + const made: (...values: unknown[]) => unknown = factory(new Uint32Array(Math.max(1, words)), hash, found, list, found.pool, lookup, rowAt, growPool, encodeValue, index.parts, orderF32, orderF64, f32, f64, u32, u8, lookup.replaced); return made; } catch { diff --git a/packages/ecs/src/implementation/world/layout-directory.ts b/packages/ecs/src/implementation/world/layout-directory.ts index b5535023..c4beb2ad 100644 --- a/packages/ecs/src/implementation/world/layout-directory.ts +++ b/packages/ecs/src/implementation/world/layout-directory.ts @@ -12,8 +12,9 @@ import type { Memory } from './memory.js'; * * Header words: the memory's length in pages (`memory.ts`), the table's offset and length, the structure epoch, then * the entity-table record: its `cursor` offset, which never moves, `free` offset and capacity, `archetype`, `row` and - * `generation` offsets and their capacity, and its version; then the resource entity's index plus one, 0 for none. The - * table's length is `cursor[2]`. + * `generation` offsets and their capacity, and its version; then the resource entity's index plus one, 0 for none; the + * structure epoch's waiters; and how many flushes replaced an existing archetype's arrays. The table's length is + * `cursor[2]`. * * The structure epoch is odd from the start of each sync point that applies anything until its directory writes at the * end, and even otherwise. A thread reading entity locations outside a task reads while it's even and checks it again @@ -35,6 +36,11 @@ export const entitiesVersionField = 7; export const resourceEntityWord = 12; /** Readers waiting on the structure epoch, so the world notifies only when one is. */ export const structureWaitersWord = 13; +/** + * Flushes that wrote an existing archetype's record again, its arrays replaced, so a joined thread checks this word + * once a run rather than each record's version for every row it reads. + */ +export const replacedWord = 14; /** * An archetype record: its capacity, `entities` offset, storage count, version, flags and row count, then per storage @@ -175,13 +181,16 @@ export const flushDirectory = (directory: LayoutDirectory | null, bumped: Archet directory.entities = entities.version; } if (bumped.length === 0) return; + let replaced = false; for (let k = 0; k < bumped.length; k++) { const archetype = bumped[k]; archetype.listed = false; + if (directory.records[archetype.id] !== undefined) replaced = true; writeArchetype(directory, archetype); } bumped.length = 0; Atomics.store(directory.header, tableLengthWord, archetypes.length); + if (replaced) Atomics.add(directory.header, replacedWord, 1); }; /** diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 71e35280..3ffc5de9 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -184,6 +184,10 @@ In chromium: `sparse changed main` 295 → 260 µs, `sparse changed workers` 70 - **WASM kernels find runs of passing blocks in an outer loop and run their rows in an inner one,** a chunk's passing blocks read into a mask once and each run found with `ctz`, a chunk with more than half its blocks passing taken as one run; every row pays one comparison against the run's end, where it paid a test of whether it started a chunk: `changed all 100K kernels` 504 → 420 µs. - **Index refreshes and `up` find changed key-part and filter rows by blocks too.** +Joined threads' index lookups checking archetypes once a run, alternating against `13eb6d6f`, node, on a machine loaded by other work, medians of 9 and 11 rounds: +- **A row a lookup gave stays bound while one count stays the same,** as on the world's thread: each flush that writes an existing archetype's record again adds one to a header word, and a joined thread's directory moves its count on once a run when the word, its mirror's archetypes (a rebuild) or its views (memory grown, keys named) changed. Before, every row read loaded its archetype's view, the mirror's entry and the record's version. `joined observer 10K` 485 → 438 µs and 480 → 417 µs a tick; `joined select 10K factory` 524 → 498 µs. Within noise: `joined observer numbers 10K`, `joined observer strings 10K` (their rows bind once per archetype a run either way), `joined observer resource` 4.7 against 4.6 µs, `unique lookup 1M` 79 against 71 ns (its generated site now checks the count, not the archetype's version), `grid neighbors 100K main` 5.94 against 5.99 µs a row. +- **A rebuilt mirror's rows are bound again:** rows compare the view they were bound to, not only its version, so a row a lookup gave before the thread's log lapped no longer reads the objects of the mirror's old columns. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -405,6 +409,6 @@ move, chromium, by worker count and entity count (a quarter of the entities move - Kernels taking `%` of an f64: a conditional on `resources[clock].tick % 2` costs about 33 ns a row in node, 10K rows 333 µs against 8.5 µs without it. - Replaying the move log: `log replay` takes 24 ns a move in node and 36.5 ns in chromium, two entries each, against about 1 ns to move the bytes; the cost is the per-entry dispatch and the scattered rows a swap reads. - Ordered selections recomputed every tick: `joined observer 10K` spends most of its 0.4–0.5 ms a tick sorting 10K entities. -- `joined observer 10K`, back to back: lookups read the rows in place, so the world's writer waits for each run the thread claimed, about 1.07 ms, in 1,451 of 3,000 ticks; the thread's run is about 60% the benchmark's own sort, and of the rest `at` through the index without a key (14%) and a load of the archetype's version per row (12%), which a check of versions once a post would save. +- `joined observer 10K`, back to back: lookups read the rows in place, so the world's writer waits for each run the thread claimed, about 1.07 ms, in 1,451 of 3,000 ticks; the thread's run is mostly the benchmark's own sort, and of the rest `at` through the index without a key. - A tail's body reading from a snapshot ran slower in one case: a loop counted down from an unbounded integer resource (an f64) took 13.6 ms against 11.2 in place over 10,000 rows of 200 steps, in node, while counted as an i32 it took 10.8 against 11.2; deopts didn't account for it. - Main-thread spawns reserving in blocks too: `spawn bulk` still takes one atomic add per spawn. Finalizers spawn during the sync point, after it materializes the run's reservations, so a world's block would have to end before each materialization. diff --git a/packages/ecs/src/implementation/world/thread-directory.ts b/packages/ecs/src/implementation/world/thread-directory.ts index c0fcae4c..84407144 100644 --- a/packages/ecs/src/implementation/world/thread-directory.ts +++ b/packages/ecs/src/implementation/world/thread-directory.ts @@ -6,7 +6,7 @@ import { emptyViews } from './arrays.js'; import { tableLength } from './entities.js'; import { archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, - recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, resourceEntityWord, resourceFlag, + recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, replacedWord, resourceEntityWord, resourceFlag, rowField, storageAdded, storageBlockAdded, storageBlockChanged, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, } from './layout-directory.js'; @@ -67,12 +67,38 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror const thread = mirror?.thread ?? -1; /** The records strings' columns name, which this thread reads. */ const strings = createStringReader(memory); + /** + * Moved on whenever a view `archetypeAt` gave a row may have been made again: the world replaced an archetype's + * arrays, the views were dropped, or the mirror rebuilt. Index lookups' rows stay bound while it stays the same, so + * they check it rather than each archetype for every row. A mirror that begins holding an archetype needs no move: + * it does from the first entry naming it, replayed before any run reads a row there. + */ + const replaced = { count: 0 }; + /** The directory's `replacedWord`, and the mirror's archetypes, as this thread last saw them. */ + let replacedSeen = 0; + let mirroredSeen: readonly (Mirrored | null | undefined)[] = noMirrored; + + /** + * Moves `replaced` on if any archetype's view may have changed since it last looked: once a run, before it reads + * rows, since the world replaces arrays only at a sync point or a move outside one, which wait for a run in progress. + */ + const settle = (): void => { + const word = Atomics.load(viewed.words, replacedWord); + const mirrored = mirror?.archetypes ?? noMirrored; + if (word === replacedSeen && mirrored === mirroredSeen) return; + replacedSeen = word; + mirroredSeen = mirrored; + replaced.count++; + }; - /** Views of the memory again, once it grew; every archetype's views with them. Returns its buffer. */ + /** Views of the memory again, once it grew, every archetype's with them, and `replaced` settled. Returns its buffer. */ const refresh = (): ArrayBufferLike => { if (viewed.words.length === 0) viewed.words = new Uint32Array(memory.buffer); const current = Atomics.load(viewed.words, pagesWord); - if (current === pages) return viewed.buffer; + if (current === pages) { + settle(); + return viewed.buffer; + } const latest = coveringBuffer(memory, current * pageBytes); if (latest.byteLength < current * pageBytes) throw new Error(`This thread's memory is ${latest.byteLength} bytes, short of the ${current * pageBytes} the world stored.`); viewed.buffer = latest; @@ -80,7 +106,9 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror viewed.views = viewsOf(latest, 0, latest.byteLength); pages = current; archetypes.length = 0; + replaced.count++; entitiesVersion = -1; + settle(); return latest; }; @@ -194,6 +222,7 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror layouts[number] = layoutOf(declarationOf(key)?.type); // Views hold storages only of components with layouts here, so they're made again. archetypes.length = 0; + replaced.count++; } } }; @@ -205,7 +234,7 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror }; return { - viewed, rows, keys, layouts, numbers, mirror, strings, + viewed, rows, keys, layouts, numbers, mirror, strings, replaced, refresh, refreshEntities, archetypeAt, countOf, resourceRow, tableCount, name, numberOf, layoutOf: (key: Key): Layout | undefined => layouts[numbers.get(firstKeyOf(key)) ?? -1], }; diff --git a/packages/ecs/src/implementation/world/thread-indexes.ts b/packages/ecs/src/implementation/world/thread-indexes.ts index 2681c87a..96dc2a05 100644 --- a/packages/ecs/src/implementation/world/thread-indexes.ts +++ b/packages/ecs/src/implementation/world/thread-indexes.ts @@ -39,7 +39,7 @@ const u32From = (buffer: ArrayBufferLike, at: number): Uint32Array => new Uint32 const i32From = (buffer: ArrayBufferLike, at: number): Int32Array => new Int32Array(buffer, at); const f64From = (buffer: ArrayBufferLike, at: number): Float64Array => new Float64Array(buffer, at); -export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => LookupArchetype | undefined, setup: ThreadIndexSetup): ThreadIndex => { +export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => LookupArchetype | undefined, replaced: { readonly count: number }, setup: ThreadIndexSetup): ThreadIndex => { const { parts, words } = keyPartsOf(setup.layouts); const watchedPositions = Array.from({ length: setup.watched }, (_, k) => k); const hashWatched = new Map(); @@ -52,7 +52,7 @@ export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => Lo }; const index: IndexView = { parts, words, unique: setup.unique, hash, sorted }; const since: Since = { value: 0 }; - const lookup = lookupFunction({ rows, archetypeAt, index, unique: setup.unique, reads: setup.reads, readIds: setup.readIds, watched: watchedPositions, since, replaced: null }); + const lookup = lookupFunction({ rows, archetypeAt, index, unique: setup.unique, reads: setup.reads, readIds: setup.readIds, watched: watchedPositions, since, replaced }); const count = recordWordsOf(setup.sorted, setup.watched); let record: Float64Array = none64; const refresh = (buffer: ArrayBufferLike): void => { diff --git a/packages/ecs/src/implementation/world/thread-observer.ts b/packages/ecs/src/implementation/world/thread-observer.ts index ea6ecb2f..572864e3 100644 --- a/packages/ecs/src/implementation/world/thread-observer.ts +++ b/packages/ecs/src/implementation/world/thread-observer.ts @@ -73,7 +73,7 @@ export const createThreadObserver = (host: ThreadObserverHost) => { const runnerHost: RunnerHost = { rows: directory.rows, archetypeAt: directory.archetypeAt, countOf: directory.countOf, tableCount: directory.tableCount, resourceRow: directory.resourceRow, refreshEntities: directory.refreshEntities, commands: noCommands, - numberOf: directory.numberOf, layoutOf: directory.layoutOf, mirror, refresh: directory.refresh, strings: directory.strings, + numberOf: directory.numberOf, layoutOf: directory.layoutOf, mirror, refresh: directory.refresh, replaced: directory.replaced, strings: directory.strings, }; const placed: (Placed | undefined)[] = []; diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 6ab3431c..7fd37e5e 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -286,7 +286,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { const runnerHost: RunnerHost = { rows: rowHost, archetypeAt, countOf, tableCount, resourceRow, refreshEntities, commands, mirror, - refresh: directory.refresh, + refresh: directory.refresh, replaced: directory.replaced, numberOf: directory.numberOf, layoutOf: directory.layoutOf, strings: directory.strings, @@ -596,8 +596,10 @@ export type RunnerHost = { readonly layoutOf: (key: Key) => Layout | undefined; /** This thread's objects, null where it fills none. */ readonly mirror: Mirror | null; - /** Views of the memory again, once it grew: its buffer. */ + /** Views of the memory again, once it grew, and `replaced` moved on if a view may have: its buffer. */ readonly refresh: () => ArrayBufferLike; + /** Moved on by `refresh` once any archetype's view may have changed, which index lookups' rows check (`thread-directory.ts`). */ + readonly replaced: { readonly count: number }; /** The records strings' columns name. */ readonly strings: StringStore; }; @@ -700,7 +702,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon if (index === undefined || record === undefined) found = entityLookupOf(host, declared, lookupSince); else { const reads = declared.read ?? []; - const threadIndex = createThreadIndex(host.rows, host.archetypeAt, { + const threadIndex = createThreadIndex(host.rows, host.archetypeAt, host.replaced, { layouts: index.declaration.key.map(key => { const layout = host.layoutOf(key); if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 39777cd2..ccecac55 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -184,7 +184,7 @@ All derived from declarations and, for terminal systems, the plugin's analysis o flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets, its chunk count and its block-tick offsets, sixteen blocks a chunk; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource archetype has a record too, flagged, and the header holds the - resource entity's index. + resource entity's index, and a count of flushes that wrote an existing archetype's record again. - **Entity-table record:** the `cursor` offset, which never moves, the free list's offset and capacity, the `archetype`, `row` and `generation` offsets and capacity, and a version. The table's length is `cursor[2]`. Its arrays move whenever they grow, even without memory growing, since a grown array's old block is reused. @@ -196,7 +196,9 @@ All derived from declarations and, for terminal systems, the plugin's analysis o - **Reads:** a thread checks a record's version, one load, before using what it cached for that archetype, and keeps per-archetype column views and kernel columns, rebuilt only when that version moves. The whole-buffer view serves the header and records; columns stay views of their own, as kernels and layouts take them. On an entity-table version - change, a worker updates the `entities` object its spawners hold in place. + change, a worker updates the `entities` object its spawners hold in place. A joined thread's index lookups check + that count once a run instead, with its mirror's archetypes and its own views (memory grown, keys named): rows they + gave stay bound while none moved, and are bound again to the views they find otherwise, a rebuilt mirror's included. - **Messages:** only new components' schemas are posted, none for a value that isn't data, since a message closes the pool's windows. A new archetype is found in the table, its record naming its component numbers, the first time a task names it. From 3a2cf054886a92932e970ccab462387510a281d0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:33:38 -0700 Subject: [PATCH 030/127] ecs: say why a joined directory's view drops move its count, and where a task settles it Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/world/thread-directory.ts | 2 ++ packages/ecs/src/implementation/world/thread-systems.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/ecs/src/implementation/world/thread-directory.ts b/packages/ecs/src/implementation/world/thread-directory.ts index 84407144..95008772 100644 --- a/packages/ecs/src/implementation/world/thread-directory.ts +++ b/packages/ecs/src/implementation/world/thread-directory.ts @@ -106,6 +106,7 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror viewed.views = viewsOf(latest, 0, latest.byteLength); pages = current; archetypes.length = 0; + // Defensive: rows bound to the old views still read the same shared memory, and arrays that moved bumped `replacedWord`. replaced.count++; entitiesVersion = -1; settle(); @@ -222,6 +223,7 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror layouts[number] = layoutOf(declarationOf(key)?.type); // Views hold storages only of components with layouts here, so they're made again. archetypes.length = 0; + // Defensive: a lookup's read keys are named before its runner and pool exist, so no bound row lacks a storage. replaced.count++; } } diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 7fd37e5e..110857a9 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -834,6 +834,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const since = !filtered || resourcesChanged ? -1 : lastRun; bind(); if (threadIndexes.length !== 0) { + // Settles the count after `ready()` or `current()` may have rebuilt the mirror, since `runTask` settles before them. const buffer = host.refresh(); for (let k = 0; k < threadIndexes.length; k++) threadIndexes[k].refresh(buffer); } From 7e0f98a1e55e3573caedb6bf4ea88b0a800e37e6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 02:57:28 -0700 Subject: [PATCH 031/127] ecs: joined finalizers read string resources from copies in the stop list, as before apply A finalizer on a joined thread read a string resource from its live record when it ran, so it saw the value as of its replay, after its stops' sync point applied. Each group of stops now holds a copy of each string resource's record its finalizer reads, made before apply and let go with the block's other copies once the thread acknowledged it. Adds a test (red before) and the `despawn objects 10K string resource` benchmark. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/followups.md | 5 --- .../src/implementation/world/finalizers.ts | 14 ++++++- packages/ecs/src/implementation/world/perf.md | 2 + .../world/remote-finalizers-worker.fixture.ts | 5 ++- .../world/remote-finalizers.cases.ts | 4 ++ .../world/remote-finalizers.fixture.ts | 26 ++++++++++++- .../world/remote-finalizers.test-helper.ts | 24 ++++++++++++ .../src/implementation/world/stop-lists.ts | 38 ++++++++++++++---- .../implementation/world/thread-finalizers.ts | 33 +++++++++++++--- packages/ecs/src/perftest/finalized.ts | 39 +++++++++++++++++-- packages/ecs/threads.md | 8 ++-- 12 files changed, 170 insertions(+), 30 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 30fe3543..ee8e44b1 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -28,7 +28,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Structure of Arrays component storage. - Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them. - **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)). - - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. + - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads as they were before apply, from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. - **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there. - Memory only reallocates at sync points. Structure is frozen between sync points, so workers never see memory move mid-phase. - Removed rows stay in place until Apply. At Apply the last row is swap-moved into the hole and the entity lookup table is updated. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 50b6216c..e1fb5fe2 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -87,11 +87,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in `src/observe`. -## Findings from joined finalizers' resources - -- **A joined finalizer reads a string resource from its live record,** not a copy, where the strings of its stops are - copied into the stop list. - ## Findings from the joined-thread stress run - **A lent worker's death has two reports that race on the side it's lent to:** its port's `close` and its owner's diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts index 59e63052..0f095f31 100644 --- a/packages/ecs/src/implementation/world/finalizers.ts +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -112,8 +112,14 @@ export const placeFinalizer = (state: State, runner: FinalizerRunner, channel: T copies.push(layout.kind === "string"); } } + // In the order the thread's context reads them. + const stringResources: number[] = []; + for (const key of new Set(finalizer.declaration.resources?.read ?? [])) { + const id = idOf(state, key); + if (state.layouts[id].kind === "string") stringResources.push(id); + } const index = channel.placeFinalizer(state, finalizer); - runner.remote = { finalizer, index, objects, linear, strides, copies, entries: [], channel }; + runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, entries: [], channel }; }; const matches = (runner: FinalizerRunner, archetype: Archetype): boolean => { @@ -382,7 +388,11 @@ export const runFinalizers = (state: State, queued: readonly Queued[], born: Map } if (state.claimed && staged(state)) { const { plan } = state; - const source = { entity: plan.entity, from: plan.from, despawned: plan.despawned, archetypes: state.archetypes, row: state.entities.row, holds: state.holds }; + const resource = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const source = { + entity: plan.entity, from: plan.from, despawned: plan.despawned, archetypes: state.archetypes, row: state.entities.row, holds: state.holds, + resourceArchetype: resource < 0 ? -1 : state.entities.archetype[resource], resourceRow: resource < 0 ? 0 : state.entities.row[resource], + }; for (const channel of state.channels) writeStops(channel.stops, tick, source); } } diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 3ffc5de9..ae1be5d5 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -136,6 +136,8 @@ Strings in shared memory, alternating against `c6bb3c83`, node, five and six rou A joined thread's finalizers reading its object resources from its mirror, alternating against `998738a3`, node, three rounds: `despawn objects 10K` 98.2–102 against 99.6–103 ns, within noise. The mirror's archetype holding the resource entity is found once per group of stops that reads one, kept and checked before it's searched for again. +A joined thread's finalizers reading string resources from copies in the stop list, once per group, alternating against `13eb6d6f`, node, six rounds, medians, on a machine busy elsewhere: all within noise. New `despawn objects 10K string resource`, the despawn benchmark's finalizer reading a string resource too, 95.5 ns a despawn against 99.5 reading the live record (the benchmark run on the old build); `despawn objects 10K` 98.0 against 97.2 ns, `string write 10K joined` 129 against 129 ns, `joined observer strings 10K` 478 against 500 µs a tick. Finalizers reading no string resource copy nothing and the group holds no extra bytes. + Sync points waiting only for tails that read the world's memory, against `c21e8e1e`, node: `tail late snapshot 10K` 474–484 → 9.0–9.5 ns a row copied, against 8.9 for `e3c30aa1` measured beside it (its record 8.2), and in chromium 450 → 6.0 ns (its record 5.0, one or two 5 µs timer steps); within noise in both: `tail prompt`, `tail overlap`, `tail overlap waiting`. Since every run spawns its report, until 256 are, almost every sync point has something to apply, and each one settled every tail, so the run waited out the whole body of the benchmark's 4 ms tail ahead of the late one, whose head it had claimed. - **A tail reading only its own thread's objects isn't settled at a sync point,** having nothing there a sync point writes: 474 → 12.8 ns. - **The benchmark times the run alone,** reading its report after: 12.8 → 9.0 ns. Reading it through `runReported` timed a second run as well. diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts index c5b7c057..256b6aaf 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -2,7 +2,7 @@ import type { MessagePort as NodeMessagePort } from "node:worker_threads"; import { join } from "./create.js"; import { scopeChannel } from "./port.js"; -import { entitled, released, type Released } from "./remote-finalizers.test-helper.js"; +import { entitled, headlined, released, type Released } from "./remote-finalizers.test-helper.js"; /** * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it @@ -14,12 +14,14 @@ export type ToRemoteFinalizersWorker = | { readonly kind: "join"; readonly port: Port } | { readonly kind: "released" } | { readonly kind: "entitled" } + | { readonly kind: "headlined" } | { readonly kind: "pause"; readonly milliseconds: number } | { readonly kind: "spin" }; export type FromRemoteFinalizersWorker = | { readonly kind: "joined" } | { readonly kind: "released"; readonly released: readonly Released[] } | { readonly kind: "entitled"; readonly entitled: readonly { readonly serial: number; readonly title: string }[] } + | { readonly kind: "headlined"; readonly headlined: readonly { readonly serial: number; readonly heading: string }[] } | { readonly kind: "spinning" }; const channel = scopeChannel(); @@ -31,6 +33,7 @@ channel.onMessage(message => { } else if (message.kind === "released") channel.post({ kind: "released", released }); else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); + else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined }); else if (message.kind === "pause") { channel.post({ kind: "spinning" }); // Runs nothing meanwhile, finalizers included, then goes on. diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index dbeb61ea..0b75c5e2 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -22,4 +22,8 @@ export const remoteFinalizersCases = (load: () => Promise) => de const fixture = await load(); expect(await fixture.titledStops()).toEqual(Array.from({ length: 50 }, (_, k) => ({ serial: k, title: `t${k}` }))); }, 30000); + it("read a string resource as it was before their stops' sync point applied, after the world wrote it again and freed its record", async () => { + const fixture = await load(); + expect(await fixture.headedStops()).toEqual(Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: k < 10 ? "h0" : "h6" }))); + }, 30000); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts index 6f22b5a2..c1509ee1 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -6,7 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { indexOf } from "./entities.js"; import type { FromRemoteFinalizersWorker, ToRemoteFinalizersWorker } from "./remote-finalizers-worker.fixture.js"; -import { despawn, released, retitle, scenario, serial, sorted, spawn, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; +import { despawn, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; import { fillReports, runReported } from "./reports.test-helper.js"; /** @@ -139,3 +139,27 @@ export const titledStops = async () => { await world.dispose(); return [...seen].sort((a, b) => a.serial - b.serial); }; + +/** + * Headed rows despawned with the heading "h0" while the worker runs nothing, then the world's thread writing the + * heading six times, so the record "h0" was in is freed and reused before the worker finalizes them, and the rest + * despawned: the headings its finalizer read. + */ +export const headedStops = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + await world.run(rehead, { heading: "h0" }); + await world.run(spawnHeaded, { from: 0, count: 20 }); + await worker.ask({ kind: "pause", milliseconds: 300 }); + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(rehead, { heading: `h${round}` }); + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); + let seen: readonly { readonly serial: number; readonly heading: string }[] = []; + for (let k = 0; k < 200 && seen.length < 20; k++) { + const reply = await worker.ask({ kind: "headlined" }); + seen = reply.kind === "headlined" ? reply.headlined : []; + } + await worker.terminate(); + await world.dispose(); + return [...seen].sort((a, b) => a.serial - b.serial); +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 6482949c..2b9fd0c2 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -18,6 +18,8 @@ export const widget = Symbol("remoteFinalizersTest/widget"); export const label = Symbol("remoteFinalizersTest/label"); export const tone = Symbol("remoteFinalizersTest/tone"); export const title = Symbol("remoteFinalizersTest/title"); +export const headed = Symbol("remoteFinalizersTest/headed"); +export const heading = Symbol("remoteFinalizersTest/heading"); declare module "@adobe/ecs" { export interface Threads { remoteFinalizersTest: true; @@ -30,9 +32,11 @@ declare module "@adobe/ecs" { [widget]: { type: Widget | null; thread: "remoteFinalizersTest" }; [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest" }; [title]: { type: string }; + [headed]: { type: true }; } export interface Resources { [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; + [heading]: { type: string }; } } /** A value that isn't data, which only its thread holds. */ @@ -47,6 +51,9 @@ Component.register(label, { type: { anyOf: [text, { type: "null" }], default: { Resource.register(tone, { type: { ...text, default: { text: "quiet" } }, thread: "remoteFinalizersTest" }); /** A string, in shared memory, which the world's thread writes and the finalizer on the thread reads. */ Component.register(title, { type: { type: "string" } }); +Component.register(headed, { type: { const: true } }); +/** A string resource, which the world's thread writes and the finalizer on the thread reads. */ +Resource.register(heading, { type: { type: "string", default: "h" } }); /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; @@ -72,6 +79,23 @@ export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", entitled.push({ serial: row[serial], title: row[title] }); }); +/** Every headed stop finalized on this thread, in order, with the heading it read. */ +export const headlined: { readonly serial: number; readonly heading: string }[] = []; + +/** On the finalizers' thread: the heading as it was before each headed stop's sync point applied. */ +export const headline = Finalizer.create({ name: "remoteFinalizersTest/headline", components: { read: [serial, widget], has: [headed] }, resources: { read: [heading] } }, (row, { resources }) => { + headlined.push({ serial: row[serial], heading: resources[heading] }); +}); + +/** Spawns headed rows from the world's thread, with the thread's widget at null. */ +export const spawnHeaded = System.create({ name: "remoteFinalizersTest/spawnHeaded" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); +}); +/** From the world's thread: the heading. */ +export const rehead = System.create({ name: "remoteFinalizersTest/rehead", resources: { write: [heading] } }, ({ resources }, input: { readonly heading: string }) => { + resources[heading] = input.heading; +}); + /** Spawns titled rows from the world's thread, with the thread's widget at null. */ export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); diff --git a/packages/ecs/src/implementation/world/stop-lists.ts b/packages/ecs/src/implementation/world/stop-lists.ts index 8a5390f6..0820fe96 100644 --- a/packages/ecs/src/implementation/world/stop-lists.ts +++ b/packages/ecs/src/implementation/world/stop-lists.ts @@ -12,7 +12,8 @@ import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; // its bytes. Then a group per finalizer with stops, in the world's finalizer order: its index on the thread, its stops, // its bytes; then as columns, each on 8 bytes, every stop's entity (f64), its archetype and row before apply (u32), the // tag of each object it reads (u32), and the value of each linear struct it reads in that component's layout, or for a -// string, the handle of a copy of its record, freed with the block, since the thread may read it sync points later. +// string, the handle of a copy of its record; then the handle of a copy of each string resource it reads (u32). Copies +// are freed with the block, since the thread may read them sync points later. export const blockSyncWord = 0; export const blockNextWord = 1; @@ -37,11 +38,14 @@ export const valuesAt = (group: number, count: number, objects: number): number /** Bytes of one linear read's column of `count` values of `stride` bytes. */ export const columnBytes = (count: number, stride: number): number => align(count * stride); -/** Bytes of a group of `count` stops of a finalizer reading `objects` objects and linear values of `strides` bytes. */ -const groupBytes = (count: number, objects: number, strides: readonly number[]): number => { +/** + * Bytes of a group of `count` stops of a finalizer reading `objects` objects, linear values of `strides` bytes and + * `strings` string resources. + */ +const groupBytes = (count: number, objects: number, strides: readonly number[], strings: number): number => { let bytes = valuesAt(0, count, objects); for (const stride of strides) bytes += columnBytes(count, stride); - return bytes; + return bytes + align(4 * strings); }; /** One of a joined thread's finalizers, as the world writes its stops. */ @@ -55,6 +59,8 @@ export type StopsOf = { readonly strides: readonly number[]; /** By linear read: whether it's a string's handles, whose records the block holds copies of. */ readonly copies: readonly boolean[]; + /** The string resources it reads, in its context's order, whose records each group holds copies of. */ + readonly stringResources: readonly number[]; /** This sync point's stops, as plan entries, reused by every sync point. */ readonly entries: number[]; }; @@ -128,7 +134,10 @@ const copyStrings = (strings: StringStore, copied: number[], u32: Uint32Array, a /** A storage as stops are copied from it: its tags, or its linear values. */ type Copied = { readonly tags: Uint32Array; readonly views: { readonly u8: Uint8Array; readonly u32: Uint32Array } }; -/** What a sync point's stops come from: entries' entities and archetypes before apply, and the entity table's rows. */ +/** + * What a sync point's stops come from: entries' entities and archetypes before apply, the entity table's rows, and the + * resource entity's archetype and row, -1 without one. + */ export type StopSource = { readonly entity: Float64Array; readonly from: Int32Array; @@ -136,6 +145,8 @@ export type StopSource = { readonly archetypes: readonly { readonly storageById: readonly (Copied | undefined)[] }[]; readonly row: Uint32Array; readonly holds: Holds; + readonly resourceArchetype: number; + readonly resourceRow: number; }; /** @@ -147,7 +158,7 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) const { staged, memory } = writer; if (staged.length === 0) return; let bytes = blockHeaderBytes; - for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides); + for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides, stops.stringResources.length); const offset = memory.allocate(bytes); const buffer = memory.memory.buffer; const u8 = new Uint8Array(buffer); @@ -163,9 +174,9 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) let group = offset + blockHeaderBytes; const { entity, from, despawned, archetypes, row: rows, holds } = source; for (const stops of staged) { - const { entries, objects, linear, strides, copies } = stops; + const { entries, objects, linear, strides, copies, stringResources } = stops; const count = entries.length; - const size = groupBytes(count, objects.length, strides); + const size = groupBytes(count, objects.length, strides, stringResources.length); u32[(group >> 2) + groupIndexWord] = stops.index; u32[(group >> 2) + groupCountWord] = count; u32[(group >> 2) + groupBytesWord] = size; @@ -229,6 +240,17 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) } column += columnBytes(count, stride); } + // As before apply, as its component strings are: the thread may read them after the resource is written again. + if (stringResources.length > 0) { + const { strings } = writer; + const storageById = source.resourceArchetype < 0 ? undefined : archetypes[source.resourceArchetype].storageById; + for (let k = 0; k < stringResources.length; k++) { + const handles = storageById?.[stringResources[k]]?.views.u32; + const copy = handles === undefined || strings === null ? 0 : strings.copy(handles[source.resourceRow]); + if (copy !== 0) copied.push(copy); + u32[(column >> 2) + k] = copy; + } + } stopped.push([stops.finalizer, count]); group += size; entries.length = 0; diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index e711606f..cf6cf3d4 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -63,8 +63,13 @@ type ThreadFinalizer = { readonly constantLayouts: readonly Layout[]; readonly bind: (archetype: RowArchetype) => Row; readonly context: object; - /** Resolves its resources: its thread's objects as it holds them, any other's cell as of now. */ - readonly cells: () => void; + /** + * Resolves its resources: its thread's objects as it holds them, its strings from the copies a group holds at byte + * `strings`, or with -1 as of now, and any other's cell as of now. + */ + readonly cells: (strings: number) => void; + /** How many string resources it reads, whose copies each group holds after its columns. */ + readonly stringResources: number; readonly body: (row: object, context: object) => void; }; @@ -133,6 +138,16 @@ export const createThreadFinalizers = (host: Threa const resourceStarts = numbers.map(number => mirror.starts.get(number) ?? null); const ownValues: unknown[] = keys.map(() => undefined); const shared = own.includes(false); + // By key: its layout as a string resource, and its slot among a group's copies of them, -1 for any other. + const stringLayouts: (Layout | undefined)[] = []; + const stringSlots: number[] = []; + let stringResources = 0; + for (const key of keys) { + const layout = host.layoutOf(key); + const string = layout?.kind === "string"; + stringLayouts.push(string ? layout : undefined); + stringSlots.push(string ? stringResources++ : -1); + } if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { @@ -156,11 +171,19 @@ export const createThreadFinalizers = (host: Threa excluded: (components.not ?? []).map(host.numberOf), bind: rowBinderOf(host.rows, host.numberOf, reads, []), context: value, - cells: () => { + stringResources, + cells: strings => { const found = shared ? host.resourceRow() : null; for (let k = 0; k < keys.length; k++) { // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); + else if (strings >= 0 && stringSlots[k] >= 0) { + const layout = stringLayouts[k]; + if (layout === undefined) continue; + // As before its stops' sync point applied, from the group's copy of its record. + resolved[k].storage = columnStorage(numbers[k], layout, viewsOf(buffer, strings + 4 * stringSlots[k], 4), [], host.strings); + resolved[k].row = 0; + } else { resolved[k].storage = found?.archetype.storageById[numbers[k]]; resolved[k].row = found?.row ?? 0; @@ -246,7 +269,7 @@ export const createThreadFinalizers = (host: Threa } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); - finalizer.cells(); + finalizer.cells(finalizer.stringResources > 0 ? column : -1); for (let i = 0; i < count; i++) { row.index = i; guarded(index, finalizer, row); @@ -307,7 +330,7 @@ export const createThreadFinalizers = (host: Threa for (let index = 0; index < finalizers.length; index++) { const finalizer = finalizers[index]; if (finalizer === null || finalizer === undefined) continue; - finalizer.cells(); + finalizer.cells(-1); for (let id = 0; id < host.tableCount(); id++) { const archetype = host.archetypeAt(id); if (archetype === undefined || archetype.resource || (archetype.meta && finalizer.required.length === 0)) continue; diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index 6039cd49..ab9ee11b 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -2,6 +2,7 @@ /// import { Component } from "../interface/component/component.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; +import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld, type CreatedWorld } from "../implementation/index.js"; @@ -10,10 +11,13 @@ import { runReported } from "../implementation/world/reports.test-helper.js"; // Despawning entities whose objects a joined thread fills, with a finalizer on that thread: the world's thread writes // each stop's declared struct reads into the thread's stop list and doesn't wait for it. The joined thread is -// `joined.ts`, which imports this module, so it runs the finalizer. +// `joined.ts`, which imports this module, so it runs the finalizers. A second finalizer, over other entities, reads a +// string resource too, which each sync point's stop list holds a copy of. const spot = Symbol("finalized/spot"); const handle = Symbol("finalized/handle"); +const noted = Symbol("finalized/noted"); +const note = Symbol("finalized/note"); declare module "@adobe/ecs" { export interface Threads { finalizedBenchmark: true; @@ -21,11 +25,17 @@ declare module "@adobe/ecs" { export interface Components { [spot]: { type: readonly [number, number, number] }; [handle]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + [noted]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + } + export interface Resources { + [note]: { type: string }; } } Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); /** An object of the joined thread, as a DOM node or a GPU handle is. */ Component.register(handle, { thread: "finalizedBenchmark" }); +Component.register(noted, { thread: "finalizedBenchmark" }); +Resource.register(note, { type: { type: "string", default: "released" } }); /** Entities each timed sync point despawns. */ export const despawnedObjects = 10_000; @@ -37,12 +47,24 @@ export const release = Finalizer.create({ name: "finalized/release", components: if (row[spot][0] >= 0) releases.count++; }); +/** On the joined thread: the same, reading the world's string resource too. */ +export const releaseNoted = Finalizer.create({ name: "finalized/releaseNoted", components: { read: [noted, spot] }, resources: { read: [note] } }, (row, { resources }) => { + row[noted]?.release(); + if (row[spot][0] >= 0 && resources[note].length > 0) releases.count++; +}); + const spawn = System.create({ name: "finalized/spawn" }, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); const despawn = System.create({ name: "finalized/despawn", components: { has: [handle, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const spawnNoted = System.create({ name: "finalized/spawnNoted" }, ({ commands }) => { + for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, noted])({ [spot]: [k, 0, 0], [noted]: null }); +}); +const despawnNoted = System.create({ name: "finalized/despawnNoted", components: { has: [noted, spot] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); const joinedFile = "./joined.js"; @@ -87,15 +109,15 @@ const startClaiming = async (world: CreatedWorld): Promise<{ readonly terminate: * The fastest of `rounds` sync points despawning `despawnedObjects` entities holding the joined thread's objects, * timed on the world's thread, each after a spawn and a pause in which the thread finalizes the last. */ -const despawnObjects = async (rounds = 30): Promise => { +const despawnObjects = async (spawning: typeof spawn = spawn, despawning: typeof despawn = despawn, rounds = 30): Promise => { const world = createWorld({ [workers]: 0 }); const joined = await startClaiming(world); let best = Infinity; try { for (let round = 0; round < rounds; round++) { - await world.run(spawn); + await world.run(spawning); const start = performance.now(); - const report = await runReported(world, despawn); + const report = await runReported(world, despawning); const time = performance.now() - start; if (report.failures.length > 0) throw new Error(`despawn objects: ${report.failures[0].message}`); if (round > 1) best = Math.min(best, time); @@ -120,4 +142,13 @@ export const finalizedBenchmarks: readonly Benchmark[] = [ best: { bytes: 56, basis: "Each despawn frees its 8-byte location and entity, and writes its stop: its entity, archetype, row and tag, 20 bytes, and its 12-byte vec3, read and written." }, run: () => despawnObjects(), }, + { + name: "despawn objects 10K string resource", + operation: "`despawn objects 10K` with the finalizer reading a string resource too, whose record the sync point's stop list holds a copy of, so the thread reads it as it was before apply.", + per: "despawns", + n: despawnedObjects, + typical: [1, 10_000], + best: { bytes: 56, basis: "As `despawn objects 10K`: the resource's copy is once per sync point." }, + run: () => despawnObjects(spawnNoted, despawnNoted), + }, ]; diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index ccecac55..f1dca1d3 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -456,8 +456,9 @@ and is reported once in a sync point's failures. Once the thread is gone, they r - **Stop lists** (`stop-lists.ts`): before apply, the world's thread writes one block per sync point with stops for that thread: per finalizer, in the world's finalizer order, a group of its stops as columns: each entity, its pre-apply archetype and row, the tag of each object it reads, and a copy of each linear struct it reads, in that component's - layout, and of each string's record, by its handle, let go once the thread acknowledged the block: it may finalize - sync points after the row's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are + layout, and of each string's record, by its handle; then, once per group, a copy of the record of each string + resource it reads, as it was before apply. The copies are let go once the thread acknowledged the block: it may + finalize sync points after the row's or resource's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are linked in sync order, the first from the log header's stops word, each block's next offset stored once that block is whole, so a thread reads only whole blocks of published syncs. A block is freed once the thread acknowledged a later one, since the thread reads the next offset from the newest block it ran. The world wakes the thread after each sync @@ -470,7 +471,8 @@ and is reported once in a sync point's failures. Once the thread is gone, they r object resources are read as it holds them: at the resource entity's row in its mirror as replayed so far, which may be behind the entity table, its old rows before a rebuild, and the start where no row holds it, as before its first rebuild. That's what the rebuild keeps: only its systems write those values, and the world gives them only their start, - when they're added or claimed. Other resources are read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + when they're added or claimed. String resources are read from the group's copies, so as before apply; other resources + where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a `"finalizer"` failure. From cf5d74227cbfec91f83a3246e011c6abd0313ff8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:20:33 -0700 Subject: [PATCH 032/127] ecs: joined finalizers reading two or more string resources read them through one view per group A group's copies of string resources are 4-byte handles from an 8-aligned start, and a view built at each odd slot threw from its Float64Array, outside the finalizer's guard, so the thread stopped finalizing. Each group now has one set of views over every handle, a string resource reading its slot as the row; and the resource row is found only for resources read where they are. The tests hold the worker on a shared flag rather than a timed pause. Co-Authored-By: Claude Opus 5.5 --- .../world/remote-finalizers-worker.fixture.ts | 24 +++++++----- .../world/remote-finalizers.cases.ts | 8 +++- .../world/remote-finalizers.fixture.ts | 30 +++++++++++---- .../world/remote-finalizers.test-helper.ts | 16 +++++++- .../implementation/world/thread-finalizers.ts | 38 ++++++++++--------- 5 files changed, 77 insertions(+), 39 deletions(-) diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts index 256b6aaf..cf0d5ade 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -2,11 +2,11 @@ import type { MessagePort as NodeMessagePort } from "node:worker_threads"; import { join } from "./create.js"; import { scopeChannel } from "./port.js"; -import { entitled, headlined, released, type Released } from "./remote-finalizers.test-helper.js"; +import { entitled, headlined, headlinedBoth, released, type Released } from "./remote-finalizers.test-helper.js"; /** * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it - * tells what its finalizers saw when asked, or spins, without yielding, for a while or for good. + * tells what its finalizers saw when asked, blocks until a shared flag is set, or spins, without yielding, for good. */ type Port = MessagePort | NodeMessagePort; @@ -15,13 +15,18 @@ export type ToRemoteFinalizersWorker = | { readonly kind: "released" } | { readonly kind: "entitled" } | { readonly kind: "headlined" } - | { readonly kind: "pause"; readonly milliseconds: number } + | { readonly kind: "hold"; readonly flag: Int32Array } | { readonly kind: "spin" }; export type FromRemoteFinalizersWorker = | { readonly kind: "joined" } | { readonly kind: "released"; readonly released: readonly Released[] } | { readonly kind: "entitled"; readonly entitled: readonly { readonly serial: number; readonly title: string }[] } - | { readonly kind: "headlined"; readonly headlined: readonly { readonly serial: number; readonly heading: string }[] } + | { + readonly kind: "headlined"; + readonly headlined: readonly { readonly serial: number; readonly heading: string }[]; + readonly both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[]; + } + | { readonly kind: "held" } | { readonly kind: "spinning" }; const channel = scopeChannel(); @@ -33,12 +38,11 @@ channel.onMessage(message => { } else if (message.kind === "released") channel.post({ kind: "released", released }); else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); - else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined }); - else if (message.kind === "pause") { - channel.post({ kind: "spinning" }); - // Runs nothing meanwhile, finalizers included, then goes on. - const until = performance.now() + message.milliseconds; - while (performance.now() < until) { /* busy */ } + else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined, both: headlinedBoth }); + else if (message.kind === "hold") { + channel.post({ kind: "held" }); + // Runs nothing, finalizers included, until the flag over shared memory is set. + Atomics.wait(message.flag, 0, 0); } else { channel.post({ kind: "spinning" }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index 0b75c5e2..3febba6b 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -22,8 +22,12 @@ export const remoteFinalizersCases = (load: () => Promise) => de const fixture = await load(); expect(await fixture.titledStops()).toEqual(Array.from({ length: 50 }, (_, k) => ({ serial: k, title: `t${k}` }))); }, 30000); - it("read a string resource as it was before their stops' sync point applied, after the world wrote it again and freed its record", async () => { + it("read one string resource, or two, as they were before their stops' sync point applied, after the world wrote them again and freed their records", async () => { const fixture = await load(); - expect(await fixture.headedStops()).toEqual(Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: k < 10 ? "h0" : "h6" }))); + const heading = (k: number): string => k < 10 ? "h0" : "h6"; + expect(await fixture.headedStops()).toEqual({ + one: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k) })), + two: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k), subheading: `s${heading(k)}` })), + }); }, 30000); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts index c1509ee1..4656b80d 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -66,6 +66,16 @@ const joined = async (world: CreatedWorld): Promise<{ readonly worker: Worker }> return { worker }; }; +/** Holds the worker, which runs nothing until the returned function lets it go on. */ +const hold = async (worker: Worker): Promise<() => void> => { + const flag = new Int32Array(new SharedArrayBuffer(4)); + await worker.ask({ kind: "hold", flag }); + return () => { + Atomics.store(flag, 0, 1); + Atomics.notify(flag, 0); + }; +}; + /** What the worker's finalizer saw. */ const releasedThere = async (worker: Worker): Promise => { const reply = await worker.ask({ kind: "released" }); @@ -118,7 +128,7 @@ export const diesHolding = async () => { }; /** - * Titled rows despawned while the worker runs nothing, then the world's thread rewriting the titles of others six + * Titled rows despawned while the worker is held, then the world's thread rewriting the titles of others six * times, so the records the despawned rows' titles were in are freed and reused before the worker finalizes them: * the titles its finalizer read. */ @@ -126,10 +136,11 @@ export const titledStops = async () => { const world = createWorld({ [workers]: 0 }); const { worker } = await joined(world); await world.run(spawnTitled, { from: 0, count: 50 }); - await worker.ask({ kind: "pause", milliseconds: 300 }); + const release = await hold(worker); await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); await world.run(spawnTitled, { from: 100, count: 50 }); for (let round = 0; round < 6; round++) await world.run(retitle, { round }); + release(); let seen: readonly { readonly serial: number; readonly title: string }[] = []; for (let k = 0; k < 200 && seen.length < 50; k++) { const reply = await worker.ask({ kind: "entitled" }); @@ -141,25 +152,28 @@ export const titledStops = async () => { }; /** - * Headed rows despawned with the heading "h0" while the worker runs nothing, then the world's thread writing the - * heading six times, so the record "h0" was in is freed and reused before the worker finalizes them, and the rest - * despawned: the headings its finalizer read. + * Headed rows despawned with the heading "h0" while the worker is held, then the world's thread writing the heading + * and subheading six times, so the records of "h0" and "sh0" are freed and reused before the worker finalizes them, + * and the rest despawned once it goes on: what its finalizers of one and two string resources read. */ export const headedStops = async () => { const world = createWorld({ [workers]: 0 }); const { worker } = await joined(world); await world.run(rehead, { heading: "h0" }); await world.run(spawnHeaded, { from: 0, count: 20 }); - await worker.ask({ kind: "pause", milliseconds: 300 }); + const release = await hold(worker); await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); for (let round = 1; round <= 6; round++) await world.run(rehead, { heading: `h${round}` }); + release(); await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); let seen: readonly { readonly serial: number; readonly heading: string }[] = []; - for (let k = 0; k < 200 && seen.length < 20; k++) { + let both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; + for (let k = 0; k < 200 && (seen.length < 20 || both.length < 20); k++) { const reply = await worker.ask({ kind: "headlined" }); seen = reply.kind === "headlined" ? reply.headlined : []; + both = reply.kind === "headlined" ? reply.both : []; } await worker.terminate(); await world.dispose(); - return [...seen].sort((a, b) => a.serial - b.serial); + return { one: [...seen].sort((a, b) => a.serial - b.serial), two: [...both].sort((a, b) => a.serial - b.serial) }; }; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 2b9fd0c2..be5bfbd7 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -20,6 +20,7 @@ export const tone = Symbol("remoteFinalizersTest/tone"); export const title = Symbol("remoteFinalizersTest/title"); export const headed = Symbol("remoteFinalizersTest/headed"); export const heading = Symbol("remoteFinalizersTest/heading"); +export const subheading = Symbol("remoteFinalizersTest/subheading"); declare module "@adobe/ecs" { export interface Threads { remoteFinalizersTest: true; @@ -37,6 +38,7 @@ declare module "@adobe/ecs" { export interface Resources { [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; [heading]: { type: string }; + [subheading]: { type: string }; } } /** A value that isn't data, which only its thread holds. */ @@ -54,6 +56,7 @@ Component.register(title, { type: { type: "string" } }); Component.register(headed, { type: { const: true } }); /** A string resource, which the world's thread writes and the finalizer on the thread reads. */ Resource.register(heading, { type: { type: "string", default: "h" } }); +Resource.register(subheading, { type: { type: "string", default: "s" } }); /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; @@ -87,13 +90,22 @@ export const headline = Finalizer.create({ name: "remoteFinalizersTest/headline" headlined.push({ serial: row[serial], heading: resources[heading] }); }); +/** Every headed stop finalized on this thread, in order, with the heading and subheading it read. */ +export const headlinedBoth: { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; + +/** On the finalizers' thread: two string resources as they were before each headed stop's sync point applied. */ +export const headlines = Finalizer.create({ name: "remoteFinalizersTest/headlines", components: { read: [serial, widget], has: [headed] }, resources: { read: [subheading, heading] } }, (row, { resources }) => { + headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); +}); + /** Spawns headed rows from the world's thread, with the thread's widget at null. */ export const spawnHeaded = System.create({ name: "remoteFinalizersTest/spawnHeaded" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); }); -/** From the world's thread: the heading. */ -export const rehead = System.create({ name: "remoteFinalizersTest/rehead", resources: { write: [heading] } }, ({ resources }, input: { readonly heading: string }) => { +/** From the world's thread: the heading and subheading. */ +export const rehead = System.create({ name: "remoteFinalizersTest/rehead", resources: { write: [heading, subheading] } }, ({ resources }, input: { readonly heading: string }) => { resources[heading] = input.heading; + resources[subheading] = `s${input.heading}`; }); /** Spawns titled rows from the world's thread, with the thread's widget at null. */ diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index cf6cf3d4..1be38d3f 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -138,16 +138,18 @@ export const createThreadFinalizers = (host: Threa const resourceStarts = numbers.map(number => mirror.starts.get(number) ?? null); const ownValues: unknown[] = keys.map(() => undefined); const shared = own.includes(false); - // By key: its layout as a string resource, and its slot among a group's copies of them, -1 for any other. - const stringLayouts: (Layout | undefined)[] = []; - const stringSlots: number[] = []; - let stringResources = 0; - for (const key of keys) { + // By slot among a group's copies of string resources: its key's index and layout. + const stringKeys: number[] = []; + const stringLayouts: Layout[] = []; + keys.forEach((key, k) => { const layout = host.layoutOf(key); - const string = layout?.kind === "string"; - stringLayouts.push(string ? layout : undefined); - stringSlots.push(string ? stringResources++ : -1); - } + if (layout?.kind !== "string") return; + stringKeys.push(k); + stringLayouts.push(layout); + }); + const stringResources = stringKeys.length; + // Whether it reads a resource where it is even while a group holds copies of its strings. + const live = keys.some((key, k) => !own[k] && host.layoutOf(key)?.kind !== "string"); if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { @@ -173,22 +175,24 @@ export const createThreadFinalizers = (host: Threa context: value, stringResources, cells: strings => { - const found = shared ? host.resourceRow() : null; + const copied = strings >= 0 && stringResources > 0; + const found = (copied ? live : shared) ? host.resourceRow() : null; for (let k = 0; k < keys.length; k++) { // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); - else if (strings >= 0 && stringSlots[k] >= 0) { - const layout = stringLayouts[k]; - if (layout === undefined) continue; - // As before its stops' sync point applied, from the group's copy of its record. - resolved[k].storage = columnStorage(numbers[k], layout, viewsOf(buffer, strings + 4 * stringSlots[k], 4), [], host.strings); - resolved[k].row = 0; - } else { resolved[k].storage = found?.archetype.storageById[numbers[k]]; resolved[k].row = found?.row ?? 0; } } + if (!copied) return; + // As before its stops' sync point applied, from the group's copies of their records: a handle a slot. + const views = viewsOf(buffer, strings, 4 * stringResources); + for (let slot = 0; slot < stringResources; slot++) { + const k = stringKeys[slot]; + resolved[k].storage = columnStorage(numbers[k], stringLayouts[slot], views, [], host.strings); + resolved[k].row = slot; + } }, body: (row, context) => untyped.body(row, context), }; From cd78b7f0cd2174cef927c1a560294bece42c20a8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:31:02 -0700 Subject: [PATCH 033/127] ecs: joined finalizers read every resource as of before their stops' sync point applied Read-semantics change: a finalizer on a joined thread read its linear (struct) resources where they were when the thread replayed, so after its stops' sync point applied, as documented ("other resources are read where they are"). Each group of stops now holds a copy of each linear resource its finalizer reads, made before apply after the string resources' handles, and the thread reads them there; its thread's objects still come from its mirror and constants need no copy. Finalizers reading no linear resource add no bytes. Adds a test (red before) and the `despawn objects 10K linear resource` benchmark. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 4 +- .../src/implementation/world/finalizers.ts | 16 +++- packages/ecs/src/implementation/world/perf.md | 2 + .../world/remote-finalizers-worker.fixture.ts | 5 +- .../world/remote-finalizers.cases.ts | 5 ++ .../world/remote-finalizers.fixture.ts | 26 +++++- .../world/remote-finalizers.test-helper.ts | 21 +++++ .../src/implementation/world/stop-lists.ts | 61 +++++++++++--- .../implementation/world/thread-finalizers.ts | 82 ++++++++++++------- packages/ecs/src/perftest/finalized.ts | 32 +++++++- packages/ecs/threads.md | 14 ++-- 11 files changed, 212 insertions(+), 56 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index ee8e44b1..930f8fa7 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -28,7 +28,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Structure of Arrays component storage. - Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them. - **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)). - - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads as they were before apply, from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. + - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads, from copies in the stop list, as it reads its linear resources, so every resource as of before its stops' sync point applied, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. - **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there. - Memory only reallocates at sync points. Structure is frozen between sync points, so workers never see memory move mid-phase. - Removed rows stay in place until Apply. At Apply the last row is swap-moved into the hole and the entity lookup table is updated. @@ -196,7 +196,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped. - **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null. - **Finalizers are the only removal mechanism.** `Finalizer.create({ name, components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. - - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct reads copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. + - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct and string reads and its resources copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. - **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched. - **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know. - **Run once, against the final plan.** Their structural commands go to the **next** sync point. Re-planning would let a later round bring back a component an earlier round already finalized, for example a freed handle on an entity that survives. diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts index 0f095f31..b2557fa5 100644 --- a/packages/ecs/src/implementation/world/finalizers.ts +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -16,7 +16,7 @@ import type { Essential, ReadonlyValue } from '../../interface/component/value.j import type { Leaf } from '../../interface/finalizer/created.js'; import { newestKeyOf, threadOf } from '../../interface/registry/index.js'; import type { TaskChannel } from './remote-systems.js'; -import { writeStops, type StopsOf } from './stop-lists.js'; +import { resourceOffsetsOf, writeStops, type StopsOf } from './stop-lists.js'; import { acquireLookups, refreshUses, type IndexLookups } from './indexes.js'; import { indexWatchesOf } from './lookup-keys.js'; @@ -112,14 +112,22 @@ export const placeFinalizer = (state: State, runner: FinalizerRunner, channel: T copies.push(layout.kind === "string"); } } - // In the order the thread's context reads them. + // Its string and linear resources, in the order the thread's context reads them, copied as of before apply. const stringResources: number[] = []; + const linearResources: number[] = []; + const resourceStrides: number[] = []; for (const key of new Set(finalizer.declaration.resources?.read ?? [])) { const id = idOf(state, key); - if (state.layouts[id].kind === "string") stringResources.push(id); + const layout = state.layouts[id]; + if (layout.kind === "string") stringResources.push(id); + else if (layout.kind === "linear") { + linearResources.push(id); + resourceStrides.push(layout.stride); + } } + const resourceOffsets = resourceOffsetsOf(stringResources.length, resourceStrides); const index = channel.placeFinalizer(state, finalizer); - runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, entries: [], channel }; + runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets, entries: [], channel }; }; const matches = (runner: FinalizerRunner, archetype: Archetype): boolean => { diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index ae1be5d5..92b3489b 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -138,6 +138,8 @@ A joined thread's finalizers reading its object resources from its mirror, alter A joined thread's finalizers reading string resources from copies in the stop list, once per group, alternating against `13eb6d6f`, node, six rounds, medians, on a machine busy elsewhere: all within noise. New `despawn objects 10K string resource`, the despawn benchmark's finalizer reading a string resource too, 95.5 ns a despawn against 99.5 reading the live record (the benchmark run on the old build); `despawn objects 10K` 98.0 against 97.2 ns, `string write 10K joined` 129 against 129 ns, `joined observer strings 10K` 478 against 500 µs a tick. Finalizers reading no string resource copy nothing and the group holds no extra bytes. +Their linear resources copied into the stop list too, once per group, alternating against `92b823c9`, node, six rounds, medians, on a machine busy elsewhere: all within noise. New `despawn objects 10K linear resource`, the finalizer reading a number and a vec3 resource too, 99.5 ns a despawn against 99.1 reading them where they are (the benchmark run on the old build); `despawn objects 10K` 100.5 against 101.0 ns, `despawn objects 10K string resource` 98.4 against 99.3 ns, `joined observer 10K` 584 against 567 µs a tick, a path this doesn't touch, its rounds spread 500–754 µs. + Sync points waiting only for tails that read the world's memory, against `c21e8e1e`, node: `tail late snapshot 10K` 474–484 → 9.0–9.5 ns a row copied, against 8.9 for `e3c30aa1` measured beside it (its record 8.2), and in chromium 450 → 6.0 ns (its record 5.0, one or two 5 µs timer steps); within noise in both: `tail prompt`, `tail overlap`, `tail overlap waiting`. Since every run spawns its report, until 256 are, almost every sync point has something to apply, and each one settled every tail, so the run waited out the whole body of the benchmark's 4 ms tail ahead of the late one, whose head it had claimed. - **A tail reading only its own thread's objects isn't settled at a sync point,** having nothing there a sync point writes: 474 → 12.8 ns. - **The benchmark times the run alone,** reading its report after: 12.8 → 9.0 ns. Reading it through `runReported` timed a second run as well. diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts index cf0d5ade..e2dc3adc 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -2,7 +2,7 @@ import type { MessagePort as NodeMessagePort } from "node:worker_threads"; import { join } from "./create.js"; import { scopeChannel } from "./port.js"; -import { entitled, headlined, headlinedBoth, released, type Released } from "./remote-finalizers.test-helper.js"; +import { entitled, gauged, headlined, headlinedBoth, released, type Released } from "./remote-finalizers.test-helper.js"; /** * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it @@ -15,6 +15,7 @@ export type ToRemoteFinalizersWorker = | { readonly kind: "released" } | { readonly kind: "entitled" } | { readonly kind: "headlined" } + | { readonly kind: "gauged" } | { readonly kind: "hold"; readonly flag: Int32Array } | { readonly kind: "spin" }; export type FromRemoteFinalizersWorker = @@ -26,6 +27,7 @@ export type FromRemoteFinalizersWorker = readonly headlined: readonly { readonly serial: number; readonly heading: string }[]; readonly both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[]; } + | { readonly kind: "gauged"; readonly gauged: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] } | { readonly kind: "held" } | { readonly kind: "spinning" }; @@ -38,6 +40,7 @@ channel.onMessage(message => { } else if (message.kind === "released") channel.post({ kind: "released", released }); else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); + else if (message.kind === "gauged") channel.post({ kind: "gauged", gauged }); else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined, both: headlinedBoth }); else if (message.kind === "hold") { channel.post({ kind: "held" }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index 3febba6b..ef41cfdb 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -30,4 +30,9 @@ export const remoteFinalizersCases = (load: () => Promise) => de two: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k), subheading: `s${heading(k)}` })), }); }, 30000); + it("read a number and a struct resource as they were before their stops' sync point applied, after the world wrote them again", async () => { + const fixture = await load(); + const level = (k: number): number => k < 10 ? 0 : 6; + expect(await fixture.gaugedStops()).toEqual(Array.from({ length: 20 }, (_, k) => ({ serial: k, level: level(k), origin: [level(k), level(k) + 10] }))); + }, 30000); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts index 4656b80d..55aff3d9 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -6,7 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { indexOf } from "./entities.js"; import type { FromRemoteFinalizersWorker, ToRemoteFinalizersWorker } from "./remote-finalizers-worker.fixture.js"; -import { despawn, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; +import { despawn, regauge, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; import { fillReports, runReported } from "./reports.test-helper.js"; /** @@ -177,3 +177,27 @@ export const headedStops = async () => { await world.dispose(); return { one: [...seen].sort((a, b) => a.serial - b.serial), two: [...both].sort((a, b) => a.serial - b.serial) }; }; + +/** + * Headed rows despawned at level 0 while the worker is held, then the world's thread writing the level and origin six + * times, and the rest despawned once it goes on: the linear resources its finalizer read. + */ +export const gaugedStops = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + await world.run(regauge, { level: 0 }); + await world.run(spawnHeaded, { from: 0, count: 20 }); + const release = await hold(worker); + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(regauge, { level: round }); + release(); + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); + let seen: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; + for (let k = 0; k < 200 && seen.length < 20; k++) { + const reply = await worker.ask({ kind: "gauged" }); + seen = reply.kind === "gauged" ? reply.gauged : []; + } + await worker.terminate(); + await world.dispose(); + return [...seen].sort((a, b) => a.serial - b.serial); +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index be5bfbd7..e832a719 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -21,6 +21,8 @@ export const title = Symbol("remoteFinalizersTest/title"); export const headed = Symbol("remoteFinalizersTest/headed"); export const heading = Symbol("remoteFinalizersTest/heading"); export const subheading = Symbol("remoteFinalizersTest/subheading"); +export const level = Symbol("remoteFinalizersTest/level"); +export const origin = Symbol("remoteFinalizersTest/origin"); declare module "@adobe/ecs" { export interface Threads { remoteFinalizersTest: true; @@ -39,6 +41,8 @@ declare module "@adobe/ecs" { [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; [heading]: { type: string }; [subheading]: { type: string }; + [level]: { type: number }; + [origin]: { type: readonly [number, number] }; } } /** A value that isn't data, which only its thread holds. */ @@ -57,6 +61,9 @@ Component.register(headed, { type: { const: true } }); /** A string resource, which the world's thread writes and the finalizer on the thread reads. */ Resource.register(heading, { type: { type: "string", default: "h" } }); Resource.register(subheading, { type: { type: "string", default: "s" } }); +/** Linear resources, a number and a struct, which the world's thread writes and the finalizer on the thread reads. */ +Resource.register(level, { type: { type: "number", default: -1 } }); +Resource.register(origin, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, default: [-1, -1] } }); /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; @@ -98,6 +105,20 @@ export const headlines = Finalizer.create({ name: "remoteFinalizersTest/headline headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); }); +/** Every headed stop finalized on this thread, in order, with the linear resources it read. */ +export const gauged: { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; + +/** On the finalizers' thread: a number and a struct resource as they were before each headed stop's sync point applied. */ +export const gauge = Finalizer.create({ name: "remoteFinalizersTest/gauge", components: { read: [serial, widget], has: [headed] }, resources: { read: [origin, level] } }, (row, { resources }) => { + gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); +}); + +/** From the world's thread: the level, and the origin at it. */ +export const regauge = System.create({ name: "remoteFinalizersTest/regauge", resources: { write: [level, origin] } }, ({ resources }, input: { readonly level: number }) => { + resources[level] = input.level; + resources[origin] = [input.level, input.level + 10]; +}); + /** Spawns headed rows from the world's thread, with the thread's widget at null. */ export const spawnHeaded = System.create({ name: "remoteFinalizersTest/spawnHeaded" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); diff --git a/packages/ecs/src/implementation/world/stop-lists.ts b/packages/ecs/src/implementation/world/stop-lists.ts index 0820fe96..025c58e9 100644 --- a/packages/ecs/src/implementation/world/stop-lists.ts +++ b/packages/ecs/src/implementation/world/stop-lists.ts @@ -12,8 +12,9 @@ import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; // its bytes. Then a group per finalizer with stops, in the world's finalizer order: its index on the thread, its stops, // its bytes; then as columns, each on 8 bytes, every stop's entity (f64), its archetype and row before apply (u32), the // tag of each object it reads (u32), and the value of each linear struct it reads in that component's layout, or for a -// string, the handle of a copy of its record; then the handle of a copy of each string resource it reads (u32). Copies -// are freed with the block, since the thread may read them sync points later. +// string, the handle of a copy of its record; then its resources as they were before apply: the handle of a copy of +// each string resource it reads (u32), then each linear resource it reads in its layout, each on 8 bytes. Copies of +// records are freed with the block, since the thread may read them sync points later. export const blockSyncWord = 0; export const blockNextWord = 1; @@ -38,14 +39,29 @@ export const valuesAt = (group: number, count: number, objects: number): number /** Bytes of one linear read's column of `count` values of `stride` bytes. */ export const columnBytes = (count: number, stride: number): number => align(count * stride); +/** + * Where each linear resource's copy starts past a group's columns, after the handles of `strings` string resources, + * for linear resources of `strides` bytes; then the bytes of them all. + */ +export const resourceOffsetsOf = (strings: number, strides: readonly number[]): number[] => { + const offsets: number[] = []; + let at = align(4 * strings); + for (const stride of strides) { + offsets.push(at); + at += align(stride); + } + offsets.push(at); + return offsets; +}; + /** * Bytes of a group of `count` stops of a finalizer reading `objects` objects, linear values of `strides` bytes and - * `strings` string resources. + * `resources` bytes of resources. */ -const groupBytes = (count: number, objects: number, strides: readonly number[], strings: number): number => { +const groupBytes = (count: number, objects: number, strides: readonly number[], resources: number): number => { let bytes = valuesAt(0, count, objects); for (const stride of strides) bytes += columnBytes(count, stride); - return bytes + align(4 * strings); + return bytes + resources; }; /** One of a joined thread's finalizers, as the world writes its stops. */ @@ -59,8 +75,14 @@ export type StopsOf = { readonly strides: readonly number[]; /** By linear read: whether it's a string's handles, whose records the block holds copies of. */ readonly copies: readonly boolean[]; - /** The string resources it reads, in its context's order, whose records each group holds copies of. */ + /** + * The string resources it reads, then its linear resources with their strides, in its context's order, which each + * group holds copies of; and where each linear one's copy starts past the group's columns, then their bytes. + */ readonly stringResources: readonly number[]; + readonly linearResources: readonly number[]; + readonly resourceStrides: readonly number[]; + readonly resourceOffsets: readonly number[]; /** This sync point's stops, as plan entries, reused by every sync point. */ readonly entries: number[]; }; @@ -158,7 +180,7 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) const { staged, memory } = writer; if (staged.length === 0) return; let bytes = blockHeaderBytes; - for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides, stops.stringResources.length); + for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides, stops.resourceOffsets[stops.linearResources.length]); const offset = memory.allocate(bytes); const buffer = memory.memory.buffer; const u8 = new Uint8Array(buffer); @@ -174,9 +196,9 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) let group = offset + blockHeaderBytes; const { entity, from, despawned, archetypes, row: rows, holds } = source; for (const stops of staged) { - const { entries, objects, linear, strides, copies, stringResources } = stops; + const { entries, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets } = stops; const count = entries.length; - const size = groupBytes(count, objects.length, strides, stringResources.length); + const size = groupBytes(count, objects.length, strides, resourceOffsets[linearResources.length]); u32[(group >> 2) + groupIndexWord] = stops.index; u32[(group >> 2) + groupCountWord] = count; u32[(group >> 2) + groupBytesWord] = size; @@ -240,16 +262,31 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) } column += columnBytes(count, stride); } - // As before apply, as its component strings are: the thread may read them after the resource is written again. - if (stringResources.length > 0) { + // As before apply, as its components are: the thread may read them after the resources are written again. + if (stringResources.length > 0 || linearResources.length > 0) { const { strings } = writer; + const { resourceRow } = source; const storageById = source.resourceArchetype < 0 ? undefined : archetypes[source.resourceArchetype].storageById; for (let k = 0; k < stringResources.length; k++) { const handles = storageById?.[stringResources[k]]?.views.u32; - const copy = handles === undefined || strings === null ? 0 : strings.copy(handles[source.resourceRow]); + const copy = handles === undefined || strings === null ? 0 : strings.copy(handles[resourceRow]); if (copy !== 0) copied.push(copy); u32[(column >> 2) + k] = copy; } + for (let k = 0; k < linearResources.length; k++) { + const values = storageById?.[linearResources[k]]?.views; + const stride = resourceStrides[k]; + const target = column + resourceOffsets[k]; + if (values === undefined) { + u8.fill(0, target, target + stride); + continue; + } + if ((stride & 3) === 0) { + const words = stride >> 2; + for (let w = 0; w < words; w++) u32[(target >> 2) + w] = values.u32[resourceRow * words + w]; + } + else for (let b = 0; b < stride; b++) u8[target + b] = values.u8[resourceRow * stride + b]; + } } stopped.push([stops.finalizer, count]); group += size; diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index 1be38d3f..94ff200c 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -11,7 +11,7 @@ import type { WorkerError } from './protocol.js'; import { rowBinderOf, type Row, type RowArchetype, type RowHost, type RowStorage } from './rows.js'; import { archetypesAt, blockGroupsWord, blockHeaderBytes, blockNextWord, blockSyncWord, columnBytes, entitiesAt, groupBytesWord, groupCountWord, - groupIndexWord, rowsAt, tagsAt, valuesAt, + groupIndexWord, resourceOffsetsOf, rowsAt, tagsAt, valuesAt, } from './stop-lists.js'; import type { Key } from './state.js'; import type { StringStore } from './strings.js'; @@ -64,12 +64,10 @@ type ThreadFinalizer = { readonly bind: (archetype: RowArchetype) => Row; readonly context: object; /** - * Resolves its resources: its thread's objects as it holds them, its strings from the copies a group holds at byte - * `strings`, or with -1 as of now, and any other's cell as of now. + * Resolves its resources: its thread's objects as it holds them, and the rest from the copies a group holds from + * byte `copies`, as before its stops' sync point applied, or with -1, once the world is disposed, where they are. */ - readonly cells: (strings: number) => void; - /** How many string resources it reads, whose copies each group holds after its columns. */ - readonly stringResources: number; + readonly cells: (copies: number) => void; readonly body: (row: object, context: object) => void; }; @@ -138,18 +136,33 @@ export const createThreadFinalizers = (host: Threa const resourceStarts = numbers.map(number => mirror.starts.get(number) ?? null); const ownValues: unknown[] = keys.map(() => undefined); const shared = own.includes(false); - // By slot among a group's copies of string resources: its key's index and layout. + // The world copies the rest for each group, in this order: by slot, its strings' handles, then its linear + // values, each with its key's index and layout. A constant needs no copy. const stringKeys: number[] = []; const stringLayouts: Layout[] = []; + const linearKeys: number[] = []; + const linearLayouts: Layout[] = []; + const linearStrides: number[] = []; + const constantKeys: number[] = []; + const constantStorages: FinalizedStorage[] = []; keys.forEach((key, k) => { const layout = host.layoutOf(key); - if (layout?.kind !== "string") return; - stringKeys.push(k); - stringLayouts.push(layout); + if (layout === undefined || layout.kind === "object") return; + if (layout.kind === "string") { + stringKeys.push(k); + stringLayouts.push(layout); + } + else if (layout.kind === "linear") { + linearKeys.push(k); + linearLayouts.push(layout); + linearStrides.push(layout.stride); + } + else { + constantKeys.push(k); + constantStorages.push(columnStorage(numbers[k], layout, emptyViews, [], null)); + } }); - const stringResources = stringKeys.length; - // Whether it reads a resource where it is even while a group holds copies of its strings. - const live = keys.some((key, k) => !own[k] && host.layoutOf(key)?.kind !== "string"); + const resourceOffsets = resourceOffsetsOf(stringKeys.length, linearStrides); if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { @@ -173,25 +186,36 @@ export const createThreadFinalizers = (host: Threa excluded: (components.not ?? []).map(host.numberOf), bind: rowBinderOf(host.rows, host.numberOf, reads, []), context: value, - stringResources, - cells: strings => { - const copied = strings >= 0 && stringResources > 0; - const found = (copied ? live : shared) ? host.resourceRow() : null; - for (let k = 0; k < keys.length; k++) { - // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. - if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); - else { + cells: copies => { + // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. + for (let k = 0; k < keys.length; k++) if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); + if (!shared) return; + if (copies < 0) { + const found = host.resourceRow(); + for (let k = 0; k < keys.length; k++) { + if (own[k]) continue; resolved[k].storage = found?.archetype.storageById[numbers[k]]; resolved[k].row = found?.row ?? 0; } + return; + } + if (stringKeys.length > 0) { + // One view over every handle, a slot each. + const views = viewsOf(buffer, copies, 4 * stringKeys.length); + for (let slot = 0; slot < stringKeys.length; slot++) { + const k = stringKeys[slot]; + resolved[k].storage = columnStorage(numbers[k], stringLayouts[slot], views, [], host.strings); + resolved[k].row = slot; + } + } + for (let slot = 0; slot < linearKeys.length; slot++) { + const k = linearKeys[slot]; + resolved[k].storage = columnStorage(numbers[k], linearLayouts[slot], viewsOf(buffer, copies + resourceOffsets[slot], linearStrides[slot]), [], null); + resolved[k].row = 0; } - if (!copied) return; - // As before its stops' sync point applied, from the group's copies of their records: a handle a slot. - const views = viewsOf(buffer, strings, 4 * stringResources); - for (let slot = 0; slot < stringResources; slot++) { - const k = stringKeys[slot]; - resolved[k].storage = columnStorage(numbers[k], stringLayouts[slot], views, [], host.strings); - resolved[k].row = slot; + for (let slot = 0; slot < constantKeys.length; slot++) { + resolved[constantKeys[slot]].storage = constantStorages[slot]; + resolved[constantKeys[slot]].row = 0; } }, body: (row, context) => untyped.body(row, context), @@ -273,7 +297,7 @@ export const createThreadFinalizers = (host: Threa } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); - finalizer.cells(finalizer.stringResources > 0 ? column : -1); + finalizer.cells(column); for (let i = 0; i < count; i++) { row.index = i; guarded(index, finalizer, row); diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index ab9ee11b..503a83b5 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -12,12 +12,15 @@ import { runReported } from "../implementation/world/reports.test-helper.js"; // Despawning entities whose objects a joined thread fills, with a finalizer on that thread: the world's thread writes // each stop's declared struct reads into the thread's stop list and doesn't wait for it. The joined thread is // `joined.ts`, which imports this module, so it runs the finalizers. A second finalizer, over other entities, reads a -// string resource too, which each sync point's stop list holds a copy of. +// string resource too, and a third two linear resources, which each sync point's stop list holds copies of. const spot = Symbol("finalized/spot"); const handle = Symbol("finalized/handle"); const noted = Symbol("finalized/noted"); const note = Symbol("finalized/note"); +const measured = Symbol("finalized/measured"); +const scale = Symbol("finalized/scale"); +const offset = Symbol("finalized/offset"); declare module "@adobe/ecs" { export interface Threads { finalizedBenchmark: true; @@ -26,9 +29,12 @@ declare module "@adobe/ecs" { [spot]: { type: readonly [number, number, number] }; [handle]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; [noted]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + [measured]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; } export interface Resources { [note]: { type: string }; + [scale]: { type: number }; + [offset]: { type: readonly [number, number, number] }; } } Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); @@ -36,6 +42,9 @@ Component.register(spot, { type: { type: "array", items: { type: "number", preci Component.register(handle, { thread: "finalizedBenchmark" }); Component.register(noted, { thread: "finalizedBenchmark" }); Resource.register(note, { type: { type: "string", default: "released" } }); +Component.register(measured, { thread: "finalizedBenchmark" }); +Resource.register(scale, { type: { type: "number", default: 1 } }); +Resource.register(offset, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3, default: [0, 0, 0] } }); /** Entities each timed sync point despawns. */ export const despawnedObjects = 10_000; @@ -53,6 +62,12 @@ export const releaseNoted = Finalizer.create({ name: "finalized/releaseNoted", c if (row[spot][0] >= 0 && resources[note].length > 0) releases.count++; }); +/** On the joined thread: the same, reading two linear resources of the world's too. */ +export const releaseMeasured = Finalizer.create({ name: "finalized/releaseMeasured", components: { read: [measured, spot] }, resources: { read: [scale, offset] } }, (row, { resources }) => { + row[measured]?.release(); + if (row[spot][0] * resources[scale] + resources[offset][0] >= 0) releases.count++; +}); + const spawn = System.create({ name: "finalized/spawn" }, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); @@ -62,6 +77,12 @@ const despawn = System.create({ name: "finalized/despawn", components: { has: [h const spawnNoted = System.create({ name: "finalized/spawnNoted" }, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, noted])({ [spot]: [k, 0, 0], [noted]: null }); }); +const spawnMeasured = System.create({ name: "finalized/spawnMeasured" }, ({ commands }) => { + for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, measured])({ [spot]: [k, 0, 0], [measured]: null }); +}); +const despawnMeasured = System.create({ name: "finalized/despawnMeasured", components: { has: [measured, spot] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); const despawnNoted = System.create({ name: "finalized/despawnNoted", components: { has: [noted, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); @@ -151,4 +172,13 @@ export const finalizedBenchmarks: readonly Benchmark[] = [ best: { bytes: 56, basis: "As `despawn objects 10K`: the resource's copy is once per sync point." }, run: () => despawnObjects(spawnNoted, despawnNoted), }, + { + name: "despawn objects 10K linear resource", + operation: "`despawn objects 10K` with the finalizer reading a number and a vec3 resource too, which the sync point's stop list holds copies of, so the thread reads them as they were before apply.", + per: "despawns", + n: despawnedObjects, + typical: [1, 10_000], + best: { bytes: 56, basis: "As `despawn objects 10K`: the resources' copies are once per sync point." }, + run: () => despawnObjects(spawnMeasured, despawnMeasured), + }, ]; diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index f1dca1d3..8e62fb18 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -456,9 +456,11 @@ and is reported once in a sync point's failures. Once the thread is gone, they r - **Stop lists** (`stop-lists.ts`): before apply, the world's thread writes one block per sync point with stops for that thread: per finalizer, in the world's finalizer order, a group of its stops as columns: each entity, its pre-apply archetype and row, the tag of each object it reads, and a copy of each linear struct it reads, in that component's - layout, and of each string's record, by its handle; then, once per group, a copy of the record of each string - resource it reads, as it was before apply. The copies are let go once the thread acknowledged the block: it may - finalize sync points after the row's or resource's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are + layout, and of each string's record, by its handle; then, once per group, the resources it reads that aren't its + thread's objects, as they were before apply: a copy of each string resource's record, by its handle, then each linear + resource's value in its layout, from the resource entity's row; a constant needs none. A finalizer reading no such + resource adds no bytes. The records' copies are let go once the thread acknowledged the block: it may finalize sync + points after the row's or resource's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are linked in sync order, the first from the log header's stops word, each block's next offset stored once that block is whole, so a thread reads only whole blocks of published syncs. A block is freed once the thread acknowledged a later one, since the thread reads the next offset from the newest block it ran. The world wakes the thread after each sync @@ -471,14 +473,14 @@ and is reported once in a sync point's failures. Once the thread is gone, they r object resources are read as it holds them: at the resource entity's row in its mirror as replayed so far, which may be behind the entity table, its old rows before a rebuild, and the start where no row holds it, as before its first rebuild. That's what the rebuild keeps: only its systems write those values, and the world gives them only their start, - when they're added or claimed. String resources are read from the group's copies, so as before apply; other resources - where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + when they're added or claimed. Its string and linear resources are read from the group's copies, so every resource + it reads is as of before its stops' sync point applied. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a `"finalizer"` failure. - **The guarantee:** each such finalizer runs exactly once per stop, on its thread, before that thread's next task or observer post after the sync point and before the entity's index is reused, seeing its declared struct and string - reads and its thread's objects as of before apply. + reads, its thread's objects and every resource it reads as of before apply. - **Held indexes:** the index of an entity despawned with such stops is held, counted by index for each thread that hasn't acknowledged it, apart from `released`, so the leak scan never sees it and the free list never has it; the sync point after a thread acknowledges releases it once no thread holds it. A thread that's gone has its held indexes From dc99f024b2051349bed3d732b9586f1c27f772ec Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:44:14 -0700 Subject: [PATCH 034/127] ecs: a joined finalizer's missing resource copies its start; tests of resources of every width A stop list without a resource the finalizer reads, unreachable while finalizers activate only once their resources exist, now holds its start, written through its layout or as a new record, rather than zeros or null. The resource tests cover a string, a boolean, an f32 vec3, a padded CPU struct and a storage struct beside one another, and release the held worker in a finally. Co-Authored-By: Claude Opus 5.5 --- .../src/implementation/world/finalizers.ts | 13 +++- .../world/remote-finalizers-worker.fixture.ts | 10 ++- .../world/remote-finalizers.cases.ts | 8 ++- .../world/remote-finalizers.fixture.ts | 42 ++++++++---- .../world/remote-finalizers.test-helper.ts | 65 ++++++++++++++++++- .../src/implementation/world/stop-lists.ts | 13 +++- 6 files changed, 126 insertions(+), 25 deletions(-) diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts index b2557fa5..19a4e5ae 100644 --- a/packages/ecs/src/implementation/world/finalizers.ts +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -15,6 +15,7 @@ import type { Queued } from './synchronize.js'; import type { Essential, ReadonlyValue } from '../../interface/component/value.js'; import type { Leaf } from '../../interface/finalizer/created.js'; import { newestKeyOf, threadOf } from '../../interface/registry/index.js'; +import type { Layout } from '../../interface/registry/layout.js'; import type { TaskChannel } from './remote-systems.js'; import { resourceOffsetsOf, writeStops, type StopsOf } from './stop-lists.js'; import { acquireLookups, refreshUses, type IndexLookups } from './indexes.js'; @@ -116,18 +117,26 @@ export const placeFinalizer = (state: State, runner: FinalizerRunner, channel: T const stringResources: number[] = []; const linearResources: number[] = []; const resourceStrides: number[] = []; + const stringStarts: unknown[] = []; + const linearStarts: unknown[] = []; + const resourceLayouts: Layout[] = []; for (const key of new Set(finalizer.declaration.resources?.read ?? [])) { const id = idOf(state, key); const layout = state.layouts[id]; - if (layout.kind === "string") stringResources.push(id); + if (layout.kind === "string") { + stringResources.push(id); + stringStarts.push(state.starts[id]); + } else if (layout.kind === "linear") { linearResources.push(id); resourceStrides.push(layout.stride); + linearStarts.push(state.starts[id]); + resourceLayouts.push(layout); } } const resourceOffsets = resourceOffsetsOf(stringResources.length, resourceStrides); const index = channel.placeFinalizer(state, finalizer); - runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets, entries: [], channel }; + runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets, stringStarts, linearStarts, resourceLayouts, entries: [], channel }; }; const matches = (runner: FinalizerRunner, archetype: Archetype): boolean => { diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts index e2dc3adc..2e42569c 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -2,7 +2,7 @@ import type { MessagePort as NodeMessagePort } from "node:worker_threads"; import { join } from "./create.js"; import { scopeChannel } from "./port.js"; -import { entitled, gauged, headlined, headlinedBoth, released, type Released } from "./remote-finalizers.test-helper.js"; +import { entitled, gauged, headlined, headlinedBoth, released, surveyed, type Released, type Surveyed } from "./remote-finalizers.test-helper.js"; /** * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it @@ -27,7 +27,11 @@ export type FromRemoteFinalizersWorker = readonly headlined: readonly { readonly serial: number; readonly heading: string }[]; readonly both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[]; } - | { readonly kind: "gauged"; readonly gauged: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] } + | { + readonly kind: "gauged"; + readonly gauged: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[]; + readonly surveyed: readonly Surveyed[]; + } | { readonly kind: "held" } | { readonly kind: "spinning" }; @@ -40,7 +44,7 @@ channel.onMessage(message => { } else if (message.kind === "released") channel.post({ kind: "released", released }); else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); - else if (message.kind === "gauged") channel.post({ kind: "gauged", gauged }); + else if (message.kind === "gauged") channel.post({ kind: "gauged", gauged, surveyed }); else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined, both: headlinedBoth }); else if (message.kind === "hold") { channel.post({ kind: "held" }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index ef41cfdb..dd050ba8 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import type * as Fixture from "./remote-finalizers.fixture.js"; +import { surveyOf } from "./remote-finalizers.test-helper.js"; /** Finalizers on a joined worker: the cases, over the fixture built for node or from source. */ export const remoteFinalizersCases = (load: () => Promise) => describe("finalizers on a joined worker", () => { @@ -30,9 +31,12 @@ export const remoteFinalizersCases = (load: () => Promise) => de two: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k), subheading: `s${heading(k)}` })), }); }, 30000); - it("read a number and a struct resource as they were before their stops' sync point applied, after the world wrote them again", async () => { + it("read linear resources of every width, and strings beside them, as they were before their stops' sync point applied, after the world wrote them again", async () => { const fixture = await load(); const level = (k: number): number => k < 10 ? 0 : 6; - expect(await fixture.gaugedStops()).toEqual(Array.from({ length: 20 }, (_, k) => ({ serial: k, level: level(k), origin: [level(k), level(k) + 10] }))); + expect(await fixture.gaugedStops()).toEqual({ + linear: Array.from({ length: 20 }, (_, k) => ({ serial: k, level: level(k), origin: [level(k), level(k) + 10] })), + mixed: Array.from({ length: 20 }, (_, k) => ({ serial: k, ...surveyOf(level(k)) })), + }); }, 30000); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts index 55aff3d9..bc9c2773 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -6,7 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { indexOf } from "./entities.js"; import type { FromRemoteFinalizersWorker, ToRemoteFinalizersWorker } from "./remote-finalizers-worker.fixture.js"; -import { despawn, regauge, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; +import { despawn, regauge, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released, type Surveyed } from "./remote-finalizers.test-helper.js"; import { fillReports, runReported } from "./reports.test-helper.js"; /** @@ -137,10 +137,14 @@ export const titledStops = async () => { const { worker } = await joined(world); await world.run(spawnTitled, { from: 0, count: 50 }); const release = await hold(worker); - await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); - await world.run(spawnTitled, { from: 100, count: 50 }); - for (let round = 0; round < 6; round++) await world.run(retitle, { round }); - release(); + try { + await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); + await world.run(spawnTitled, { from: 100, count: 50 }); + for (let round = 0; round < 6; round++) await world.run(retitle, { round }); + } + finally { + release(); + } let seen: readonly { readonly serial: number; readonly title: string }[] = []; for (let k = 0; k < 200 && seen.length < 50; k++) { const reply = await worker.ask({ kind: "entitled" }); @@ -162,9 +166,13 @@ export const headedStops = async () => { await world.run(rehead, { heading: "h0" }); await world.run(spawnHeaded, { from: 0, count: 20 }); const release = await hold(worker); - await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); - for (let round = 1; round <= 6; round++) await world.run(rehead, { heading: `h${round}` }); - release(); + try { + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(rehead, { heading: `h${round}` }); + } + finally { + release(); + } await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); let seen: readonly { readonly serial: number; readonly heading: string }[] = []; let both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; @@ -180,7 +188,7 @@ export const headedStops = async () => { /** * Headed rows despawned at level 0 while the worker is held, then the world's thread writing the level and origin six - * times, and the rest despawned once it goes on: the linear resources its finalizer read. + * times, with resources of mixed widths, and the rest despawned once it goes on: what its finalizers read. */ export const gaugedStops = async () => { const world = createWorld({ [workers]: 0 }); @@ -188,16 +196,22 @@ export const gaugedStops = async () => { await world.run(regauge, { level: 0 }); await world.run(spawnHeaded, { from: 0, count: 20 }); const release = await hold(worker); - await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); - for (let round = 1; round <= 6; round++) await world.run(regauge, { level: round }); - release(); + try { + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(regauge, { level: round }); + } + finally { + release(); + } await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); let seen: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; - for (let k = 0; k < 200 && seen.length < 20; k++) { + let mixed: readonly Surveyed[] = []; + for (let k = 0; k < 200 && (seen.length < 20 || mixed.length < 20); k++) { const reply = await worker.ask({ kind: "gauged" }); seen = reply.kind === "gauged" ? reply.gauged : []; + mixed = reply.kind === "gauged" ? reply.surveyed : []; } await worker.terminate(); await world.dispose(); - return [...seen].sort((a, b) => a.serial - b.serial); + return { linear: [...seen].sort((a, b) => a.serial - b.serial), mixed: [...mixed].sort((a, b) => a.serial - b.serial) }; }; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index e832a719..6d473b38 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -23,6 +23,11 @@ export const heading = Symbol("remoteFinalizersTest/heading"); export const subheading = Symbol("remoteFinalizersTest/subheading"); export const level = Symbol("remoteFinalizersTest/level"); export const origin = Symbol("remoteFinalizersTest/origin"); +export const caption = Symbol("remoteFinalizersTest/caption"); +export const lit = Symbol("remoteFinalizersTest/lit"); +export const tint = Symbol("remoteFinalizersTest/tint"); +export const sample = Symbol("remoteFinalizersTest/sample"); +export const material = Symbol("remoteFinalizersTest/material"); declare module "@adobe/ecs" { export interface Threads { remoteFinalizersTest: true; @@ -43,6 +48,11 @@ declare module "@adobe/ecs" { [subheading]: { type: string }; [level]: { type: number }; [origin]: { type: readonly [number, number] }; + [caption]: { type: string }; + [lit]: { type: boolean }; + [tint]: { type: readonly [number, number, number] }; + [sample]: { type: Sample }; + [material]: { type: Material }; } } /** A value that isn't data, which only its thread holds. */ @@ -64,6 +74,31 @@ Resource.register(subheading, { type: { type: "string", default: "s" } }); /** Linear resources, a number and a struct, which the world's thread writes and the finalizer on the thread reads. */ Resource.register(level, { type: { type: "number", default: -1 } }); Resource.register(origin, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, default: [-1, -1] } }); +/** Resources of mixed widths: a string, a boolean (1 byte), an f32 vec3 (12), a padded CPU struct (24) and a storage struct (32). */ +type Sample = { readonly on: boolean; readonly count: number; readonly weight: number; readonly ratio: number }; +type Material = { readonly alpha: number; readonly color: readonly [number, number, number]; readonly glossy: boolean }; +const f32 = { type: "number", precision: 1 } as const; +const vec3 = { type: "array", items: f32, minItems: 3, maxItems: 3 } as const; +Resource.register(caption, { type: { type: "string", default: "c" } }); +Resource.register(lit, { type: { type: "boolean", default: false } }); +Resource.register(tint, { type: { ...vec3, default: [-1, -1, -1] } }); +Resource.register(sample, { + type: { + type: "object", + properties: { on: { type: "boolean" }, count: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF }, weight: { type: "number" }, ratio: f32 }, + required: ["on", "count", "weight", "ratio"], + default: { on: false, count: 0, weight: -1, ratio: -1 }, + }, +}); +Resource.register(material, { + type: { + type: "object", + properties: { alpha: f32, color: vec3, glossy: { type: "boolean" } }, + required: ["alpha", "color", "glossy"], + layout: "storage", + default: { alpha: -1, color: [-1, -1, -1], glossy: false }, + }, +}); /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; @@ -113,10 +148,36 @@ export const gauge = Finalizer.create({ name: "remoteFinalizersTest/gauge", comp gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); }); -/** From the world's thread: the level, and the origin at it. */ -export const regauge = System.create({ name: "remoteFinalizersTest/regauge", resources: { write: [level, origin] } }, ({ resources }, input: { readonly level: number }) => { +/** Every headed stop finalized on this thread, in order, with the resources of mixed widths it read. */ +export type Surveyed = { readonly serial: number; readonly caption: string; readonly lit: boolean; readonly tint: readonly number[]; readonly sample: Sample; readonly material: Material }; +export const surveyed: Surveyed[] = []; + +/** On the finalizers' thread: resources of mixed widths as they were before each headed stop's sync point applied. */ +export const survey = Finalizer.create({ name: "remoteFinalizersTest/survey", components: { read: [serial, widget], has: [headed] }, resources: { read: [material, caption, sample, lit, tint] } }, (row, { resources }) => { + const { alpha, color, glossy } = resources[material]; + surveyed.push({ + serial: row[serial], caption: resources[caption], lit: resources[lit], tint: [...resources[tint]], + sample: { ...resources[sample] }, material: { alpha, color: [...color], glossy }, + }); +}); + +/** What `regauge` writes to the resources of mixed widths at a level. */ +export const surveyOf = (at: number): Omit => ({ + caption: `c${at}`, lit: at > 3, tint: [at, at + 0.5, at * 2], + sample: { on: at > 3, count: at * 1000 + 1, weight: at + 0.25, ratio: at * 0.5 + 1 }, + material: { alpha: at * 0.25 + 1, color: [at + 1, at + 2, at + 3], glossy: at > 3 }, +}); + +/** From the world's thread: the level, the origin at it, and the resources of mixed widths at it. */ +export const regauge = System.create({ name: "remoteFinalizersTest/regauge", resources: { write: [level, origin, caption, lit, tint, sample, material] } }, ({ resources }, input: { readonly level: number }) => { resources[level] = input.level; resources[origin] = [input.level, input.level + 10]; + const values = surveyOf(input.level); + resources[caption] = values.caption; + resources[lit] = values.lit; + resources[tint] = [values.tint[0], values.tint[1], values.tint[2]]; + resources[sample] = values.sample; + resources[material] = values.material; }); /** Spawns headed rows from the world's thread, with the thread's widget at null. */ diff --git a/packages/ecs/src/implementation/world/stop-lists.ts b/packages/ecs/src/implementation/world/stop-lists.ts index 025c58e9..2f03faac 100644 --- a/packages/ecs/src/implementation/world/stop-lists.ts +++ b/packages/ecs/src/implementation/world/stop-lists.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Finalizer } from '../../interface/finalizer/created.js'; +import { viewsOf, type Layout } from '../../interface/registry/layout.js'; import type { Memory } from './memory.js'; import type { StringStore } from './strings.js'; import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; @@ -83,6 +84,10 @@ export type StopsOf = { readonly linearResources: readonly number[]; readonly resourceStrides: readonly number[]; readonly resourceOffsets: readonly number[]; + /** What its string and linear resources start at, with the linear ones' layouts, for one the world doesn't hold. */ + readonly stringStarts: readonly unknown[]; + readonly linearStarts: readonly unknown[]; + readonly resourceLayouts: readonly Layout[]; /** This sync point's stops, as plan entries, reused by every sync point. */ readonly entries: number[]; }; @@ -269,7 +274,9 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) const storageById = source.resourceArchetype < 0 ? undefined : archetypes[source.resourceArchetype].storageById; for (let k = 0; k < stringResources.length; k++) { const handles = storageById?.[stringResources[k]]?.views.u32; - const copy = handles === undefined || strings === null ? 0 : strings.copy(handles[resourceRow]); + // Without the resource, unreachable while a finalizer activates only once its resources exist: its start. + const start = stops.stringStarts[k]; + const copy = strings === null ? 0 : handles !== undefined ? strings.copy(handles[resourceRow]) : typeof start === "string" ? strings.write(start) : 0; if (copy !== 0) copied.push(copy); u32[(column >> 2) + k] = copy; } @@ -278,7 +285,9 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) const stride = resourceStrides[k]; const target = column + resourceOffsets[k]; if (values === undefined) { - u8.fill(0, target, target + stride); + // Unreachable while a finalizer activates only once its resources exist: its start. + const layout = stops.resourceLayouts[k]; + if (layout.kind === "linear") layout.write(viewsOf(buffer, target, stride), 0, stops.linearStarts[k]); continue; } if ((stride & 3) === 0) { From bed788e1996d84248353f9d97c66e4530df8c260 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:49:54 -0700 Subject: [PATCH 035/127] ecs: report a lent worker its owner ends as terminated by its owner on the thread it's lent to The worker's port could close there before the owner's report arrived on the other channel. The owner now reports before ending the worker, and the port's close is reported only once the owner answers a `closed` on its channel, after anything it reported first. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 7 ----- .../ecs/src/implementation/world/port.test.ts | 24 ++++++++++++++++- packages/ecs/src/implementation/world/port.ts | 27 +++++++++++-------- .../src/implementation/world/ports.cases.ts | 3 +-- packages/ecs/threads.md | 4 ++- 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index e1fb5fe2..d7bbb049 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -4,7 +4,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Queued (decided, not started) - ## Decisions for the maintainers - **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every @@ -87,12 +86,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done 6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in `src/observe`. -## Findings from the joined-thread stress run - -- **A lent worker's death has two reports that race on the side it's lent to:** its port's `close` and its owner's - message, so which message a run's failure carries varies (`ports.cases.ts` accepts either). Worth deciding whether - the owner's report should win, for one message per way a worker ends. - ## Features to discuss - **Computed index keys:** an index keyed on a value computed from a component, such as a struct field diff --git a/packages/ecs/src/implementation/world/port.test.ts b/packages/ecs/src/implementation/world/port.test.ts index 17408626..2127c544 100644 --- a/packages/ecs/src/implementation/world/port.test.ts +++ b/packages/ecs/src/implementation/world/port.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { serveLent } from "./port.js"; +import { lentPort, serveLent } from "./port.js"; describe("a lent worker's owner", () => { it("reports a worker it ends as terminated by its owner, though the worker's own end is heard of first", async () => { @@ -21,3 +21,25 @@ describe("a lent worker's owner", () => { port2.close(); }); }); + +describe("a lent worker", () => { + it("is reported terminated by its owner when its owner ends it, though its port's close is heard of first", async () => { + const data = new MessageChannel(); + const control = new MessageChannel(); + const lent = lentPort({ port: data.port2, owner: control.port2 }); + const reported = new Promise(resolve => lent.onDeath(error => resolve(error.message))); + const portClosed = new Promise(resolve => data.port2.addEventListener("close", () => resolve())); + // Ending the worker closes its port, and the thread it's lent to hears of that before `terminate` resolves. + const worker = { + onDeath: () => {}, + terminate: async () => { + data.port1.close(); + await portClosed; + }, + }; + await serveLent(worker, control.port1).terminate(); + expect(await reported).toBe("A world worker was terminated by its owner."); + control.port1.close(); + control.port2.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/port.ts b/packages/ecs/src/implementation/world/port.ts index 9aef2b4f..05c9fde1 100644 --- a/packages/ecs/src/implementation/world/port.ts +++ b/packages/ecs/src/implementation/world/port.ts @@ -35,9 +35,12 @@ export type Endpoint = { */ export type Lent

= { readonly port: P; readonly owner: P }; -/** Between a lent worker's owner and the thread it's lent to. */ -type ToOwner = { readonly kind: "terminate" }; -type FromOwner = { readonly kind: "death"; readonly message: string } | { readonly kind: "terminated" }; +/** + * Between a lent worker's owner and the thread it's lent to. `closed` tells the owner the worker's port closed; the + * owner answers it in order with what it posts, so a death it reported first arrives first. + */ +type ToOwner = { readonly kind: "terminate" } | { readonly kind: "closed" }; +type FromOwner = { readonly kind: "death"; readonly message: string } | { readonly kind: "terminated" } | { readonly kind: "closed" }; /** A death to report once: the first error wins, and a handler set after it still hears it. */ export const createDeath = (): { readonly die: (error: Error) => void; readonly onDeath: (handler: (error: Error) => void) => void } => { @@ -81,8 +84,9 @@ export const scopeChannel = (): Channel => { /** * The thread a lent worker runs on, as the thread it's lent to sees it: its death is what its owner reports, or either - * port's `close` where the platform has it; `terminate` asks the owner, and closes both ports once it has, or once the - * owner's port closed. + * port's `close` where the platform has it. The worker's port closing is reported once its owner answers that it + * reported nothing before, so an owner's report wins over the close its ending causes. `terminate` asks the owner, and + * closes both ports once it has, or once the owner's port closed. */ export const lentPort = ({ port, owner }: Lent): Port => { const channel = endpointChannel(port); @@ -91,9 +95,10 @@ export const lentPort = ({ port, owner }: Lent): Port void) | null = null; control.onMessage(message => { if (message.kind === "death") death.die(new Error(message.message)); + else if (message.kind === "closed") death.die(new Error("A world worker's port closed.")); else terminated?.(); }); - port.addEventListener("close", () => death.die(new Error("A world worker's port closed."))); + port.addEventListener("close", () => control.post({ kind: "closed" })); // An owner whose thread is gone can't answer; where the platform reports that, its worker is gone with it. owner.addEventListener("close", () => { death.die(new Error("A world worker's owner is gone.")); @@ -116,7 +121,7 @@ export const lentPort = ({ port, owner }: Lent): Port, "onDeath" | "terminate">, owner: Endpoint): { readonly terminate: () => Promise } => { const control = endpointChannel(owner); @@ -127,13 +132,13 @@ export const serveLent = (worker: Pick, "onDeath" | "term control.post({ kind: "death", message: error.message }); }); const end = async (): Promise => { - // Gone before it's ended, so the death reported is this, not the worker's end it causes, which may come first. - const reported = gone; + // Reported before it's ended, so it comes before the worker's end it causes, here and as its port's close there. + if (!gone) control.post({ kind: "death", message: "A world worker was terminated by its owner." }); gone = true; await worker.terminate(); - if (!reported) control.post({ kind: "death", message: "A world worker was terminated by its owner." }); }; - control.onMessage(async () => { + control.onMessage(async message => { + if (message.kind === "closed") return control.post({ kind: "closed" }); gone = true; await worker.terminate(); control.post({ kind: "terminated" }); diff --git a/packages/ecs/src/implementation/world/ports.cases.ts b/packages/ecs/src/implementation/world/ports.cases.ts index 4a5ecc22..2c7d8e3e 100644 --- a/packages/ecs/src/implementation/world/ports.cases.ts +++ b/packages/ecs/src/implementation/world/ports.cases.ts @@ -10,8 +10,7 @@ export const portsCases = (load: () => Promise) => describe("thr it(`fail the run whose ${wasm ? "WASM" : "JS"} task's worker is killed from outside mid-task, by ${by === "exit" ? "ending it" : "its owner"}, and every later task on it`, async () => { const fixture = await load(); const { first, second, disposed } = await fixture.killedMidTask(by, wasm); - // A lent worker's death is what its owner reports, or its port's close where the platform has it, whichever comes first. - const death = by === "exit" ? /exited/ : /terminated by its owner|port closed/; + const death = by === "exit" ? /exited/ : /terminated by its owner/; expect(first.length).toBeGreaterThan(0); for (const message of first) expect(message).toMatch(death); expect(second.length).toBeGreaterThan(0); diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 8e62fb18..e24f224e 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -258,7 +258,9 @@ only on node, so browser bundles never load them, and the node worker's url isn' so a browser build bundles one worker (`worker-platform.ts`). Death is a `Worker`'s `error` or `messageerror`, a node worker's `error`, `messageerror` or `exit`, or, for a lent worker, what its owner reports or either `MessagePort`'s `close` where the platform has it, whichever comes first; nothing polls, so a quiet world does no work. A browser -`Worker` ended from outside its port reports nothing, so a lent worker's owner reports the end of one it ends. +`Worker` ended from outside its port reports nothing, so a lent worker's owner reports the end of one it ends, before +ending it. The worker's port closing is reported only once the owner answers a `closed` on its channel, which comes +after anything it reported first, so an owner's report wins over the close its ending causes. - **Platforms:** a world's pool comes from a platform, `{ count, start }`: `count` is what the `workers` resource's `null` resolves to, its hardware threads, `navigator.hardwareConcurrency` or `os.availableParallelism()`, minus the From 360a0eeaf2b78e95650680459ef82ca284bac213 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 03:56:10 -0700 Subject: [PATCH 036/127] ecs: plugin tests install a copy of the package built from its sources Two plugin tests read packages/ecs/dist, so on a fresh checkout they failed until tsc -b had run: the library-kernels test resolved "@adobe/ecs" through a symlink to the package root, whose exports name dist, and the runtime-copy test copied dist. Both now install a copy of the package into a temporary project, its dist transpiled from the modules its entries reach at runtime, once per test file. The library's kernel attaches to that installed copy, so the application takes its test hook from it. Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/plugin.test.ts | 89 +++++++++++++------ 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 78f3e782..28ce647b 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, expect, test } from 'vitest'; -import { build, createServer, type Rollup, type ViteDevServer } from 'vite'; +import { build, createServer, parseAst, transformWithOxc, type Rollup, type ViteDevServer } from 'vite'; import type { KernelDescription, KernelModule } from '../world/kernel.js'; import { kernelPlugin } from './plugin.js'; @@ -72,21 +72,6 @@ import { Listed } from "./listed.js"; const kernel = System.create({ name: "listing", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); export const description = kernelOf(kernel); `); -/** An application importing a kernel a library ships uncompiled, from its `dist`. */ -const application = join(directory, "application.js"); -mkdirSync(join(directory, "node_modules/@adobe"), { recursive: true }); -symlinkSync(fileURLToPath(new URL("../../..", import.meta.url)), join(directory, "node_modules/@adobe/ecs")); -mkdirSync(join(directory, "node_modules/kernel-library/dist"), { recursive: true }); -writeFileSync(join(directory, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); -writeFileSync(join(directory, "node_modules/kernel-library/dist/index.js"), `import { Component, System } from "@adobe/ecs"; -const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); -export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -`); -writeFileSync(application, `import { kernelOf } from ${source("../world/attach-kernel.ts")}; -import { kernel } from "kernel-library"; -export const description = kernelOf(kernel); -`); /** Two kernels in one module, writing different functions of the entity. */ const pair = join(directory, "pair.js"); writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; @@ -156,6 +141,47 @@ const externalList = join(directory, "external-list.js"); writeFileSync(externalList, externalKernel("externalList", "Pair")); afterAll(() => rmSync(directory, { recursive: true, force: true })); +const own = fileURLToPath(new URL("../../..", import.meta.url)); +/** The modules an installed copy's code starts from: its root, its worker, and those compiled code imports. */ +const copyEntries = ["index", "implementation/world/worker", "implementation/world/attach-kernel", "implementation/world/prepared-spawn", "implementation/world/prepared-lookup"]; +let builtDist: Promise> | null = null; + +/** Every relative module a module's code imports or re-exports from. */ +const relativeImports = (code: string): readonly string[] => parseAst(code).body.flatMap(statement => + (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration" || statement.type === "ExportNamedDeclaration") + && typeof statement.source?.value === "string" && statement.source.value.startsWith(".") ? [statement.source.value] : []); + +/** + * The package's `dist` code by path within it, built from the sources as `tsc` emits each module on its own: the + * modules the entries reach at runtime, so no test reads a previous build. Built once per test file. + */ +const distFromSources = (): Promise> => builtDist ??= (async () => { + const sources = join(own, "src"); + const files = new Map(); + const visit = async (file: string): Promise => { + const path = relative(sources, file).replace(/\.ts$/, ".js"); + if (files.has(path)) return; + files.set(path, ""); + const { code } = await transformWithOxc(readFileSync(file, "utf8"), file, { lang: "ts" }); + files.set(path, code); + await Promise.all(relativeImports(code).map(specifier => visit(resolve(dirname(file), specifier.replace(/\.js$/, ".ts"))))); + }; + await Promise.all(copyEntries.map(entry => visit(join(sources, `${entry}.ts`)))); + return files; +})(); + +/** Installs a built copy of the package in a project's `node_modules`, a directory of its own as an install makes. */ +const installCopy = async (project: string): Promise => { + const copy = join(project, "node_modules/@adobe/ecs"); + mkdirSync(copy, { recursive: true }); + writeFileSync(join(copy, "package.json"), readFileSync(join(own, "package.json"))); + for (const [path, code] of await distFromSources()) { + mkdirSync(dirname(join(copy, "dist", path)), { recursive: true }); + writeFileSync(join(copy, "dist", path), code); + } + return copy; +}; + /** Runs a one-column kernel writing a function of the entity, for entities 3 and 4. */ const runs = (module: KernelModule, description: KernelDescription): readonly number[] => { const entities = Float64Array.from([3, 4]); @@ -291,9 +317,26 @@ test("serve compiles kernels again when a list they import changes", async () => }); test("an application's build compiles the kernels a library ships uncompiled", async () => { - const outDir = join(directory, "dist-application"); + // An application importing a kernel a library ships uncompiled, from its `dist`, with the package installed. + const project = join(directory, "shipped"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "shipped-app", type: "module" })); + mkdirSync(join(project, "node_modules/kernel-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); + writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { Component, System } from "@adobe/ecs"; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +`); + // The test hook comes from the installed copy, which the library's compiled kernel attaches to. + const application = join(project, "application.js"); + writeFileSync(application, `import { kernelOf } from ${JSON.stringify(join(copy, "dist/implementation/world/attach-kernel.js"))}; +import { kernel } from "kernel-library"; +export const description = kernelOf(kernel); +`); + const outDir = join(project, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, }); const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -389,11 +432,7 @@ test("a build takes a name from a package external to it as one key, and registr test("compiled code imports the runtime from the copy of the package its module imports, not the plugin's own", async () => { // A project whose installed copy of the package is a directory of its own, as a lockfile's copy is beside a linked one. const project = join(directory, "copied"); - const copy = join(project, "node_modules/@adobe/ecs"); - const own = fileURLToPath(new URL("../../..", import.meta.url)); - mkdirSync(copy, { recursive: true }); - cpSync(join(own, "package.json"), join(copy, "package.json")); - cpSync(join(own, "dist"), join(copy, "dist"), { recursive: true, filter: file => !/\.(map|d\.ts)$|\.test\./.test(file) }); + await installCopy(project); writeFileSync(join(project, "package.json"), JSON.stringify({ name: "copy-app", type: "module" })); const module = join(project, "copied.js"); writeFileSync(module, `import { System, Component } from "@adobe/ecs"; From e8350fe7b5d22c148cd8849766b3283a5ec0fead Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 05:57:12 -0700 Subject: [PATCH 037/127] ecs: the transfer benchmark's spread in chromium is its own, measured on a quiet machine Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index d7bbb049..71c5f6db 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -103,8 +103,9 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Performance -- **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next - record. +- **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is + its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes + as signal. ## Earlier open work From 5331542eb20e8156e21423db5407135e6ef2c0dc Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 10:36:35 -0700 Subject: [PATCH 038/127] ecs: README lists the features and recorded performance Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 619a1ed3..1bca8321 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -2,4 +2,56 @@ A data-oriented entity component system. Systems run on the main thread, on worker threads, as WASM kernels or on the GPU. +## Features + +- **Shared columnar storage.** Components are stored as structure-of-arrays columns. Numbers, tuples, structs and + strings live in a `SharedArrayBuffer` that every thread reads, and objects stay on the thread that owns them. +- **Declared reads and writes.** Each system lists the components and resources it reads and writes. The scheduler uses + those lists to order systems and to run the ones that don't conflict at the same time. +- **Deferred structure.** Spawning, despawning, adding and removing components are commands. They apply at sync points + in the order they were issued, so memory never moves while systems run. Value writes take effect immediately. +- **One body, four places to run.** The same system body can run on the main thread, on a worker pool, as a WASM + kernel or as a WebGPU compute shader. A Vite plugin compiles kernels at build time and checks their reads and writes + against the declaration. A GPU kernel runs on the CPU when there's no device. Render systems record WebGPU draws + directly from component storage, optionally in an index's order. +- **Change detection.** Every component of every entity has an added tick and a changed tick, so each system sees each + change exactly once. Change filters skip unchanged chunks and unchanged 64-row blocks without visiting their rows. +- **Indexes and lookups.** Indexes can be hash, unique, sorted (with ranges, pages and `order` runs) or keyless. Lookups + return rows in entity order, and they report whether anything entered, left or moved. +- **Hierarchies.** `up` walks parent references from roots to leaves and reruns only the chains below what changed. +- **Rules, finalizers and traits.** Rules derive components from structure. A finalizer runs for each entity that + stops matching and reads its values from before. Traits declare behaviors such as despawning an entity along with the + entity it refers to. +- **Typed input.** A system's input is the TypeScript type of its body's parameter. The plugin turns that type into the + runtime schema at build time. +- **Observer systems.** These read state after every sync point, and they're how a UI reads the world. They can run on + the world's thread or on a thread that joined it. +- **Threads.** A world can be shared with other threads through a `MessagePort` or a worker. Joined threads run + systems and observers, and they can lend their workers to the pool. +- **Errors as data.** `run` resolves to a run number. Failures, skipped systems and warnings for that run are spawned as + a report entity instead of being thrown. + +## Performance + +Per-frame work is kept as low as possible, and nothing is allocated per row or per run. Below are some recorded +results from an Apple M1 Max with node 24, one thread unless noted. See [perf/README.md](perf/README.md) for every +benchmark, Chromium's worker and GPU results, and the history of each. + +| Benchmark | Time | +|---|---:| +| Kernel adding velocity to position, 250K rows | 0.09 ms | +| Same, on workers in Chromium | 0.015 ms | +| Six dependent kernels fused into one pass, 1M rows | 1.56 ms | +| Change-filtered system, 1M rows with few changed | 0.32 ms | +| Hierarchy update after one root moved, 1M rows | 0.024 ms | +| Idle run | 0.33 µs | +| Run with one spawn and its sync point | 0.83 µs | +| Bulk spawn from a kernel, 100K entities | 1.05 ms | +| Sorted range lookups, 10K over 1M entities | 5.6 ms | +| Index rebuild, 1M entities | 26.9 ms; 6.9 ms on workers in Chromium | + +The velocity kernel runs within 1.24× of the time it takes just to move its bytes at the measured copy bandwidth. + +## Guide + See the [guide](src/guide/guide.md) for how to use it. From f23dcef58783272f8312a3a1e6dc29fff6a03b61 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 10:38:34 -0700 Subject: [PATCH 039/127] ecs: README outlines the features Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 77 ++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 1bca8321..e20977c8 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -4,53 +4,36 @@ A data-oriented entity component system. Systems run on the main thread, on work ## Features -- **Shared columnar storage.** Components are stored as structure-of-arrays columns. Numbers, tuples, structs and - strings live in a `SharedArrayBuffer` that every thread reads, and objects stay on the thread that owns them. -- **Declared reads and writes.** Each system lists the components and resources it reads and writes. The scheduler uses - those lists to order systems and to run the ones that don't conflict at the same time. -- **Deferred structure.** Spawning, despawning, adding and removing components are commands. They apply at sync points - in the order they were issued, so memory never moves while systems run. Value writes take effect immediately. -- **One body, four places to run.** The same system body can run on the main thread, on a worker pool, as a WASM - kernel or as a WebGPU compute shader. A Vite plugin compiles kernels at build time and checks their reads and writes - against the declaration. A GPU kernel runs on the CPU when there's no device. Render systems record WebGPU draws - directly from component storage, optionally in an index's order. -- **Change detection.** Every component of every entity has an added tick and a changed tick, so each system sees each - change exactly once. Change filters skip unchanged chunks and unchanged 64-row blocks without visiting their rows. -- **Indexes and lookups.** Indexes can be hash, unique, sorted (with ranges, pages and `order` runs) or keyless. Lookups - return rows in entity order, and they report whether anything entered, left or moved. -- **Hierarchies.** `up` walks parent references from roots to leaves and reruns only the chains below what changed. -- **Rules, finalizers and traits.** Rules derive components from structure. A finalizer runs for each entity that - stops matching and reads its values from before. Traits declare behaviors such as despawning an entity along with the - entity it refers to. -- **Typed input.** A system's input is the TypeScript type of its body's parameter. The plugin turns that type into the - runtime schema at build time. -- **Observer systems.** These read state after every sync point, and they're how a UI reads the world. They can run on - the world's thread or on a thread that joined it. -- **Threads.** A world can be shared with other threads through a `MessagePort` or a worker. Joined threads run - systems and observers, and they can lend their workers to the pool. -- **Errors as data.** `run` resolves to a run number. Failures, skipped systems and warnings for that run are spawned as - a report entity instead of being thrown. - -## Performance - -Per-frame work is kept as low as possible, and nothing is allocated per row or per run. Below are some recorded -results from an Apple M1 Max with node 24, one thread unless noted. See [perf/README.md](perf/README.md) for every -benchmark, Chromium's worker and GPU results, and the history of each. - -| Benchmark | Time | -|---|---:| -| Kernel adding velocity to position, 250K rows | 0.09 ms | -| Same, on workers in Chromium | 0.015 ms | -| Six dependent kernels fused into one pass, 1M rows | 1.56 ms | -| Change-filtered system, 1M rows with few changed | 0.32 ms | -| Hierarchy update after one root moved, 1M rows | 0.024 ms | -| Idle run | 0.33 µs | -| Run with one spawn and its sync point | 0.83 µs | -| Bulk spawn from a kernel, 100K entities | 1.05 ms | -| Sorted range lookups, 10K over 1M entities | 5.6 ms | -| Index rebuild, 1M entities | 26.9 ms; 6.9 ms on workers in Chromium | - -The velocity kernel runs within 1.24× of the time it takes just to move its bytes at the measured copy bandwidth. +- **Linear, gapless structure-of-arrays component storage** + - Fixed-length values (numbers, tuples, structs) in a `SharedArrayBuffer` + - Shared across the main thread and all workers + - Read by WASM kernels without copying + - WebGPU-compatible struct layout for fast uploads + - WebGPU storage for GPU system kernels + - Synced back to shared memory only when the CPU reads it + - Strings in shared memory, readable on every thread + - Objects and other variable-length values on the thread that owns them +- **Systems** + - Plain TypeScript bodies with declared reads and writes + - Kernels compiled and checked at build time to optimized JavaScript, WASM and WGSL + - Balanced scheduling across the main thread, workers and the GPU + - Typed input, with its schema generated from the TypeScript type + - Render systems that draw straight from component storage + - Observer systems for reading state into a UI +- **Changes** + - Per-component added and changed ticks, with each change seen exactly once + - Filters that skip unchanged chunks and 64-row blocks + - Structural changes batched at sync points, so memory never moves while systems run + - Rules, finalizers and traits for derived components, cleanup and references +- **Queries** + - Hash, unique, sorted and keyless indexes, all updated incrementally + - Hierarchy traversal that reruns only below what changed +- **Threads** + - Worlds shared with other threads through a `MessagePort` or worker + - Joined threads run their own systems and observers, and lend their workers to the pool +- **Runtime** + - No allocation per row or per frame + - Errors reported as data, in each run's report ## Guide From 7cb86a7d4458018da4f0c1191901a6096dd7fc60 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:05:49 -0700 Subject: [PATCH 040/127] ecs: README says what allocates nothing; follow-up on a run's fixed allocation Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 2 +- packages/ecs/followups.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index e20977c8..29a47ab3 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -32,7 +32,7 @@ A data-oriented entity component system. Systems run on the main thread, on work - Worlds shared with other threads through a `MessagePort` or worker - Joined threads run their own systems and observers, and lend their workers to the pool - **Runtime** - - No allocation per row or per frame + - Kernels allocate nothing per row; commands, lookups and reports reuse their buffers - Errors reported as data, in each run's report ## Guide diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 71c5f6db..92df0324 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -103,6 +103,9 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Performance +- **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, + `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing + waits, and measure with a sampling heap profile. - **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes as signal. From f87fe10ead8eda11239147941e466d0ebbf50546 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:23:33 -0700 Subject: [PATCH 041/127] ecs: README notes change checks are O(1) Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 29a47ab3..b3bb4e1f 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -21,7 +21,7 @@ A data-oriented entity component system. Systems run on the main thread, on work - Render systems that draw straight from component storage - Observer systems for reading state into a UI - **Changes** - - Per-component added and changed ticks, with each change seen exactly once + - Per-component added and changed ticks: an O(1) check per entity, with each change seen exactly once - Filters that skip unchanged chunks and 64-row blocks - Structural changes batched at sync points, so memory never moves while systems run - Rules, finalizers and traits for derived components, cleanup and references From 644a5573f3bbb70769e03e727e132925d36af6e6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:25:38 -0700 Subject: [PATCH 042/127] ecs: README opens with a higher-level summary Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index b3bb4e1f..b9739c5e 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -1,6 +1,6 @@ # @adobe/ecs -A data-oriented entity component system. Systems run on the main thread, on worker threads, as WASM kernels or on the GPU. +A high-performance entity component system for real-time TypeScript apps on the web. ## Features From 2050355236cd2805d23041b6ead60ec7f938cac3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:28:20 -0700 Subject: [PATCH 043/127] ecs: README summary names applications, simulations and games Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index b9739c5e..61a2d629 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -1,6 +1,6 @@ # @adobe/ecs -A high-performance entity component system for real-time TypeScript apps on the web. +A high-performance Entity Component System for web-based applications, simulations and games. ## Features From 2c939da33227f956c3780f7ae30691ad364604a6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:29:23 -0700 Subject: [PATCH 044/127] ecs: README summary says multithreaded and GPU-integrated Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 61a2d629..59636b0c 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -1,6 +1,7 @@ # @adobe/ecs -A high-performance Entity Component System for web-based applications, simulations and games. +A high-performance, multithreaded, GPU-integrated Entity Component System for web-based applications, simulations and +games. ## Features From 9026bfbd28bbbc2f0b924faae223f113bfc3b806 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:35:13 -0700 Subject: [PATCH 045/127] ecs: README summary drops GPU-integrated Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 59636b0c..b6dda888 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -1,7 +1,6 @@ # @adobe/ecs -A high-performance, multithreaded, GPU-integrated Entity Component System for web-based applications, simulations and -games. +A high-performance, multithreaded Entity Component System for web-based applications, simulations and games. ## Features From 7fc9d060411d212802e4723f91a5b9d7baab7579 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 12:50:06 -0700 Subject: [PATCH 046/127] ecs: PRs bump this package by hand, never through the workspace bump Co-Authored-By: Claude Opus 5.5 --- packages/ecs/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md index 658ac6a8..4e2f63b0 100644 --- a/packages/ecs/CLAUDE.md +++ b/packages/ecs/CLAUDE.md @@ -49,6 +49,7 @@ empties, and avoid closures, spreads and awaits there. Measure changes with `pnp Each PR costs a review, a merge and a publish, so keep one open PR at a time. Add new work and fixes to it as more commits; never open stacked or parallel PRs. Keep it rebased on main, with this package's version one above main's. +Bump it by hand in `package.json`; never run the workspace `bump` script, which bumps packages this PR didn't change. ## Memory From 01732a1deaad5104c29988a6ad9c94e6bc9fa001 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 15:12:37 -0700 Subject: [PATCH 047/127] ecs: follow-ups list the API optimizations the required plugin allows Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 92df0324..1028b718 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -78,6 +78,25 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept. +## API optimizations + +The plugin is required, so it can derive what consumers now restate. Each is a contract change, reviewed one at a +time; inferred values are emitted as the same literals the runtime takes today, so none costs anything at runtime. + +1. **Declarations from the augmentation.** The pre-strip pass reads `interface Components`, `Resources` and `Traits` + and emits the `register` calls: the schema from the type (`F32`, `I32`, `U32` for precision and width), the traits + as declared, and defaults as a trait. About 200 calls in consumer code restate their augmentation. +2. **Names from bindings.** `name` on systems, composites, indexes and finalizers defaults to the plugin's id + (`#`), which 218 of 325 names restate; `Symbol` descriptions likewise. +3. **Reductions declared once.** A system's `reduce` drops the operator, which is the resource's `reduce` trait, and + its keys come from the body's `reduce[key](…)` calls. +4. **Kernels by default.** A body in the kernel subset compiles as a `"cpu"` kernel unflagged; `kernel: "cpu"` + asserts it, `"gpu"` chooses the backend, `false` runs it as written. Needs a measured rule for small runs, and + accepts results that aren't bit-exact with plain JS. +5. **Access inferred from bodies.** `components`, `resources`, `lookup` and `up` read and write lists come from the + body where `usedBy` follows every use, explicit otherwise. Bodies' rows are then typed over every component, so + it needs the planned ESLint rule to keep errors in the editor. + ## Findings from the todo sample (`src/samples/todo/README.md`) 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities From be6d8f90b599d84ef7e9d4fa9b4132da5e14573a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 15:40:27 -0700 Subject: [PATCH 048/127] ecs: Schema.Type names the type a schema describes; the math types are declared through it Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/interface/math/f32/index.ts | 5 +++- packages/ecs/src/interface/math/i32/index.ts | 5 +++- .../ecs/src/interface/math/mat4x4/index.ts | 4 +++- .../ecs/src/interface/math/math.type-test.ts | 24 ++++++++++++++++++- packages/ecs/src/interface/math/quat/index.ts | 5 +++- packages/ecs/src/interface/math/u32/index.ts | 5 +++- .../ecs/src/interface/math/vec2/f32/f32.ts | 5 +++- .../ecs/src/interface/math/vec2/i32/i32.ts | 5 +++- .../ecs/src/interface/math/vec2/u32/u32.ts | 5 +++- .../ecs/src/interface/math/vec3/f32/f32.ts | 5 +++- .../ecs/src/interface/math/vec3/i32/i32.ts | 5 +++- .../ecs/src/interface/math/vec3/u32/u32.ts | 5 +++- .../ecs/src/interface/math/vec4/f32/f32.ts | 5 +++- .../ecs/src/interface/math/vec4/i32/i32.ts | 5 +++- .../ecs/src/interface/math/vec4/u32/u32.ts | 5 +++- packages/ecs/src/interface/schema/schema.ts | 7 ++++++ .../src/interface/schema/schema.type-test.ts | 7 ++++++ 17 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/ecs/src/interface/math/f32/index.ts b/packages/ecs/src/interface/math/f32/index.ts index 9fe4517f..8576c110 100644 --- a/packages/ecs/src/interface/math/f32/index.ts +++ b/packages/ecs/src/interface/math/f32/index.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = number; +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/i32/index.ts b/packages/ecs/src/interface/math/i32/index.ts index dc94e8ba..d850e959 100644 --- a/packages/ecs/src/interface/math/i32/index.ts +++ b/packages/ecs/src/interface/math/i32/index.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = number; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/mat4x4/index.ts b/packages/ecs/src/interface/math/mat4x4/index.ts index 4c5893d3..77830a52 100644 --- a/packages/ecs/src/interface/math/mat4x4/index.ts +++ b/packages/ecs/src/interface/math/mat4x4/index.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; -export type Mat4x4 = readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; +export type Mat4x4 = Schema.Type; /** * 4x4 Matrix operations using column-major order (WebGPU standard). diff --git a/packages/ecs/src/interface/math/math.type-test.ts b/packages/ecs/src/interface/math/math.type-test.ts index cc51dee0..5c34cfc3 100644 --- a/packages/ecs/src/interface/math/math.type-test.ts +++ b/packages/ecs/src/interface/math/math.type-test.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Mat4x4, Quat, Vec2, Vec3, Vec4 } from '../index.js'; +import { F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from '../index.js'; // Each type is also a namespace of functions; `Vec3` is `Vec3.F32`, with integer vectors nested beside it. const position: Vec3 = [1, 2, 3]; @@ -19,3 +19,25 @@ export const used = [integer, unsigned, texel, color, transform, wrong]; // @ts-expect-error vectors are read-only values position[0] = 1; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; +type Two = readonly [number, number]; +type Three = readonly [number, number, number]; +type Four = readonly [number, number, number, number]; + +// Each is the plain type its schema describes. +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); diff --git a/packages/ecs/src/interface/math/quat/index.ts b/packages/ecs/src/interface/math/quat/index.ts index a69b1e59..144928e1 100644 --- a/packages/ecs/src/interface/math/quat/index.ts +++ b/packages/ecs/src/interface/math/quat/index.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type Quat = readonly [number, number, number, number]; +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type Quat = Schema.Type; export * as Quat from "./public.js"; diff --git a/packages/ecs/src/interface/math/u32/index.ts b/packages/ecs/src/interface/math/u32/index.ts index fa1611bd..782ad40a 100644 --- a/packages/ecs/src/interface/math/u32/index.ts +++ b/packages/ecs/src/interface/math/u32/index.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = number; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/f32.ts b/packages/ecs/src/interface/math/vec2/f32/f32.ts index 8833b01b..6d5070b5 100644 --- a/packages/ecs/src/interface/math/vec2/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec2/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/i32.ts b/packages/ecs/src/interface/math/vec2/i32/i32.ts index 5dd189bf..526e1c9a 100644 --- a/packages/ecs/src/interface/math/vec2/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec2/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/u32.ts b/packages/ecs/src/interface/math/vec2/u32/u32.ts index 429d7a78..e4aefaac 100644 --- a/packages/ecs/src/interface/math/vec2/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec2/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/f32.ts b/packages/ecs/src/interface/math/vec3/f32/f32.ts index 9313ba60..6d5070b5 100644 --- a/packages/ecs/src/interface/math/vec3/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec3/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/i32.ts b/packages/ecs/src/interface/math/vec3/i32/i32.ts index 147bc895..526e1c9a 100644 --- a/packages/ecs/src/interface/math/vec3/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec3/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/u32.ts b/packages/ecs/src/interface/math/vec3/u32/u32.ts index c318ebd6..e4aefaac 100644 --- a/packages/ecs/src/interface/math/vec3/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec3/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/f32.ts b/packages/ecs/src/interface/math/vec4/f32/f32.ts index 59d94791..6d5070b5 100644 --- a/packages/ecs/src/interface/math/vec4/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec4/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number, number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/i32.ts b/packages/ecs/src/interface/math/vec4/i32/i32.ts index d40cf1a0..526e1c9a 100644 --- a/packages/ecs/src/interface/math/vec4/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec4/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number, number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/u32.ts b/packages/ecs/src/interface/math/vec4/u32/u32.ts index 6daca429..e4aefaac 100644 --- a/packages/ecs/src/interface/math/vec4/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec4/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number, number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/schema/schema.ts b/packages/ecs/src/interface/schema/schema.ts index 3af7f140..24c02d24 100644 --- a/packages/ecs/src/interface/schema/schema.ts +++ b/packages/ecs/src/interface/schema/schema.ts @@ -140,6 +140,13 @@ export namespace Schema { T extends NullSchema ? null : never; + /** + * The type schema `S` describes, as a plain type: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>` + * is a `number`. Declarations naming such a type, directly or through aliases, get `S` as their schema from the + * build, so a named type carries its precision, width and bounds. + */ + export type Type = ToType; + /** The schemas whose `ToType` is `T`; constrains a schema to describe a known type. */ export type ForType = Schema & ( diff --git a/packages/ecs/src/interface/schema/schema.type-test.ts b/packages/ecs/src/interface/schema/schema.type-test.ts index 09223bcf..8bcf9dd3 100644 --- a/packages/ecs/src/interface/schema/schema.type-test.ts +++ b/packages/ecs/src/interface/schema/schema.type-test.ts @@ -152,3 +152,10 @@ check, true>>(); // Neither is a struct: neither lives in shared memory. check>, false>>(); check>, false>>(); + +// Schema.Type is the plain type a schema describes, so a type named by it is that type: a number or a tuple, unbranded. +type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>; +check>(); +check, readonly [number, number, number]>>(); +// @ts-expect-error its argument is a schema +type NotSchema = Schema.Type<{ type: "integer"; minimum: "zero" }>; From fabf18f1197a7eb370b48273669f0e7a4a962bec Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 15:42:57 -0700 Subject: [PATCH 049/127] ecs: ids name their package's version; a module outside a named package fails the build Copies of one package at different versions no longer share ids, and a module's id no longer depends on the build's root. Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/identify.test.ts | 43 +++++++++++-------- .../src/implementation/compiler/identify.ts | 28 ++++++------ .../ecs/src/implementation/compiler/plugin.ts | 38 ++++++++-------- .../src/implementation/world/names.test.ts | 2 +- .../implementation/world/reevaluation.test.ts | 2 +- 5 files changed, 61 insertions(+), 52 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index f6149e95..e842236e 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -124,33 +124,40 @@ describe("identify, for systems, rules and finalizers", () => { `const computed = System["create"]({ name: "computed" });`, ].join("\n"))).toBe(null); }); + test("fails for a module outside a named package, which has nothing to name its ids by", async () => { + const result = await identifyModule(`export const position = Symbol();`, null, parse, resolve); + expect(result !== null && "failure" in result ? result.failure : result).toContain("package.json"); + expect(await identifyModule(`export const two = 2;`, null, parse, resolve)).toBe(null); + }); }); describe("moduleIdOf", () => { - /** Packages by directory, as their package.json files name them. */ - const packages = (named: { readonly [directory: string]: string | null }) => (directory: string) => { - const name = named[directory]; - return name === undefined ? undefined : name; + /** Packages by directory, as their package.json files name and version them. */ + const packages = (named: { readonly [directory: string]: { readonly name: string; readonly version?: string } | null }) => (directory: string) => { + const found = named[directory]; + return found === undefined ? undefined : found; }; const none = packages({}); - test("is the nearest package's name and the path in it, without a query", async () => { - const app = packages({ "/app": "app", "/repo/packages/shared": "@scope/shared", "/repo/packages/shared/src/nested": null }); - expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", app)).toBe("app/src/keys.ts"); - expect(moduleIdOf("/app/src/worker.ts?worker_file&type=module", "/app/", app)).toBe("app/src/worker.ts"); + test("is the nearest package's name, its version and the path in it, without a query", async () => { + const app = packages({ "/app": { name: "app", version: "1.2.0" }, "/repo/packages/shared": { name: "@scope/shared", version: "0.3.1" }, "/repo/packages/shared/src/nested": null }); + expect(moduleIdOf("/app/src/keys.ts?t=1712", app)).toBe("app@1.2.0/src/keys.ts"); + expect(moduleIdOf("/app/src/worker.ts?worker_file&type=module", app)).toBe("app@1.2.0/src/worker.ts"); // A workspace package linked from outside the root is named by its package, not its directories. - expect(moduleIdOf("/repo/packages/shared/src/nested/keys.ts", "/repo/apps/web", app)).toBe("@scope/shared/src/nested/keys.ts"); + expect(moduleIdOf("/repo/packages/shared/src/nested/keys.ts", app)).toBe("@scope/shared@0.3.1/src/nested/keys.ts"); + }); + test("leaves out a version the package doesn't give", async () => { + expect(moduleIdOf("/app/src/keys.ts", packages({ "/app": { name: "app" } }))).toBe("app/src/keys.ts"); }); - test("is a dependency's package name and subpath", async () => { - const scoped = packages({ "/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys": "@scope/keys" }); - expect(moduleIdOf("/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys/dist/keys.js?v=3f2a", "/app", scoped)).toBe("@scope/keys/dist/keys.js"); - expect(moduleIdOf("/elsewhere/node_modules/keys/index.js", "/app", none)).toBe("keys/index.js"); + test("is a dependency's package name, version and subpath", async () => { + const scoped = packages({ "/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys": { name: "@scope/keys", version: "1.2.0" } }); + expect(moduleIdOf("/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys/dist/keys.js?v=3f2a", scoped)).toBe("@scope/keys@1.2.0/dist/keys.js"); + expect(moduleIdOf("/elsewhere/node_modules/keys/index.js", none)).toBe("keys/index.js"); }); - test("is the path from the root without a named package", async () => { - expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", none)).toBe("src/keys.ts"); - expect(moduleIdOf("/libraries/shared/keys.ts?import", "/app", none)).toBe("../libraries/shared/keys.ts"); + test("is null outside a named package", async () => { + expect(moduleIdOf("/app/src/keys.ts?t=1712", none)).toBe(null); + expect(moduleIdOf("/libraries/shared/keys.ts", packages({ "/libraries": null }))).toBe(null); }); test("uses forward slashes", async () => { - expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", none)).toBe("src/keys.ts"); - expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", packages({ "C:/app": "app" }))).toBe("app/src/keys.ts"); + expect(moduleIdOf("C:\\app\\src\\keys.ts", packages({ "C:/app": { name: "app", version: "1.0.0" } }))).toBe("app@1.0.0/src/keys.ts"); }); }); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index b51acc34..c195b8df 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -16,25 +16,23 @@ export type Identified = { readonly code: string; readonly map: SourceMap }; /** A dependency's path inside the last `node_modules`: its package name and subpath. */ const dependency = /\/node_modules\/(?!.*\/node_modules\/)(.+)$/; -/** The name a directory's package.json gives: undefined without one, null for one without a name. */ -export type PackageNameOf = (directory: string) => string | null | undefined; +/** What a directory's package.json gives: undefined without one, null for one without a name. */ +export type PackageOf = (directory: string) => { readonly name: string; readonly version?: string } | null | undefined; /** - * A module's id: the name of the nearest package.json above it that has one and its path in that package, else for a - * dependency its package name and subpath, else its path from `root`. Without a query and with forward slashes, so it's - * the same on every machine and names no directory outside its package. + * A module's id: the name and version of the nearest package.json above it that has a name, and its path in that + * package, else for a dependency its package name and subpath; null outside both. Without a query and with forward + * slashes, so it's the same on every machine and names no directory outside its package. Copies of one package at + * different versions have different ids. */ -export const moduleIdOf = (file: string, root: string, packageNameOf: PackageNameOf): string => { +export const moduleIdOf = (file: string, packageOf: PackageOf): string | null => { const path = (file.split("?")[0] ?? file).replace(/\\/g, "/"); for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { - const name = packageNameOf(directory); - if (typeof name === "string") return `${name}/${posix.relative(directory, path)}`; + const found = packageOf(directory); + if (found !== null && found !== undefined) return `${found.name}${found.version === undefined ? "" : `@${found.version}`}/${posix.relative(directory, path)}`; if (posix.dirname(directory) === directory) break; } - const found = dependency.exec(path)?.[1]; - if (found !== undefined) return found; - const relative = posix.relative(root.replace(/\\/g, "/"), path); - return posix.isAbsolute(relative) ? posix.basename(path) : relative; + return dependency.exec(path)?.[1] ?? null; }; /** Whether a binding named `name` is declared in the module's scope: at top level, or as a `var` in any of its blocks. */ @@ -120,9 +118,10 @@ const mayDeclare = /\.(create|render|changes)\(/; * * The same bindings initialized by a constructor call of the package's `System`, `Rule`, `Finalizer` or `Index`, imported under * any local name, give the call's declaration their id, which the constructor takes from the map: a kernel's call as - * the kernel transform wrapped it too. Returns null when nothing is identified. + * the kernel transform wrapped it too. Returns null when nothing is identified, and a failure when something is but the + * module has no id (`moduleIdOf`). */ -export const identify = async (code: string, id: string, parse: Parse, resolve: ResolveNamespace): Promise => { +export const identify = async (code: string, id: string | null, parse: Parse, resolve: ResolveNamespace): Promise => { const symbols = code.includes("Symbol("); const items = mayDeclare.test(code); if (!symbols && !items) return null; @@ -145,6 +144,7 @@ export const identify = async (code: string, id: string, parse: Parse, resolve: } } if (found.length === 0 || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; + if (id === null) return { failure: "this module's keys and systems need ids, which name its package, but no package.json above it gives a name: give its package one" }; const identifier = uniqueName(code, "identify$"); const insertions: Insertion[] = []; for (const { value, binding } of found) { diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index af3b8398..a4614c6d 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -6,7 +6,7 @@ import { isAbsolute } from 'node:path'; import type { Plugin, Rollup, ViteDevServer } from 'vite'; import { estreeOf } from './estree.js'; import { describeFailure } from './failure.js'; -import { identify as identifyModule, moduleIdOf, type PackageNameOf } from './identify.js'; +import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; import { passInputSchemas, type InputHost } from './input-schemas.js'; @@ -31,23 +31,24 @@ const loadFile = async (file: string): Promise<{ readonly code: string; readonly return { code: result.code, locate: result.map === undefined ? undefined : locateThroughMap(result.code, result.map.mappings) }; }; -/** The name each directory's package.json gives, read once per directory. */ -const packageNames = (): PackageNameOf => { - const names = new Map(); +/** The name and version each directory's package.json gives, read once per directory. */ +const packages = (): PackageOf => { + const found = new Map>(); return directory => { - if (names.has(directory)) return names.get(directory); - let name: string | null | undefined; + if (found.has(directory)) return found.get(directory); + let entry: ReturnType; try { const json: unknown = JSON.parse(readFileSync(`${directory}/package.json`, "utf8")); - const named: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; - name = typeof named === "string" && named !== "" ? named : null; + const name: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; + const version: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "version") : undefined; + entry = typeof name !== "string" || name === "" ? null : typeof version === "string" && version !== "" ? { name, version } : { name }; } catch { // No package.json here, or none that parses. - name = undefined; + entry = undefined; } - names.set(directory, name); - return name; + found.set(directory, entry); + return entry; }; }; @@ -170,11 +171,10 @@ export const kernelPlugin = (): Plugin[] => [inputPlugin(), compilePlugin()]; const compilePlugin = (): Plugin => { let serve = true; - let root = process.cwd(); /** Whether an earlier instance of the plugin is in the same bundle. */ let repeated = false; let server: ViteDevServer | undefined; - const packageNameOf = packageNames(); + const packageOf = packages(); /** * A linker per environment, since client and SSR builds resolve imports under different conditions. Each resolves * through its environment's latest transform. @@ -222,7 +222,6 @@ const compilePlugin = (): Plugin => { config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => kernelPlugin() } }), configResolved: config => { serve = config.command === "serve"; - root = config.root; repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; }, configureServer: devServer => { @@ -264,10 +263,13 @@ const compilePlugin = (): Plugin => { async transform(code, id, options) { if (repeated || id.includes("\0") || !scripts.test(id.split("?")[0] ?? id)) return null; const parse = (input: string) => estreeOf(this.parse(input)); - const identify = (input: string) => identifyModule(input, moduleIdOf(id, root, packageNameOf), parse, async (source, namespace) => { - const resolved = await this.resolve(source, id); - return resolved !== null && (resolved.external ? source === packageName : namespaceCopyOf(resolved.id, namespace) !== null); - }); + const identify = async (input: string) => { + const identified = await identifyModule(input, moduleIdOf(id, packageOf), parse, async (source, namespace) => { + const resolved = await this.resolve(source, id); + return resolved !== null && (resolved.external ? source === packageName : namespaceCopyOf(resolved.id, namespace) !== null); + }); + return identified !== null && "failure" in identified ? this.error(identified.failure) : identified; + }; const ssr = options?.ssr === true; const linker = linkerFor(ssr, this); const hashes: string[] = []; diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index c56cdcd6..ad5a43fc 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -76,6 +76,6 @@ describe("systems, rules and finalizers of one name", () => { }); it("label a system without a name by its binding and the id the plugin gave it", async () => { await using world = createWorld({ [workers]: 0 }); - expect((await runReported(world, failOnce)).failures.map(failure => failure.name)).toEqual(["failOnce (@adobe/ecs/src/implementation/world/names.test.ts#failOnce)"]); + expect((await runReported(world, failOnce)).failures.map(failure => failure.name)).toEqual([expect.stringMatching(/^failOnce \(@adobe\/ecs@[^/]+\/src\/implementation\/world\/names\.test\.ts#failOnce\)$/)]); }); }); diff --git a/packages/ecs/src/implementation/world/reevaluation.test.ts b/packages/ecs/src/implementation/world/reevaluation.test.ts index 394be711..a7acfcdd 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test.ts @@ -16,7 +16,7 @@ const broken = System.create({ name: "reevaluation/bump" }, () => { it("labels systems that share a name by their ids", async () => { await using world = createWorld(); const { failures } = await runReported(world, broken); - expect(failures.map(({ name }) => name)).toEqual(["reevaluation/bump (@adobe/ecs/src/implementation/world/reevaluation.test.ts#broken)"]); + expect(failures.map(({ name }) => name)).toEqual([expect.stringMatching(/^reevaluation\/bump \(@adobe\/ecs@[^/]+\/src\/implementation\/world\/reevaluation\.test\.ts#broken\)$/)]); }); it("matches the keys and systems of a module evaluated again by id", async () => { From 6c98193fe3ee8594a2511196f7cd807214e8a1c7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 15:53:59 -0700 Subject: [PATCH 050/127] ecs: the plugin registers what declare module blocks declare; types follow Schema.Type and namespaces The pre-strip pass reads Components, Resources and Traits in each declare module "@adobe/ecs" block and emits each key's registration after it: the schema of its type, its default and its traits. Types are read through any alias, namespace or re-export, into the package's sources or declarations, and a type named by Schema.Type has that schema, so the compiler no longer lists the package's types. Built-in resources augment their own modules. Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 121 ++++ .../implementation/compiler/augmentations.ts | 206 ++++++ .../implementation/compiler/identify.test.ts | 6 +- .../compiler/input-schemas.test.ts | 41 +- .../implementation/compiler/input-schemas.ts | 369 +---------- .../ecs/src/implementation/compiler/paths.ts | 28 +- .../ecs/src/implementation/compiler/plugin.ts | 49 +- .../compiler/type-host.test-helper.ts | 28 + .../implementation/compiler/type-schemas.ts | 621 ++++++++++++++++++ .../ecs/src/interface/registry/declare.ts | 18 + .../ecs/src/interface/report/components.ts | 2 +- .../ecs/src/interface/resources/always.ts | 6 +- .../ecs/src/interface/resources/cadence.ts | 6 +- packages/ecs/src/interface/resources/clock.ts | 6 +- .../ecs/src/interface/resources/gpu-device.ts | 6 +- packages/ecs/src/interface/resources/wake.ts | 6 +- .../ecs/src/interface/resources/workers.ts | 6 +- 17 files changed, 1124 insertions(+), 401 deletions(-) create mode 100644 packages/ecs/src/implementation/compiler/augmentations.test.ts create mode 100644 packages/ecs/src/implementation/compiler/augmentations.ts create mode 100644 packages/ecs/src/implementation/compiler/type-host.test-helper.ts create mode 100644 packages/ecs/src/implementation/compiler/type-schemas.ts create mode 100644 packages/ecs/src/interface/registry/declare.ts diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts new file mode 100644 index 00000000..6007cb07 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -0,0 +1,121 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { augmentationInsertions } from './augmentations.js'; +import { insertInto } from './source-map.js'; +import { hostOf } from './type-host.test-helper.js'; +import { moduleOf } from './type-schemas.js'; + +const header = `import { Entity, type F32, type U32, type Vec3 } from "@adobe/ecs";\n`; + +/** The module as the pass leaves it, or its failure's message. */ +const passed = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = header + body; + const host = hostOf(files); + const result = await augmentationInsertions(moduleOf("/src/keys.ts", code, host.parse(code, "/src/keys.ts")), host); + if (result === null) return null; + if ("failure" in result) return `failure: ${result.failure.message}`; + return insertInto(code, result.insertions, "/src/keys.ts").code; +}; + +/** The registrations the pass emits, one per line, without the import. */ +const registrations = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = await passed(body, files); + if (code === null || code.startsWith("failure: ")) return code; + return code.split("\n").filter(line => /^declare(Component|Resource|Trait)\$\(/.test(line)); +}; + +const keys = (...names: readonly string[]) => names.map(name => `const ${name} = Symbol();`).join("\n") + "\n"; + +describe("augmentationInsertions", () => { + test("registers each component and resource with the schema of its type", async () => { + expect(await registrations(`${keys("position", "done", "label", "speed", "count")}declare module "@adobe/ecs" { + interface Components { [position]: { type: Vec3 }; [done]: { type: boolean }; [label]: { type: string } } + interface Resources { [speed]: { type: F32 | null }; [count]: { type: U32 | null } } +}`)).toEqual([ + `declareComponent$(position, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3} });`, + `declareComponent$(done, { type: {"type":"boolean"} });`, + `declareComponent$(label, { type: {"type":"string"} });`, + `declareResource$(speed, { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, + `declareResource$(count, { type: {"anyOf":[{"type":"integer","minimum":0,"maximum":4294967295},{"type":"null"}]} });`, + ]); + }); + test("imports the registry's hooks it calls", async () => { + const code = await passed(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n")[0]).toBe(`import { declareComponent as declareComponent$ } from "declare.js";`); + }); + test("gives a default as a literal, or as the value `typeof` names, in the schema", async () => { + expect(await registrations(`${keys("gravity", "mode", "selected", "origin")}declare module "@adobe/ecs" { + interface Resources { + [gravity]: { type: F32; default: -9.8 }; + [mode]: { type: "all" | "done"; default: "all" }; + [selected]: { type: Entity; default: typeof Entity.none }; + [origin]: { type: Vec3; default: [0, 0, 0] }; + } +}`)).toEqual([ + `declareResource$(gravity, { type: {"type":"number","precision":1,"default":-9.8} });`, + `declareResource$(mode, { type: {"anyOf":[{"const":"all"},{"const":"done"}],"default":"all"} });`, + `declareResource$(selected, { type: {"type":"integer","format":"entity","default":Entity.none} });`, + `declareResource$(origin, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, + ]); + }); + test("gives a value that isn't data no schema, and its default beside its traits", async () => { + const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; + expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}const empty = new Pool(); +declare module "@adobe/ecs" { + interface Components { [row]: { type: Row; thread: "main" } } + interface Resources { + [pool]: { type: Pool; default: typeof empty }; + [canvas]: { type: OffscreenCanvas | null; transfer: true }; + [callback]: { type: (() => void) | null }; + } +}`, files)).toEqual([ + `declareComponent$(row, { thread: "main" });`, + `declareResource$(pool, { default: empty });`, + `declareResource$(canvas, { transfer: true });`, + `declareResource$(callback, {});`, + ]); + }); + test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => { + expect(await registrations(`${keys("instanced", "host", "outline", "owner", "drawn", "total")}declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: true } + interface Components { + [host]: { type: true }; + [outline]: { type: number; within: typeof host; [instanced]: true }; + [owner]: { type: Entity; reference: "owner" }; + [drawn]: { type: true; derived: true }; + } + interface Resources { [total]: { type: F32; default: 0; reduce: "+" } } +}`)).toEqual([ + `declareTrait$(instanced);`, + `declareComponent$(host, { type: {"const":true} });`, + `declareComponent$(outline, { type: {"type":"number"}, within: host, [instanced]: true });`, + `declareComponent$(owner, { type: {"type":"integer","format":"entity"}, reference: "owner" });`, + `declareComponent$(drawn, { type: {"const":true}, derived: true });`, + `declareResource$(total, { type: {"type":"number","precision":1,"default":0}, reduce: "+" });`, + ]); + }); + test("follows a type named by Schema.Type and imported keys", async () => { + const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;\nexport const level = Symbol();` }; + expect(await registrations(`import { level, type Level } from "./units.js";\ndeclare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ + `declareComponent$(level, { type: {"type":"integer","minimum":0,"maximum":9} });`, + ]); + }); + test("registers after the block, its key and the value its default names, whichever comes last", async () => { + const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Vec3; default: typeof start } } }\nconst later = Symbol();\nconst start = [1, 2, 3] as const;\nconst after = 1;`); + expect(code?.split("\n").slice(-3)).toEqual([`const start = [1, 2, 3] as const;`, `declareResource$(later, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":start} });`, `const after = 1;`]); + }); + test("passes over modules without a declaration of the package", async () => { + expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null); + }); + test("fails, saying why, for what a declaration can't be", async () => { + const block = (body: string) => `declare module "@adobe/ecs" { ${body} }`; + expect(await passed(`const key = Symbol.for("key");\n${block(`interface Components { [key]: { type: number } }`)}`)).toContain("top-level `Symbol()` constant"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: number } }`)}`)).toContain("default"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); + expect(await passed(`import type { Entity as E } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Entity; default: typeof E.none } }`)}`)).toContain("type only"); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type"); + expect(await passed(block(`interface Traits { readonly layer: true }`))).toContain("keyed by a `Symbol()` constant"); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts new file mode 100644 index 00000000..dc00cbe1 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -0,0 +1,206 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from '../../interface/schema/schema.js'; +import type { PassResult } from './input-schemas.js'; +import type { Insertion } from './source-map.js'; +import { createConverter, isNotData, isThrown, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; + +/** The module emitted code imports its registry hooks from, for the module being transformed. */ +export type DeclareModuleOf = (file: string) => string; + +export type AugmentationHost = TypeHost & { readonly declareModule: DeclareModuleOf }; + +type Block = Extract; +type Interface = Extract; +type Member = Interface["body"]["body"][number]; +type Property = Extract; +type Value = string | number | boolean | null | readonly Value[] | { readonly [field: string]: Value }; + +const kinds = { Components: "component", Resources: "resource", Traits: "trait" } as const; +const isKind = (name: string): name is keyof typeof kinds => name in kinds; + +/** The interfaces of each top-level `declare module "@adobe/ecs"` block, with the block. */ +const augmentationsOf = (module: Module): readonly { readonly block: Block; readonly interfaces: readonly Interface[] }[] => + module.program.body.flatMap(statement => { + if (statement.type !== "TSModuleDeclaration" || statement.global !== false || statement.id.type !== "Literal" || statement.id.value !== "@adobe/ecs") return []; + const body = statement.body?.type === "TSModuleBlock" ? statement.body.body : []; + const interfaces = body.flatMap(inner => { + const declared = inner.type === "ExportNamedDeclaration" ? inner.declaration : inner; + return declared?.type === "TSInterfaceDeclaration" && isKind(declared.id.name) ? [declared] : []; + }); + return [{ block: statement, interfaces }]; + }); + +type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>; + +/** A `typeof` query's name and the properties read from it; null for `this`. */ +const qualifiedParts = (name: QueryName | Extract["left"]): readonly string[] | null => { + if (name.type === "Identifier") return [name.name]; + if (name.type !== "TSQualifiedName") return null; + const left = qualifiedParts(name.left); + return left === null ? null : [...left, name.right.name]; +}; + +/** The top-level statement holding a node of the module, by position. */ +const statementAt = (module: Module, node: Node): Statement | undefined => + module.program.body.find(statement => statement.start <= node.start && node.end <= statement.end); + +/** Whether a value matches a schema, as far as a literal default can be checked. */ +const matches = (schema: Schema, value: Value): boolean => { + if ("anyOf" in schema) return schema.anyOf.some(member => matches(member, value)); + if ("allOf" in schema) return schema.allOf.every(member => matches(member, value)); + if ("const" in schema) return JSON.stringify(schema.const) === JSON.stringify(value); + switch (schema.type) { + case "null": return value === null; + case "boolean": return typeof value === "boolean"; + case "string": return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? Infinity); + case "number": return typeof value === "number" && value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity); + case "integer": return typeof value === "number" && Number.isInteger(value) && ("format" in schema || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); + case "array": return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? Infinity) && value.every(item => matches(schema.items, item)); + case "object": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const required = schema.required ?? Object.keys(schema.properties); + return Object.entries(value).every(([field, item]) => field in schema.properties && matches(schema.properties[field] ?? { allOf: [] }, item)) + && required.every(field => field in value); + } + default: return false; + } +}; + +/** Whether a type, as written, allows `null`. */ +const allowsNullType = (type: TypeNode): boolean => + type.type === "TSNullKeyword" || (type.type === "TSUnionType" && type.types.some(allowsNullType)) || (type.type === "TSParenthesizedType" && allowsNullType(type.typeAnnotation)); + +const allowsNullSchema = (schema: Schema): boolean => + ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); + +/** + * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: after the + * block, each component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and + * its other traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant, + * of the module or one it imports. A literal default is checked against the schema; `default: typeof value` names a + * value, which the registration passes. Returns null when the module declares nothing. + */ +export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => { + const { code, file } = module; + if (!code.includes("@adobe/ecs") || !code.includes("declare module")) return null; + const augmentations = augmentationsOf(module); + if (augmentations.length === 0) return null; + const converter = createConverter(host, file); + const { fail } = converter; + const insertions: Insertion[] = []; + const names = { component: "declareComponent$", resource: "declareResource$", trait: "declareTrait$" }; + const used = new Set<"component" | "resource" | "trait">(); + + /** The end of the top-level statement declaring a local binding, which the registration comes after; 0 for an import. */ + const endOfLocal = (name: string): number => { + const local = module.constants.get(name); + return local === undefined ? 0 : statementAt(module, local)?.end ?? 0; + }; + + /** Fails unless `name` is a top-level `Symbol()` constant, here or where it's imported from. */ + const requireSymbol = async (name: string, at: Node, what: string): Promise => { + const found = await converter.constantNamed([name], module); + let init = found?.declaration.init ?? null; + while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) init = init.expression; + const isSymbol = init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol"; + if (!isSymbol) fail(module, at, `${what} ${name} is a top-level \`Symbol()\` constant, of this module or one it imports`); + }; + + /** A trait's or default's value as code: a literal, or `typeof` a value, which names it; and that value's local binding. */ + const valueText = async (type: TypeNode, what: string): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string }> => { + if (type.type === "TSTypeQuery") { + const named = type.exprName.type === "TSImportType" ? null : qualifiedParts(type.exprName); + const [first] = named ?? []; + if (named === null || first === undefined || type.typeArguments !== null) return fail(module, type, `${what} is a literal type, or \`typeof\` a value`); + const imported = module.imports.get(first); + const declaredHere = module.constants.has(first) || module.program.body.some(statement => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return (declared?.type === "VariableDeclaration" && declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === first)) + || ((declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") && declared.id?.name === first); + }); + const global = imported === undefined && !declaredHere; + if (imported !== undefined && !imported.value) fail(module, type, `${what} names ${first}, imported as a type only: import it as a value, which the registration passes`); + return { text: named.join("."), local: global || imported !== undefined ? undefined : first }; + } + const value = await converter.valueOfType(type, module, new Set()); + return { text: JSON.stringify(value), value }; + }; + + try { + for (const { block, interfaces } of augmentations) { + for (const declared of interfaces) { + const interfaceName = declared.id.name; + if (!isKind(interfaceName)) continue; + const kind = kinds[interfaceName]; + for (const member of declared.body.body) { + if (member.type !== "TSPropertySignature") { + if (member.type === "TSIndexSignature" || member.type === "TSMethodSignature") fail(module, member, `${interfaceName}'s members are keys and their declarations`); + continue; + } + const { key } = member; + if (kind === "trait") { + // String-named traits are the package's own. + if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); + await requireSymbol(key.name, member, "a trait's key"); + used.add("trait"); + insertions.push({ at: Math.max(block.end, endOfLocal(key.name)), text: `\n${names.trait}(${key.name});` }); + continue; + } + if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); + await requireSymbol(key.name, member, `a ${kind}'s key`); + const declaration = member.typeAnnotation?.typeAnnotation; + if (declaration?.type !== "TSTypeLiteral") return fail(module, member, `${key.name}'s declaration is a type literal of its traits: \`{ type: ... }\``); + converter.site.at = member.start; + let at = Math.max(block.end, endOfLocal(key.name)); + let typeNode: TypeNode | undefined; + let defaultNode: TypeNode | undefined; + const traits: string[] = []; + let thread = false; + for (const trait of declaration.members) { + if (trait.type !== "TSPropertySignature" || trait.typeAnnotation === null) return fail(module, trait, `${key.name}'s traits are properties with values`); + const property: Property = trait; + const value = trait.typeAnnotation.typeAnnotation; + const name = !property.computed && property.key.type === "Identifier" ? property.key.name + : property.computed && property.key.type === "Identifier" ? null + : fail(module, trait, `${key.name}'s traits are named, or keyed by a trait's \`Symbol()\` constant`); + if (name === "type") typeNode = value; + else if (name === "default") defaultNode = value; + else { + const text = await valueText(value, `${key.name}'s trait`); + if (text.local !== undefined) at = Math.max(at, endOfLocal(text.local)); + if (name === "thread") thread = true; + traits.push(name === null ? `[${property.key.type === "Identifier" ? property.key.name : ""}]: ${text.text}` : `${name}: ${text.text}`); + } + } + if (typeNode === undefined) return fail(module, member, `${key.name} declares its type: \`{ type: ... }\``); + let schema: Schema | undefined; + try { + schema = await converter.convert(typeNode, module, new Set(), "declaration"); + } + catch (error) { + if (!isNotData(error)) throw error; + if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); + } + const start = defaultNode === undefined ? undefined : await valueText(defaultNode, `${key.name}'s default`); + if (start?.local !== undefined) at = Math.max(at, endOfLocal(start.local)); + if (schema !== undefined && start?.value !== undefined && !matches(schema, start.value)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); + const allowsNull = schema === undefined ? allowsNullType(typeNode) : allowsNullSchema(schema) || "default" in schema; + if (kind === "resource" && start === undefined && !allowsNull) fail(module, member, `resource ${key.name} declares the value it starts at: \`default: ...\`, which a type allowing null may leave out`); + const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; + if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); + parts.push(...traits); + used.add(kind); + insertions.push({ at, text: `\n${names[kind]}(${key.name}, ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); + } + } + } + } + catch (error) { + if (isThrown(error)) return { failure: error.typeFailure, dependencies: [...converter.read] }; + throw error; + } + if (insertions.length === 0) return null; + const imports = [...used].map(kind => `declare${kind[0]?.toUpperCase()}${kind.slice(1)} as ${names[kind]}`); + insertions.unshift({ at: 0, text: `import { ${imports.join(", ")} } from ${JSON.stringify(host.declareModule(file))};\n` }); + return { insertions, dependencies: [...converter.read] }; +}; diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index e842236e..06164064 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -10,7 +10,11 @@ import { decodeMappings } from './source-map.js'; const parse = (code: string) => estreeOf(parseAst(code)); /** Imports from the package, and from `./system.js` for `System` alone, are the package's namespaces. */ const resolve = async (source: string, namespace: string) => source === "@adobe/ecs" || (source === "./system.js" && namespace === "System"); -const identify = (code: string) => identifyModule(code, "src/keys.ts", parse, resolve); +const identify = async (code: string) => { + const result = await identifyModule(code, "src/keys.ts", parse, resolve); + if (result !== null && "failure" in result) throw new Error(result.failure); + return result; +}; /** The calls the rewrite wraps around each identified `Symbol(...)`, as `[symbol source, id]`. */ const wrapped = (code: string | undefined): readonly (readonly [string, string])[] => diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts index cbf6fda8..e2132ff3 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.test.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -1,16 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, test } from 'vitest'; -import { parseAst } from 'vite'; -import { passInputSchemas, type InputHost } from './input-schemas.js'; - -/** Files by path; `@adobe/ecs` is the package, which also exports `System`. */ -const hostOf = (files: { readonly [path: string]: string }): InputHost => ({ - parse: (code, file) => parseAst(code, { lang: "ts" }, file), - resolve: async specifier => specifier.startsWith("./") && `/src/${specifier.slice(2).replace(/\.js$/, ".ts")}` in files ? `/src/${specifier.slice(2).replace(/\.js$/, ".ts")}` : null, - load: async file => files[file] ?? "", - isPackage: async specifier => specifier === "@adobe/ecs", - isSystem: async specifier => specifier === "@adobe/ecs", -}); +import { passInputSchemas } from './input-schemas.js'; +import { hostOf } from './type-host.test-helper.js'; const header = `import { System, type Entity, type Vec2, type F32, type I32 } from "@adobe/ecs";\n`; @@ -82,6 +73,34 @@ describe("passInputSchemas", () => { }, }]); }); + test("follows a type named by Schema.Type to its schema, written as a literal type or `typeof` a constant", async () => { + const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;\nconst title = { type: "string", maxLength: 64 } as const;\nexport type Title = Schema.Type;` }; + expect(await schemasOf([ + `import type { Level, Title } from "./units.js";`, + `type Cell = readonly [Level, Level];`, + `System.create({}, (_c, input: { level: Level; title: Title; cell: Cell }) => {});`, + ].join("\n"), files)).toEqual([{ + type: "object", + properties: { + level: { type: "integer", minimum: 0, maximum: 9 }, + title: { type: "string", maxLength: 64 }, + cell: { type: "array", items: { type: "integer", minimum: 0, maximum: 9 }, minItems: 2, maxItems: 2 }, + }, + }]); + }); + test("reads the package's types through any namespace", async () => { + expect(await schemasOf(`import * as ecs from "@adobe/ecs";\nSystem.create({}, (_c, input: { at: ecs.Vec3; cell: ecs.Vec2.U32 }) => {});`)).toEqual([{ + type: "object", + properties: { + at: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 }, + cell: { type: "array", items: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF }, minItems: 2, maxItems: 2 }, + }, + }]); + }); + test("takes any value for a class a module exports", async () => { + const files = { "/src/row.ts": `export class Row extends HTMLElement {}` }; + expect(await schemasOf(`import { Row } from "./row.js";\nSystem.create({}, (_c, input: { row: Row }) => {});`, files)).toEqual([{ type: "object", properties: { row: { allOf: [] } } }]); + }); test("takes any value for a global class or unknown", async () => { expect(await schemasOf(`System.create({}, (_c, input: { canvas: OffscreenCanvas; bitmap: ImageBitmap; element: HTMLCanvasElement; anything: unknown }) => {});`)).toEqual([ { type: "object", properties: { canvas: { allOf: [] }, bitmap: { allOf: [] }, element: { allOf: [] }, anything: { allOf: [] } } }, diff --git a/packages/ecs/src/implementation/compiler/input-schemas.ts b/packages/ecs/src/implementation/compiler/input-schemas.ts index 9d4656f3..3dca2f2f 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.ts @@ -1,359 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { parseAst } from 'vite'; -import { Entity } from '../../interface/entity/entity.js'; -import * as math from '../../interface/math/index.js'; -import type { Schema } from '../../interface/schema/schema.js'; import type { Failure } from './failure.js'; -import { insertInto, type SourceMap } from './source-map.js'; +import { insertInto, type Insertion, type SourceMap } from './source-map.js'; +import { createConverter, isThrown, moduleOf, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; -/** A TypeScript module's syntax, types included. */ -export type TypedProgram = ReturnType; -type Statement = TypedProgram["body"][number]; -type Alias = Extract; -type Interface = Extract; -type Import = Extract; -type Declarator = Extract["declarations"][number]; -type TypeNode = Alias["typeAnnotation"]; -type Member = Interface["body"]["body"][number]; -type Named = Extract["typeName"]; -/** Any node of a module, by the fields this pass reads. */ -type Node = { readonly type: string; readonly start: number; readonly end: number }; - -/** Parses a TypeScript module, types included. */ -export type ParseTyped = (code: string, file: string) => TypedProgram; -/** The file an import specifier resolves to from `importer`, or null when it's none this pass can read. */ -export type ResolveFile = (specifier: string, importer: string) => Promise; -/** A file's code. */ -export type LoadFile = (file: string) => Promise; -/** Whether an import specifier, resolved from `importer`, is one of the package's public modules. */ -export type IsPackage = (specifier: string, importer: string) => Promise; /** Whether an import specifier, resolved from `importer`, is a module of the package exporting `System`. */ export type IsSystem = (specifier: string, importer: string) => Promise; -export type InputHost = { readonly parse: ParseTyped; readonly resolve: ResolveFile; readonly load: LoadFile; readonly isPackage: IsPackage; readonly isSystem: IsSystem }; +export type InputHost = TypeHost & { readonly isSystem: IsSystem }; /** Code with each system's input schema passed to its `create`, the files whose types it read, or why it failed. */ export type InputResult = | { readonly code: string; readonly map: SourceMap; readonly dependencies: readonly string[] } | { readonly failure: Failure; readonly dependencies: readonly string[] }; -/** Thrown inside the pass, and returned as its failure. */ -type Thrown = { readonly inputFailure: Failure }; -const isThrown = (value: unknown): value is Thrown => typeof value === "object" && value !== null && "inputFailure" in value; - -/** A value of any type: a global class, such as the platform's, or `unknown`. */ -const anything: Schema = { allOf: [] }; - -const supported = "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, the package's `Entity` and math types, a global class or `unknown`"; - -/** The schema of the package's type at `path`, such as `["Vec2", "I32"]`: its namespace's `schema`. */ -const packageSchema = (path: readonly string[]): Schema | undefined => { - const [first, ...rest] = path; - let value: unknown = first === "Entity" ? Entity : first === undefined ? undefined : Reflect.get(math, first); - for (const name of rest) value = typeof value === "object" && value !== null ? Reflect.get(value, name) : undefined; - const schema: unknown = typeof value === "object" && value !== null ? Reflect.get(value, "schema") : undefined; - return isSchema(schema) ? schema : undefined; -}; - -// The package's own schemas are its exported constants. -const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null; - -/** A parsed module: its declarations of types and its top-level constants by name, and its imports by local name. */ -type Module = { - readonly file: string; - readonly code: string; - readonly types: ReadonlyMap; - readonly constants: ReadonlyMap; - readonly imports: ReadonlyMap; - readonly exports: readonly Statement[]; -}; - -const declarationOf = (statement: Statement): Alias | Interface | null => { - const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - return declared?.type === "TSTypeAliasDeclaration" || declared?.type === "TSInterfaceDeclaration" ? declared : null; -}; - -/** The top-level constants a statement declares. */ -const constantsOf = (statement: Statement): readonly Declarator[] => { - const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - return declared?.type === "VariableDeclaration" && declared.kind === "const" ? declared.declarations.filter(declarator => declarator.id.type === "Identifier") : []; -}; - -const nameOfDeclarator = (declarator: Declarator): string => declarator.id.type === "Identifier" ? declarator.id.name : ""; - -const importsOf = (statement: Import): [string, { readonly source: string; readonly imported: string | null }][] => - statement.specifiers.map(specifier => [specifier.local.name, { - source: statement.source.value, - // A namespace import names the module itself. - imported: specifier.type === "ImportSpecifier" ? (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) - : specifier.type === "ImportDefaultSpecifier" ? "default" : null, - }]); - -const moduleOf = (file: string, code: string, program: TypedProgram): Module => { - const types = new Map(); - const constants = new Map(); - const imports = new Map(); - for (const statement of program.body) { - const declared = declarationOf(statement); - if (declared !== null) types.set(declared.id.name, declared); - for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); - if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); - } - return { file, code, types, constants, imports, exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") }; -}; - -/** The line and column of an offset, 1-based line, 0-based column, as a failure's position. */ -const positionOf = (code: string, offset: number): { readonly line: number; readonly column: number } => { - const before = code.slice(0, offset); - const line = before.split("\n").length; - return { line, column: offset - (before.lastIndexOf("\n") + 1) }; -}; - -/** The module being transformed, and where in it the parameter whose type is being converted is. */ -type Site = { readonly root: string; at: number }; - -/** Fails at `node`, or for a type of another module at the parameter, with a frame naming where in that module. */ -const failAt = (site: Site, module: Module, node: Node, message: string): never => { - const { line, column } = positionOf(module.code, node.start); - const inRoot = module.file === site.root; - const thrown: Thrown = { inputFailure: { message, position: inRoot ? node.start : site.at, frames: inRoot ? [] : [{ name: "input type", file: module.file, line, column }] } }; - throw thrown; -}; - -/** Converts input types to schemas, reading the modules they're declared in through `host`. */ -const createConverter = (host: InputHost, root: string) => { - const site: Site = { root, at: 0 }; - const fail = (module: Module, node: Node, message: string): never => failAt(site, module, node, message); - const modules = new Map>(); - const read = new Set(); - const load = (file: string): Promise => { - const found = modules.get(file); - if (found !== undefined) return found; - const loading = (async () => { - read.add(file); - const code = await host.load(file); - return moduleOf(file, code, host.parse(code, file)); - })(); - modules.set(file, loading); - return loading; - }; - - /** - * What `name` names in `module`'s exports, of `kind`, following re-exports: a type's declaration, or a constant's - * declarator; null when it exports none by that name. - */ - const exported = async (module: Module, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | "package" | null> => { - const key = `${module.file}#${name}`; - if (seen.has(key)) return null; - seen.add(key); - // A module's own declaration comes before what it re-exports. - const own = kind(module).get(name); - if (own !== undefined) return { module, declaration: own }; - for (const statement of module.exports) { - if (statement.type === "ExportNamedDeclaration") { - for (const specifier of statement.specifiers) { - const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; - if (exportedName !== name) continue; - const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; - if (statement.source !== null) return await fromSource(module, statement.source.value, localName, kind, seen); - const local = kind(module).get(localName); - if (local !== undefined) return { module, declaration: local }; - const imported = module.imports.get(localName); - if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, kind, seen); - } - } - else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { - const found = await fromSource(module, statement.source.value, name, kind, seen); - if (found !== null) return found; - } - } - return null; - }; - - const fromSource = async (module: Module, source: string, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | "package" | null> => { - if (await host.isPackage(source, module.file)) return "package"; - const file = await host.resolve(source, module.file); - if (file === null) return null; - const target = await load(file); - return target === null ? null : await exported(target, name, kind, seen); - }; - const typesOf = (module: Module): ReadonlyMap => module.types; - const constantsIn = (module: Module): ReadonlyMap => module.constants; - - const nameParts = (name: Named): readonly string[] => - name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...nameParts(name.left), name.right.name] : []; - - /** The schema of a type, read in `module`; `active` holds the declarations being converted, so recursion fails. */ - const convert = async (type: TypeNode, module: Module, active: ReadonlySet): Promise => { - switch (type.type) { - case "TSNumberKeyword": return { type: "number" }; - case "TSStringKeyword": return { type: "string" }; - case "TSBooleanKeyword": return { type: "boolean" }; - case "TSNullKeyword": return { type: "null" }; - case "TSUnknownKeyword": return anything; - case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active); - case "TSTypeOperator": - return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active) : fail(module, type, `\`${type.operator}\` isn't an input type: an input is ${supported}`); - case "TSLiteralType": { - const { literal } = type; - if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value }; - if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return { const: -literal.argument.value }; - return fail(module, type, "an input's literal type is a string, number or boolean"); - } - case "TSUnionType": { - const types = type.types.filter(member => member.type !== "TSUndefinedKeyword"); - if (types.length !== type.types.length) return fail(module, type, "an input field that may be missing is optional (`field?:`) rather than `| undefined`"); - const schemas = await Promise.all(types.map(member => convert(member, module, active))); - return { anyOf: schemas }; - } - case "TSArrayType": return { type: "array", items: await convert(type.elementType, module, active) }; - case "TSTupleType": { - const elements = type.elementTypes; - const first = elements[0]; - if (first === undefined) return fail(module, type, "an input's tuple has elements"); - const schemas = await Promise.all(elements.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" - ? fail(module, element, "an input's tuple has a fixed length, and its elements no names") : convert(element, module, active))); - const items = schemas[0]; - if (items === undefined || schemas.some(schema => JSON.stringify(schema) !== JSON.stringify(items))) return fail(module, type, "an input's tuple has elements of one type"); - return { type: "array", items, minItems: elements.length, maxItems: elements.length }; - } - case "TSTypeLiteral": return await objectOf(type.members, [], module, active, type); - case "TSIntersectionType": { - const parts = await Promise.all(type.types.map(member => convert(member, module, active))); - const properties: { [field: string]: Schema } = {}; - const required: string[] = []; - let optional = false; - for (const part of parts) { - if (!("type" in part) || part.type !== "object") return fail(module, type, "an input's intersection joins object types"); - for (const [field, schema] of Object.entries(part.properties)) properties[field] = schema; - if (part.required === undefined) required.push(...Object.keys(part.properties)); - else { - optional = true; - required.push(...part.required); - } - } - return optional ? { type: "object", properties, required } : { type: "object", properties }; - } - case "TSTypeReference": return await referenced(type, module, active); - case "TSIndexedAccessType": return await valuesOf(type, module); - default: return fail(module, type, `an input is ${supported}`); - } - }; - - const objectOf = async (members: readonly Member[], inherited: readonly Schema[], module: Module, active: ReadonlySet, at: Node): Promise => { - const properties: { [field: string]: Schema } = {}; - const required: string[] = []; - let optional = false; - for (const schema of inherited) { - if (!("type" in schema) || schema.type !== "object") return fail(module, at, "an input interface extends object types"); - for (const [field, property] of Object.entries(schema.properties)) properties[field] = property; - if (schema.required === undefined) required.push(...Object.keys(schema.properties)); - else { - optional = true; - required.push(...schema.required); - } - } - for (const member of members) { - if (member.type !== "TSPropertySignature" || member.computed) return fail(module, member, "an input's fields are named properties"); - const { key } = member; - const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : fail(module, member, "an input's fields are named properties"); - if (member.typeAnnotation === null) return fail(module, member, `input field "${field}" has a type`); - properties[field] = await convert(member.typeAnnotation.typeAnnotation, module, active); - if (member.optional) optional = true; - else required.push(field); - } - return optional ? { type: "object", properties, required } : { type: "object", properties }; - }; - - const declared = async (declaration: Alias | Interface, module: Module, active: ReadonlySet, at: Node): Promise => { - if (active.has(declaration)) return fail(module, at, `input type ${declaration.id.name} refers to itself`); - if (declaration.typeParameters !== null) return fail(module, at, `input type ${declaration.id.name} is generic, which an input type isn't`); - const inner = new Set(active).add(declaration); - if (declaration.type === "TSTypeAliasDeclaration") return await convert(declaration.typeAnnotation, module, inner); - const inherited = await Promise.all(declaration.extends.map(async heritage => { - const { expression } = heritage; - if (expression.type !== "Identifier" || heritage.typeArguments !== null) return fail(module, heritage, "an input interface extends named, non-generic types"); - return await named([expression.name], module, inner, heritage); - })); - return await objectOf(declaration.body.body, inherited, module, inner, declaration); - }; - - /** The schema of the type `path` names in `module`. */ - const named = async (path: readonly string[], module: Module, active: ReadonlySet, at: Node): Promise => { - const [first, ...rest] = path; - if (first === undefined) return fail(module, at, `an input is ${supported}`); - const local = module.types.get(first); - if (local !== undefined && rest.length === 0) return await declared(local, module, active, at); - const imported = module.imports.get(first); - if (imported !== undefined) { - if (await host.isPackage(imported.source, module.file)) { - const schema = packageSchema(imported.imported === null ? rest : [imported.imported, ...rest]); - return schema ?? fail(module, at, `${path.join(".")} is no type of the package an input can take: it takes Entity and the math types`); - } - if (imported.imported === null || rest.length > 0) return fail(module, at, `input type ${path.join(".")} is read through a namespace, which this pass doesn't follow; import it by name`); - const found = await fromSource(module, imported.source, imported.imported, typesOf, new Set()); - if (found === "package") { - return packageSchema([imported.imported]) ?? fail(module, at, `${imported.imported} is no type of the package an input can take: it takes Entity and the math types`); - } - if (found === null) return fail(module, at, `input type ${first} isn't a type alias or interface its module exports, which this pass can read`); - return await declared(found.declaration, found.module, active, at); - } - // Neither declared nor imported, it's a global, which TypeScript checked exists: a class, such as the platform's. - return anything; - }; - - const referenced = async (type: Extract, module: Module, active: ReadonlySet): Promise => { - const path = nameParts(type.typeName); - const parameters = type.typeArguments?.params ?? []; - const [only] = path; - if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) { - const [first, second] = parameters; - if ((only === "ReadonlyArray" || only === "Array") && parameters.length === 1 && first !== undefined) return { type: "array", items: await convert(first, module, active) }; - if ((only === "ReadonlySet" || only === "Set") && parameters.length === 1 && first !== undefined) return { type: "set", items: await convert(first, module, active) }; - if ((only === "ReadonlyMap" || only === "Map") && parameters.length === 2 && first !== undefined && second !== undefined) { - return { type: "map", keys: await convert(first, module, active), values: await convert(second, module, active) }; - } - } - if (parameters.length > 0) return fail(module, type, `input type ${path.join(".")} takes type arguments, which an input type doesn't`); - return await named(path, module, active, type); - }; - - /** `(typeof X)[keyof typeof X]`: the values of the `as const` object `X`, as a union of their literals. */ - const valuesOf = async (type: Extract, module: Module): Promise => { - const queried = (node: TypeNode): string | null => { - const inner = node.type === "TSParenthesizedType" ? node.typeAnnotation : node; - return inner.type === "TSTypeQuery" && inner.typeArguments === null && inner.exprName.type === "Identifier" ? inner.exprName.name : null; - }; - const name = queried(type.objectType); - const index = type.indexType.type === "TSTypeOperator" && type.indexType.operator === "keyof" ? queried(type.indexType.typeAnnotation) : null; - if (name === null || index !== name) return fail(module, type, `an input's indexed access type is \`(typeof X)[keyof typeof X]\`, the values of an \`as const\` object`); - const imported = module.imports.get(name); - const local = module.constants.get(name); - const found = local !== undefined ? { module, declaration: local } - : imported !== undefined && imported.imported !== null ? await fromSource(module, imported.source, imported.imported, constantsIn, new Set()) : null; - if (found === null || found === "package") return fail(module, type, `${name} isn't a constant its module declares or exports, which this pass can read`); - const { declaration } = found; - let init = declaration.init; - let constant = false; - while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) { - if (init.type === "TSAsExpression" && init.typeAnnotation.type === "TSTypeReference" && init.typeAnnotation.typeName.type === "Identifier" && init.typeAnnotation.typeName.name === "const") constant = true; - init = init.expression; - } - if (init === null || init.type !== "ObjectExpression" || !constant) return fail(found.module, declaration, `${name} is an object declared \`as const\`, whose values are literals`); - const values: (string | number | boolean)[] = []; - for (const property of init.properties) { - const value = property.type === "Property" ? property.value : null; - const literal = value?.type === "Literal" && (typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean") ? value.value - : value?.type === "UnaryExpression" && value.operator === "-" && value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value - : undefined; - if (literal === undefined) return fail(found.module, property, `${name}'s values are string, number or boolean literals`); - if (!values.includes(literal)) values.push(literal); - } - return { anyOf: values.map(value => ({ const: value })) }; - }; - - return { convert, moduleOf, read, site, fail }; -}; +/** What a pass inserts into a module, and the files whose types it read; or why it failed. */ +export type PassResult = + | { readonly insertions: readonly Insertion[]; readonly dependencies: readonly string[] } + | { readonly failure: Failure; readonly dependencies: readonly string[] }; type Expression = Extract["expression"]; type Call = Extract; @@ -394,11 +57,17 @@ const declaresComponents = (declaration: Call["arguments"][number]): boolean | n * takes input. */ export const passInputSchemas = async (code: string, file: string, host: InputHost): Promise => { + const result = await inputInsertions(moduleOf(file, code, host.parse(code, file)), host); + if (result === null || "failure" in result) return result; + return { ...insertInto(code, result.insertions, file), dependencies: result.dependencies }; +}; + +/** What `passInputSchemas` inserts into a module, as insertions into its code. */ +export const inputInsertions = async (module: Module, host: InputHost): Promise => { + const { code, file, program } = module; if (!code.includes("System") || !code.includes(".create(")) return null; const converter = createConverter(host, file); const { fail } = converter; - const program = host.parse(code, file); - const module = moduleOf(file, code, program); const systems = new Set(); for (const [local, entry] of module.imports) { if (entry.imported === "System" && await host.isSystem(entry.source, file)) systems.add(local); @@ -411,7 +80,7 @@ export const passInputSchemas = async (code: string, file: string, host: InputHo if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && systems.has(callee.object.name) && callee.property.type === "Identifier" && callee.property.name === "create" && node.arguments.length === 2) calls.push(node); }); - const insertions: { readonly at: number; readonly text: string }[] = []; + const insertions: Insertion[] = []; try { for (const call of calls) { const [declaration, second] = call.arguments; @@ -429,16 +98,16 @@ export const passInputSchemas = async (code: string, file: string, host: InputHo const annotation = annotationOf(parameter); if (annotation === null) return fail(module, parameter, "a body's input parameter has a type, which its schema is made from"); converter.site.at = parameter.start; - const schema = await converter.convert(annotation, module, new Set()); + const schema = await converter.convert(annotation, module, new Set(), "input"); if (!("type" in schema) || schema.type !== "object") fail(module, parameter, "a body's input is an object of named fields"); insertions.push({ at: second.end, text: `, ${JSON.stringify(schema)}` }); } } catch (error) { - if (isThrown(error)) return { failure: error.inputFailure, dependencies: [...converter.read] }; + if (isThrown(error)) return { failure: error.typeFailure, dependencies: [...converter.read] }; throw error; } if (insertions.length === 0) return null; - return { ...insertInto(code, insertions, file), dependencies: [...converter.read] }; + return { insertions, dependencies: [...converter.read] }; }; type Parameter = FunctionNode["params"][number]; diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 00c5a96a..0fcbbe1e 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { readFileSync, realpathSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { dirname, extname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,6 +21,8 @@ export type PackageCopy = { readonly preparedLookup: string; /** The package root's module, for kernels that import the package when a build treats it as external. */ readonly index: string; + /** The registry's hook for the declarations the build reads from `Components`, `Resources` and `Traits`. */ + readonly declare: string; }; /** The root of the copy of the package containing each directory, or null; found once per directory. */ @@ -72,6 +74,7 @@ const copyAt = (root: string, source: boolean, extension: string): PackageCopy = preparedSpawn: `${prefix}/implementation/world/prepared-spawn${suffix}`, preparedLookup: `${prefix}/implementation/world/prepared-lookup${suffix}`, index: `${prefix}/index${suffix}`, + declare: `${prefix}/interface/registry/declare${suffix}`, }; copies.set(key, copy); return copy; @@ -168,3 +171,26 @@ export const isInterfaceModule = (id: string): boolean => { const found = pathInCopy(fileOf(id)); return found !== null && (found.path.startsWith("src/interface/") || found.path.startsWith("dist/interface/") || rootModules.includes(found.path)); }; + +/** A file's path in a copy of the package, without `src/` or `dist/` and its extension; null outside every copy. */ +export const packagePathOf = (file: string): string | null => { + const found = pathInCopy(fileOf(file)); + if (found === null) return null; + const path = found.path.replace(/^(src|dist)\//, ""); + return path === found.path ? null : path.replace(/(\.d)?\.[cm]?[jt]sx?$/, ""); +}; + +/** + * The file whose types a resolved module's are: a copy of the package's `dist` module's source, which the package + * ships, else a JavaScript module's declarations beside it, else the module itself. + */ +export const typesFileOf = (file: string): string => { + const path = fileOf(file); + const found = pathInCopy(path); + if (found !== null && found.path.startsWith("dist/") && path.endsWith(".js")) { + const source = `${found.root}/src/${found.path.slice("dist/".length, -".js".length)}.ts`; + if (existsSync(source)) return source; + } + const declarations = path.replace(/\.([cm]?)js$/, ".d.$1ts"); + return declarations !== path && existsSync(declarations) ? declarations : path; +}; diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index a4614c6d..b52d1229 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,9 +9,11 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { passInputSchemas, type InputHost } from './input-schemas.js'; -import { copyForImporter, isInterfaceModule, namespaceCopyOf } from './paths.js'; -import { locateThroughMap } from './source-map.js'; +import { augmentationInsertions, type AugmentationHost } from './augmentations.js'; +import { inputInsertions, type InputHost } from './input-schemas.js'; +import { copyForImporter, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; +import { moduleOf, type TypedProgram } from './type-schemas.js'; +import { insertInto, locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; const scripts = /\.[cm]?[jt]sx?$/; @@ -98,8 +100,9 @@ const inlineKernels = (code: string, kernels: readonly string[], hashes: readonl const typed = /\.[cm]?tsx?$/; /** - * Passes each system's `create` the schema of its body's input, from the type of the body's parameter after its - * context (`input-schemas.ts`). It runs before TypeScript is stripped, which the type needs. + * Registers the keys `declare module "@adobe/ecs"` blocks declare (`augmentations.ts`), and passes each system's + * `create` the schema of its body's input, from the type of the body's parameter after its context + * (`input-schemas.ts`). It runs before TypeScript is stripped, which both read. */ const inputPlugin = (): Plugin => { let repeated = false; @@ -116,6 +119,8 @@ const inputPlugin = (): Plugin => { dependents.set(file, parents); } }; + /** Each file's syntax as last parsed, which transforms of other modules reading its types share. */ + const parsed = new Map(); const plugin: Plugin = { name: `${packageName}/input`, enforce: "pre", @@ -136,31 +141,37 @@ const inputPlugin = (): Plugin => { if (repeated || id.includes("\0") || !typed.test(file)) return null; const { parseAst } = await import("vite"); const resolved = async (specifier: string, importer: string) => await this.resolve(specifier, importer, { skipSelf: true }); - const host: InputHost = { - parse: (source, path) => parseAst(source, { lang: path.endsWith("x") ? "tsx" : "ts" }, path), + const host: InputHost & AugmentationHost = { + parse: (source, path) => { + const known = parsed.get(path); + if (known !== undefined && known.code === source) return known.program; + const program = parseAst(source, { lang: path.endsWith("x") ? "tsx" : "ts" }, path); + parsed.set(path, { code: source, program }); + return program; + }, resolve: async (specifier, importer) => { const found = await resolved(specifier, importer); - if (found === null || found.external) return null; - const path = found.id.split("?")[0] ?? found.id; - return path.includes("\0") || !isAbsolute(path) ? null : path; + if (found === null) return null; + // A build that treats the package as external still reads its types, from the importer's copy. + const path = found.external ? (specifier === packageName ? copyForImporter(importer).index : null) : found.id.split("?")[0] ?? found.id; + return path === null || path.includes("\0") || !isAbsolute(path) ? null : typesFileOf(path); }, load: file => readFile(file, "utf8"), - isPackage: async (specifier, importer) => { - const found = await resolved(specifier, importer); - return found !== null && (found.external ? specifier === packageName : isInterfaceModule(found.id)); - }, + packagePath: packagePathOf, + declareModule: importer => copyForImporter(importer).declare, isSystem: async (specifier, importer) => { const found = await resolved(specifier, importer); return found !== null && (found.external ? specifier === packageName : namespaceCopyOf(found.id, "System") !== null); }, }; - const result = await passInputSchemas(code, file, host); - const read = result === null ? [] : result.dependencies.filter(dependency => dependency !== file); + const module = moduleOf(file, code, host.parse(code, file)); + const results = [await augmentationInsertions(module, host), await inputInsertions(module, host)].filter(result => result !== null); + const read = [...new Set(results.flatMap(result => result.dependencies))].filter(dependency => dependency !== file); for (const dependency of read) this.addWatchFile(dependency); depend(file, read); - if (result === null) return null; - if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); - return { code: result.code, map: result.map }; + for (const result of results) if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + const insertions = results.flatMap(result => "insertions" in result ? result.insertions : []); + return insertions.length === 0 ? null : insertInto(code, insertions, file); }, }; return plugin; diff --git a/packages/ecs/src/implementation/compiler/type-host.test-helper.ts b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts new file mode 100644 index 00000000..e794f2f6 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts @@ -0,0 +1,28 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { existsSync, readFileSync } from 'node:fs'; +import { posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseAst } from 'vite'; +import type { AugmentationHost } from './augmentations.js'; +import type { InputHost } from './input-schemas.js'; +import { packagePathOf } from './paths.js'; + +const packageRoot = fileURLToPath(new URL("../../..", import.meta.url)).replace(/\/$/, ""); + +/** + * Files by path, beside the package's own sources, which `@adobe/ecs` resolves to, as it exports `System`; emitted + * registrations import from `declare.js`. + */ +export const hostOf = (files: { readonly [path: string]: string }): InputHost & AugmentationHost => ({ + parse: (code, file) => parseAst(code, { lang: "ts" }, file), + resolve: async (specifier, importer) => { + if (specifier === "@adobe/ecs") return `${packageRoot}/src/index.ts`; + if (!specifier.startsWith(".")) return null; + const target = posix.resolve(posix.dirname(importer), specifier).replace(/\.js$/, ".ts"); + return target in files || existsSync(target) ? target : null; + }, + load: async file => files[file] ?? readFileSync(file, "utf8"), + packagePath: packagePathOf, + declareModule: () => "declare.js", + isSystem: async specifier => specifier === "@adobe/ecs", +}); diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts new file mode 100644 index 00000000..c1dfeb30 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -0,0 +1,621 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { parseAst } from 'vite'; +import type { Schema } from '../../interface/schema/schema.js'; +import type { Failure } from './failure.js'; + +/** A TypeScript module's syntax, types included. */ +export type TypedProgram = ReturnType; +export type Statement = TypedProgram["body"][number]; +type Alias = Extract; +type Interface = Extract; +type Class = Extract; +type Namespace = Extract; +type Import = Extract; +export type Declarator = Extract["declarations"][number]; +export type TypeNode = Alias["typeAnnotation"]; +type Member = Interface["body"]["body"][number]; +type Named = Extract["typeName"]; +type Expression = NonNullable; +/** Any node of a module, by the fields this pass reads. */ +export type Node = { readonly type: string; readonly start: number; readonly end: number }; + +/** A type's declaration: an alias, an interface or a class. */ +type Declared = Alias | Interface | Class; + +/** Parses a TypeScript module, types included. */ +export type ParseTyped = (code: string, file: string) => TypedProgram; +/** The file an import specifier resolves to from `importer`, or null when it's none this pass can read. */ +export type ResolveFile = (specifier: string, importer: string) => Promise; +/** A file's code. */ +export type LoadFile = (file: string) => Promise; +/** + * A file's path in a copy of the package, without its extension, as `interface/schema/schema` for both its `src` and + * its `dist`; null for any other file. + */ +export type PackagePathOf = (file: string) => string | null; + +export type TypeHost = { readonly parse: ParseTyped; readonly resolve: ResolveFile; readonly load: LoadFile; readonly packagePath: PackagePathOf }; + +/** What a type is converted for, which decides how values that aren't data are taken, and how failures read. */ +export type Use = "input" | "declaration"; + +/** Thrown inside a pass, and returned as its failure. */ +type Thrown = { readonly typeFailure: Failure }; +export const isThrown = (value: unknown): value is Thrown => typeof value === "object" && value !== null && "typeFailure" in value; + +/** Thrown, for a declaration, by a type that isn't data; `node` when it's a DOM `Node`, which lives on `"main"`. */ +type NotData = { readonly notData: { readonly node: boolean } }; +export const isNotData = (value: unknown): value is NotData => typeof value === "object" && value !== null && "notData" in value; + +/** A value of any type: a global class, such as the platform's, or `unknown`. */ +const anything: Schema = { allOf: [] }; + +/** Any JSON value, or a set or map's: what a schema type's argument and an `as const` object's values are. */ +type Value = string | number | boolean | null | readonly Value[] | { readonly [field: string]: Value }; + +/** An import's source and the name it imports, null for a whole module, and whether it brings a value. */ +type Imported = { readonly source: string; readonly imported: string | null; readonly value: boolean }; + +/** A parsed module: its declarations by name, and its imports by local name. */ +export type Module = { + readonly file: string; + readonly code: string; + readonly program: TypedProgram; + readonly types: ReadonlyMap; + readonly namespaces: ReadonlyMap; + readonly constants: ReadonlyMap; + readonly imports: ReadonlyMap; + readonly exports: readonly Statement[]; +}; + +const declarationOf = (statement: Statement): Declared | Namespace | null => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declared === null || declared === undefined) return null; + if (declared.type === "TSModuleDeclaration") return declared.global === false && declared.id.type === "Identifier" ? declared : null; + return declared.type === "TSTypeAliasDeclaration" || declared.type === "TSInterfaceDeclaration" || declared.type === "ClassDeclaration" ? declared : null; +}; + +/** The top-level constants a statement declares. */ +const constantsOf = (statement: Statement): readonly Declarator[] => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "VariableDeclaration" && declared.kind === "const" ? declared.declarations.filter(declarator => declarator.id.type === "Identifier") : []; +}; + +export const nameOfDeclarator = (declarator: Declarator): string => declarator.id.type === "Identifier" ? declarator.id.name : ""; + +const importsOf = (statement: Import): [string, Imported][] => + statement.specifiers.map(specifier => [specifier.local.name, { + source: statement.source.value, + // A namespace import names the module itself. + imported: specifier.type === "ImportSpecifier" ? (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) + : specifier.type === "ImportDefaultSpecifier" ? "default" : null, + value: statement.importKind !== "type" && !(specifier.type === "ImportSpecifier" && specifier.importKind === "type"), + }]); + +/** The declarations of a module, or of a namespace's block in it. */ +const scopeOf = (statements: readonly Statement[]) => { + const types = new Map(); + const namespaces = new Map(); + const constants = new Map(); + for (const statement of statements) { + const declared = declarationOf(statement); + if (declared?.type === "TSModuleDeclaration") { + if (declared.id.type === "Identifier") namespaces.set(declared.id.name, declared); + } + else if (declared !== null && declared.id !== null) types.set(declared.id.name, declared); + for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); + } + return { types, namespaces, constants }; +}; + +export const moduleOf = (file: string, code: string, program: TypedProgram): Module => { + const imports = new Map(); + for (const statement of program.body) { + if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); + } + return { + file, code, program, ...scopeOf(program.body), imports, + exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration"), + }; +}; + +/** The line and column of an offset, 1-based line, 0-based column, as a failure's position. */ +const positionOf = (code: string, offset: number): { readonly line: number; readonly column: number } => { + const before = code.slice(0, offset); + const line = before.split("\n").length; + return { line, column: offset - (before.lastIndexOf("\n") + 1) }; +}; + +/** The module being transformed, and where in it the type being converted is. */ +type Site = { readonly root: string; at: number }; + +/** A type found by name: where it's declared, and the namespaces it's declared in; or a global. */ +type Found = { readonly module: Module; readonly declaration: Declared; readonly within: readonly string[] } | "global"; + +/** A namespace found by name: a module, or a TypeScript namespace declared in one. */ +type FoundNamespace = { readonly module: Module; readonly block: Namespace | null; readonly within: readonly string[] }; + +/** The DOM's classes of `Node`, which live on `"main"`. */ +const domNode = /^(Node|Element|Text|Comment|CharacterData|Document|DocumentFragment|ShadowRoot|Attr|HTML\w*Element|SVG\w*Element|MathMLElement)$/; + +const statementsOf = (block: Namespace): readonly Statement[] => block.body?.type === "TSModuleBlock" ? block.body.body : []; + +/** Converts types to schemas, reading the modules they're declared in through `host`. */ +export const createConverter = (host: TypeHost, root: string) => { + const site: Site = { root, at: 0 }; + const modules = new Map>(); + const read = new Set(); + const load = (file: string): Promise => { + const found = modules.get(file); + if (found !== undefined) return found; + const loading = (async () => { + read.add(file); + const code = await host.load(file); + return moduleOf(file, code, host.parse(code, file)); + })(); + modules.set(file, loading); + return loading; + }; + + /** Fails at `node`, or for a node of another module at the site, with a frame naming where in that module. */ + const fail = (module: Module, node: Node, message: string): never => { + const { line, column } = positionOf(module.code, node.start); + const inRoot = module.file === site.root; + const thrown: Thrown = { typeFailure: { message, position: inRoot ? node.start : site.at, frames: inRoot ? [] : [{ name: "type", file: module.file, line, column }] } }; + throw thrown; + }; + + const moduleAt = async (module: Module, source: string): Promise => { + const file = await host.resolve(source, module.file); + return file === null ? null : await load(file); + }; + + /** What a module exports as `name`, of `kind`, following re-exports; null when it exports none by that name. */ + const exported = async (module: Module, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | null> => { + const key = `${module.file}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + // A module's own declaration comes before what it re-exports. + const own = kind(module).get(name); + if (own !== undefined) return { module, declaration: own }; + for (const statement of module.exports) { + if (statement.type === "ExportNamedDeclaration") { + for (const specifier of statement.specifiers) { + const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; + if (exportedName !== name) continue; + const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; + if (statement.source !== null) return await fromSource(module, statement.source.value, localName, kind, seen); + const imported = module.imports.get(localName); + if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, kind, seen); + } + } + else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { + const found = await fromSource(module, statement.source.value, name, kind, seen); + if (found !== null) return found; + } + } + return null; + }; + + const fromSource = async (module: Module, source: string, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | null> => { + const target = await moduleAt(module, source); + return target === null ? null : await exported(target, name, kind, seen); + }; + + /** The namespace a module exports as `name`: a module re-exported whole, or a TypeScript namespace. */ + const exportedNamespace = async (module: Module, name: string, seen: Set): Promise => { + const key = `${module.file}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + const own = module.namespaces.get(name); + if (own !== undefined) return { module, block: own, within: [name] }; + for (const statement of module.exports) { + if (statement.type === "ExportAllDeclaration") { + const exportedName = statement.exported === null ? null : statement.exported.type === "Identifier" ? statement.exported.name : statement.exported.value; + if (exportedName === name) { + const target = await moduleAt(module, statement.source.value); + return target === null ? null : { module: target, block: null, within: [] }; + } + if (exportedName === null) { + const target = await moduleAt(module, statement.source.value); + const found = target === null ? null : await exportedNamespace(target, name, seen); + if (found !== null) return found; + } + } + else if (statement.type === "ExportNamedDeclaration") { + for (const specifier of statement.specifiers) { + const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; + if (exportedName !== name) continue; + const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; + if (statement.source !== null) { + const target = await moduleAt(module, statement.source.value); + return target === null ? null : await exportedNamespace(target, localName, seen); + } + return await namespaceNamed(module, localName, seen); + } + } + } + return null; + }; + + /** The namespace `name` names in a module: its own, one it imports whole, or one it imports by name. */ + const namespaceNamed = async (module: Module, name: string, seen: Set): Promise => { + const own = module.namespaces.get(name); + if (own !== undefined) return { module, block: own, within: [name] }; + const imported = module.imports.get(name); + if (imported === undefined) return null; + const target = await moduleAt(module, imported.source); + if (target === null) return null; + return imported.imported === null ? { module: target, block: null, within: [] } : await exportedNamespace(target, imported.imported, seen); + }; + + /** A member of a namespace: a nested namespace, a type, or a constant. */ + const memberNamespace = async (found: FoundNamespace, name: string): Promise => { + if (found.block === null) return await exportedNamespace(found.module, name, new Set()); + const nested = scopeOf(statementsOf(found.block)).namespaces.get(name); + return nested === undefined ? null : { module: found.module, block: nested, within: [...found.within, name] }; + }; + const memberType = async (found: FoundNamespace, name: string): Promise => { + if (found.block === null) { + const type = await exported(found.module, name, typesOf, new Set()); + return type === null ? null : { module: type.module, declaration: type.declaration, within: [] }; + } + const declaration = scopeOf(statementsOf(found.block)).types.get(name); + return declaration === undefined ? null : { module: found.module, declaration, within: found.within }; + }; + const memberConstant = async (found: FoundNamespace, name: string): Promise<{ readonly module: Module; readonly declaration: Declarator } | null> => { + if (found.block === null) return await exported(found.module, name, constantsIn, new Set()); + const declaration = scopeOf(statementsOf(found.block)).constants.get(name); + return declaration === undefined ? null : { module: found.module, declaration }; + }; + + const typesOf = (module: Module): ReadonlyMap => module.types; + const constantsIn = (module: Module): ReadonlyMap => module.constants; + + /** The namespace the leading parts of a path name, then its last part through `member`. */ + const throughNamespaces = async (module: Module, path: readonly string[], member: (found: FoundNamespace, name: string) => Promise): Promise => { + const [first, ...rest] = path; + const last = rest.pop(); + if (first === undefined || last === undefined) return null; + let found = await namespaceNamed(module, first, new Set()); + for (const name of rest) found = found === null ? null : await memberNamespace(found, name); + return found === null ? null : await member(found, last); + }; + + /** The type `path` names in `module`: its declaration, or a global when nothing declares or imports it. */ + const typeNamed = async (path: readonly string[], module: Module): Promise => { + const [first] = path; + if (first === undefined) return null; + if (path.length > 1) return await throughNamespaces(module, path, memberType); + const local = module.types.get(first); + if (local !== undefined) return { module, declaration: local, within: [] }; + const imported = module.imports.get(first); + if (imported === undefined) return "global"; + if (imported.imported === null) return null; + const found = await fromSource(module, imported.source, imported.imported, typesOf, new Set()); + return found === null ? null : { module: found.module, declaration: found.declaration, within: [] }; + }; + + /** The constant `path` names in `module`, through namespaces it imports. */ + const constantNamed = async (path: readonly string[], module: Module): Promise<{ readonly module: Module; readonly declaration: Declarator } | null> => { + const [first] = path; + if (first === undefined) return null; + if (path.length > 1) return await throughNamespaces(module, path, memberConstant); + const local = module.constants.get(first); + if (local !== undefined) return { module, declaration: local }; + const imported = module.imports.get(first); + return imported === undefined || imported.imported === null ? null : await fromSource(module, imported.source, imported.imported, constantsIn, new Set()); + }; + + /** The package's own declaration a found type is, by its path in the package: `Schema.Type` and `Entity`. */ + const intrinsic = (found: Found): "schema" | "entity" | null => { + if (found === "global") return null; + const path = host.packagePath(found.module.file); + const { declaration, within } = found; + if (declaration.id === null) return null; + const name = [...within, declaration.id.name].join("."); + if (path === "interface/schema/schema" && name === "Schema.Type") return "schema"; + if (path === "interface/entity/entity" && name === "Entity") return "entity"; + return null; + }; + + const nameParts = (name: Named | Extract["left"]): readonly string[] => + name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...nameParts(name.left), name.right.name] : []; + + /** What a use calls the thing it converts, in failures. */ + const words = (use: Use) => use === "input" + ? { a: "an input", the: "an input's", supported: "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, a type named by `Schema.Type`, `Entity`, a class or `unknown`" } + : { a: "a declared type", the: "a declared type's", supported: "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, a type named by `Schema.Type` or `Entity`, or a value that isn't data: a class, a function or `unknown`" }; + + /** For an input, any value; for a declaration, a throw saying the type isn't data. */ + const notData = (use: Use, node: boolean): Schema => { + if (use === "input") return anything; + const thrown: NotData = { notData: { node } }; + throw thrown; + }; + + /** Whether a class is a DOM `Node`: one of the DOM's, or one extending one, as far as its declarations are read. */ + const isNode = async (declaration: Class, module: Module, seen: Set): Promise => { + const { superClass } = declaration; + if (superClass === null || superClass.type !== "Identifier" || seen.has(declaration)) return false; + seen.add(declaration); + const found = await typeNamed([superClass.name], module); + if (found === "global") return domNode.test(superClass.name); + if (found === null || found.declaration.type === "TSTypeAliasDeclaration" || found.declaration.type === "TSInterfaceDeclaration") return false; + return await isNode(found.declaration, found.module, seen); + }; + + /** The schema of a type, read in `module`; `active` holds the declarations being converted, so recursion fails. */ + const convert = async (type: TypeNode, module: Module, active: ReadonlySet, use: Use): Promise => { + const { a, the, supported } = words(use); + switch (type.type) { + case "TSNumberKeyword": return { type: "number" }; + case "TSStringKeyword": return { type: "string" }; + case "TSBooleanKeyword": return { type: "boolean" }; + case "TSNullKeyword": return { type: "null" }; + case "TSUnknownKeyword": return notData(use, false); + case "TSFunctionType": case "TSConstructorType": + return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`); + case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active, use); + case "TSTypeOperator": + return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active, use) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`); + case "TSLiteralType": { + const { literal } = type; + if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value }; + if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return { const: -literal.argument.value }; + return fail(module, type, `${the} literal type is a string, number or boolean`); + } + case "TSUnionType": { + const types = type.types.filter(member => member.type !== "TSUndefinedKeyword"); + if (types.length !== type.types.length) return fail(module, type, `${a} that may be missing is optional (\`field?:\`) rather than \`| undefined\``); + const schemas = await Promise.all(types.map(member => convert(member, module, active, use))); + return { anyOf: schemas }; + } + case "TSArrayType": return { type: "array", items: await convert(type.elementType, module, active, use) }; + case "TSTupleType": { + const elements = type.elementTypes; + const first = elements[0]; + if (first === undefined) return fail(module, type, `${the} tuple has elements`); + const schemas = await Promise.all(elements.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" + ? fail(module, element, `${the} tuple has a fixed length, and its elements no names`) : convert(element, module, active, use))); + const items = schemas[0]; + if (items === undefined || schemas.some(schema => JSON.stringify(schema) !== JSON.stringify(items))) return fail(module, type, `${the} tuple has elements of one type`); + return { type: "array", items, minItems: elements.length, maxItems: elements.length }; + } + case "TSTypeLiteral": return await objectOf(type.members, [], module, active, type, use); + case "TSIntersectionType": { + const parts = await Promise.all(type.types.map(member => convert(member, module, active, use))); + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const part of parts) { + if (!("type" in part) || part.type !== "object") return fail(module, type, `${the} intersection joins object types`); + for (const [field, schema] of Object.entries(part.properties)) properties[field] = schema; + if (part.required === undefined) required.push(...Object.keys(part.properties)); + else { + optional = true; + required.push(...part.required); + } + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + } + case "TSTypeReference": return await referenced(type, module, active, use); + case "TSIndexedAccessType": return await valuesOf(type, module, use); + default: return fail(module, type, `${a} is ${supported}`); + } + }; + + const objectOf = async (members: readonly Member[], inherited: readonly Schema[], module: Module, active: ReadonlySet, at: Node, use: Use): Promise => { + const { a, the } = words(use); + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const schema of inherited) { + if (!("type" in schema) || schema.type !== "object") return fail(module, at, `${a} interface extends object types`); + for (const [field, property] of Object.entries(schema.properties)) properties[field] = property; + if (schema.required === undefined) required.push(...Object.keys(schema.properties)); + else { + optional = true; + required.push(...schema.required); + } + } + for (const member of members) { + if (member.type === "TSMethodSignature") return notData(use, false); + if (member.type !== "TSPropertySignature" || member.computed) return fail(module, member, `${the} fields are named properties`); + const { key } = member; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : fail(module, member, `${the} fields are named properties`); + if (member.typeAnnotation === null) return fail(module, member, `${use === "input" ? "input" : "declared"} field "${field}" has a type`); + properties[field] = await convert(member.typeAnnotation.typeAnnotation, module, active, use); + if (member.optional) optional = true; + else required.push(field); + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + }; + + const declared = async (found: Exclude, active: ReadonlySet, at: Node, module: Module, use: Use): Promise => { + const { declaration } = found; + const { a } = words(use); + const name = declaration.id?.name ?? "default"; + if (declaration.type !== "TSTypeAliasDeclaration" && declaration.type !== "TSInterfaceDeclaration") return notData(use, await isNode(declaration, found.module, new Set())); + if (active.has(declaration)) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${name} refers to itself`); + if (declaration.typeParameters !== null) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${name} is generic, which ${a} isn't`); + const inner = new Set(active).add(declaration); + if (declaration.type === "TSTypeAliasDeclaration") return await convert(declaration.typeAnnotation, found.module, inner, use); + const inherited = await Promise.all(declaration.extends.map(async heritage => { + const { expression } = heritage; + if (expression.type !== "Identifier" || heritage.typeArguments !== null) return fail(found.module, heritage, `${a} interface extends named, non-generic types`); + return await named([expression.name], found.module, inner, heritage, use); + })); + return await objectOf(declaration.body.body, inherited, found.module, inner, declaration, use); + }; + + /** The schema of the type `path` names in `module`. */ + const named = async (path: readonly string[], module: Module, active: ReadonlySet, at: Node, use: Use): Promise => { + const found = await typeNamed(path, module); + if (found === null) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${path.join(".")} isn't a type alias, interface or class its module declares or exports, which this pass can read`); + // Neither declared nor imported, it's a global, which TypeScript checked exists: a class, such as the platform's. + if (found === "global") return notData(use, path.length === 1 && domNode.test(path[0] ?? "")); + if (intrinsic(found) === "entity") return { type: "integer", format: "entity" }; + return await declared(found, active, at, module, use); + }; + + const referenced = async (type: Extract, module: Module, active: ReadonlySet, use: Use): Promise => { + const path = nameParts(type.typeName); + const parameters = type.typeArguments?.params ?? []; + const [only] = path; + if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) { + const [first, second] = parameters; + if ((only === "ReadonlyArray" || only === "Array") && parameters.length === 1 && first !== undefined) return { type: "array", items: await convert(first, module, active, use) }; + // A Set or Map changes in place, so as a declared type it isn't data. + if ((only === "Set" || only === "Map") && use === "declaration") return notData(use, false); + if ((only === "ReadonlySet" || only === "Set") && parameters.length === 1 && first !== undefined) return { type: "set", items: await convert(first, module, active, use) }; + if ((only === "ReadonlyMap" || only === "Map") && parameters.length === 2 && first !== undefined && second !== undefined) { + return { type: "map", keys: await convert(first, module, active, use), values: await convert(second, module, active, use) }; + } + } + if (parameters.length === 0) return await named(path, module, active, type, use); + const found = await typeNamed(path, module); + const [argument] = parameters; + if (found !== null && intrinsic(found) === "schema" && parameters.length === 1 && argument !== undefined) { + const schema = await valueOfType(argument, module, new Set()); + return isSchema(schema) ? schema : fail(module, argument, "Schema.Type takes a schema"); + } + return fail(module, type, `${use === "input" ? "input" : "declared"} type ${path.join(".")} takes type arguments, which ${words(use).a} doesn't, except Schema.Type`); + }; + + /** The value a literal type writes, as `Schema.Type`'s argument does: literals, tuples, object types, `typeof` a constant. */ + const valueOfType = async (type: TypeNode, module: Module, active: ReadonlySet): Promise => { + const unreadable = (): never => fail(module, type, "a schema type's argument is written as a literal type, or `typeof` a constant whose value is a literal"); + switch (type.type) { + case "TSParenthesizedType": return await valueOfType(type.typeAnnotation, module, active); + case "TSTypeOperator": return type.operator === "readonly" ? await valueOfType(type.typeAnnotation, module, active) : unreadable(); + case "TSNullKeyword": return null; + case "TSLiteralType": { + const { literal } = type; + if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return literal.value; + if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return -literal.argument.value; + return unreadable(); + } + case "TSTupleType": return await Promise.all(type.elementTypes.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? unreadable() : valueOfType(element, module, active))); + case "TSTypeLiteral": { + const value: { [field: string]: Value } = {}; + for (const member of type.members) { + if (member.type !== "TSPropertySignature" || member.computed || member.typeAnnotation === null) return unreadable(); + const { key } = member; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : unreadable(); + value[field] = await valueOfType(member.typeAnnotation.typeAnnotation, module, active); + } + return value; + } + case "TSTypeReference": { + if (type.typeArguments !== null) return unreadable(); + const found = await typeNamed(nameParts(type.typeName), module); + if (found === null || found === "global" || found.declaration.type !== "TSTypeAliasDeclaration" || active.has(found.declaration)) return unreadable(); + return await valueOfType(found.declaration.typeAnnotation, found.module, new Set(active).add(found.declaration)); + } + case "TSTypeQuery": { + if (type.exprName.type === "TSImportType" || type.exprName.type === "ThisExpression" || type.typeArguments !== null) return unreadable(); + const found = await constantNamed(nameParts(type.exprName), module); + if (found === null) return fail(module, type, `${nameParts(type.exprName).join(".")} isn't a constant its module declares or exports, which this pass can read`); + return await valueOfConstant(found.declaration, found.module, active); + } + default: return unreadable(); + } + }; + + /** A constant's value, from its initializer, or, declared without one, from its literal type. */ + const valueOfConstant = async (declarator: Declarator, module: Module, active: ReadonlySet): Promise => { + if (active.has(declarator)) return fail(module, declarator, `${nameOfDeclarator(declarator)} refers to itself`); + const inner = new Set(active).add(declarator); + if (declarator.init !== null) return await valueOf(declarator.init, module, inner); + const annotation = declarator.id.type === "Identifier" ? declarator.id.typeAnnotation : null; + return annotation === null || annotation === undefined ? fail(module, declarator, `${nameOfDeclarator(declarator)} has a value`) : await valueOfType(annotation.typeAnnotation, module, inner); + }; + + /** An expression's value, as a constant's initializer writes it: literals, arrays, objects and other constants. */ + const valueOf = async (expression: Expression, module: Module, active: ReadonlySet): Promise => { + const unreadable = (): never => fail(module, expression, "a constant read for its value is written as literals, arrays, objects and other constants"); + switch (expression.type) { + case "TSAsExpression": case "TSSatisfiesExpression": case "TSNonNullExpression": case "ParenthesizedExpression": + return await valueOf(expression.expression, module, active); + case "Literal": + return typeof expression.value === "string" || typeof expression.value === "number" || typeof expression.value === "boolean" || (expression.value === null && expression.raw === "null") ? expression.value : unreadable(); + case "UnaryExpression": + return expression.operator === "-" && expression.argument.type === "Literal" && typeof expression.argument.value === "number" ? -expression.argument.value : unreadable(); + case "ArrayExpression": + return await Promise.all(expression.elements.map(element => element === null || element.type === "SpreadElement" ? unreadable() : valueOf(element, module, active))); + case "ObjectExpression": { + const value: { [field: string]: Value } = {}; + for (const property of expression.properties) { + if (property.type === "SpreadElement") { + const spread = await valueOf(property.argument, module, active); + if (typeof spread !== "object" || spread === null || Array.isArray(spread)) return unreadable(); + Object.assign(value, spread); + continue; + } + if (property.computed || property.kind !== "init" || property.method) return unreadable(); + const { key } = property; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : unreadable(); + value[field] = await valueOf(property.value, module, active); + } + return value; + } + case "Identifier": case "MemberExpression": { + const path = pathOf(expression); + const found = path === null ? null : await constantNamed(path, module); + return found === null ? unreadable() : await valueOfConstant(found.declaration, found.module, active); + } + default: return unreadable(); + } + }; + + /** `(typeof X)[keyof typeof X]`: the values of the `as const` object `X`, as a union of their literals. */ + const valuesOf = async (type: Extract, module: Module, use: Use): Promise => { + const { the } = words(use); + const queried = (node: TypeNode): string | null => { + const inner = node.type === "TSParenthesizedType" ? node.typeAnnotation : node; + return inner.type === "TSTypeQuery" && inner.typeArguments === null && inner.exprName.type === "Identifier" ? inner.exprName.name : null; + }; + const name = queried(type.objectType); + const index = type.indexType.type === "TSTypeOperator" && type.indexType.operator === "keyof" ? queried(type.indexType.typeAnnotation) : null; + if (name === null || index !== name) return fail(module, type, `${the} indexed access type is \`(typeof X)[keyof typeof X]\`, the values of an \`as const\` object`); + const found = await constantNamed([name], module); + if (found === null) return fail(module, type, `${name} isn't a constant its module declares or exports, which this pass can read`); + const { declaration } = found; + let init = declaration.init; + let constant = false; + while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) { + if (init.type === "TSAsExpression" && init.typeAnnotation.type === "TSTypeReference" && init.typeAnnotation.typeName.type === "Identifier" && init.typeAnnotation.typeName.name === "const") constant = true; + init = init.expression; + } + if (init === null || init.type !== "ObjectExpression" || !constant) return fail(found.module, declaration, `${name} is an object declared \`as const\`, whose values are literals`); + const values: (string | number | boolean)[] = []; + for (const property of init.properties) { + const value = property.type === "Property" ? property.value : null; + const literal = value?.type === "Literal" && (typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean") ? value.value + : value?.type === "UnaryExpression" && value.operator === "-" && value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value + : undefined; + if (literal === undefined) return fail(found.module, property, `${name}'s values are string, number or boolean literals`); + if (!values.includes(literal)) values.push(literal); + } + return { anyOf: values.map(value => ({ const: value })) }; + }; + + return { convert, read, site, fail, typeNamed, constantNamed, valueOfType, valueOf, load }; +}; + +/** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ +export const pathOf = (expression: Node): readonly string[] | null => { + if (expression.type === "Identifier" && "name" in expression && typeof expression.name === "string") return [expression.name]; + if (expression.type !== "MemberExpression" || !("object" in expression) || !("property" in expression) || ("computed" in expression && expression.computed === true)) return null; + const { object, property } = expression; + if (typeof object !== "object" || object === null || typeof property !== "object" || property === null || !isNode(object)) return null; + const head = pathOf(object); + const name: unknown = Reflect.get(property, "name"); + return head === null || typeof name !== "string" ? null : [...head, name]; +}; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number" && typeof Reflect.get(value, "type") === "string"; + +// `Schema.Type`'s argument is constrained to `Schema` where it's written, so TypeScript checked an object's form. +const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/packages/ecs/src/interface/registry/declare.ts b/packages/ecs/src/interface/registry/declare.ts new file mode 100644 index 00000000..77847d49 --- /dev/null +++ b/packages/ecs/src/interface/registry/declare.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { createRegistry, type AnyDeclaration } from './declarations.js'; +import { addTrait } from './traits.js'; + +const components = createRegistry("component"); +const resources = createRegistry("resource"); + +/** + * Registers a component as the build read it from its declaration in `Components`: the plugin emits a call after each + * declaration, so consumers never register keys themselves. + */ +export const declareComponent = (key: unknown, declaration: AnyDeclaration): void => components(key, declaration); + +/** Registers a resource as the build read it from its declaration in `Resources`. */ +export const declareResource = (key: unknown, declaration: AnyDeclaration): void => resources(key, declaration); + +/** Registers a trait of a consumer's own, keyed by a symbol, as the build read it from `Traits`. */ +export const declareTrait = (key: unknown): void => addTrait(key); diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index ad9ab3aa..1caf2bbf 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -17,7 +17,7 @@ export const run = Symbol("Report/run"); */ export const errors = Symbol("Report/errors"); -declare module '@adobe/ecs' { +declare module '../component/types.js' { export interface Components { [run]: { type: { diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index 03901815..4fc0b6c4 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ export const always = Symbol("always"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [always]: { type: boolean }; } } -Resource.register(always, { type: { type: "boolean", default: false } }); +register(always, { type: { type: "boolean", default: false } }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index 052cab4a..b7776183 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** The cadence's value, for the implementation: `Resource.Value`. */ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; @@ -11,13 +11,13 @@ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; rea */ export const cadence = Symbol("cadence"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [cadence]: { type: Cadence }; } } -Resource.register(cadence, { +register(cadence, { type: { anyOf: [ { type: "object", properties: { kind: { const: "frame" } } }, diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index 7ae4e342..c8ef6fc5 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the @@ -7,12 +7,12 @@ import { Resource } from '../resource/resource.js'; */ export const clock = Symbol("clock"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [clock]: { type: { readonly now: number; readonly delta: number; readonly tick: number } }; } } -Resource.register(clock, { +register(clock, { type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, }); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts index 400a7a61..0e6a3891 100644 --- a/packages/ecs/src/interface/resources/gpu-device.ts +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world @@ -8,10 +8,10 @@ import { Resource } from '../resource/resource.js'; */ export const gpuDevice = Symbol("gpuDevice"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [gpuDevice]: { type: GPUDevice | null }; } } -Resource.register(gpuDevice, {}); +register(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index 9ae43924..d6a19f12 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity @@ -7,10 +7,10 @@ import { Resource } from '../resource/resource.js'; */ export const wake = Symbol("wake"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [wake]: { type: number; reduce: "min" }; } } -Resource.register(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +register(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index eb4be500..cdae93b8 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { register } from '../resource/register.js'; /** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ export const workers = Symbol("workers"); -declare module '@adobe/ecs' { +declare module '../resource/types.js' { export interface Resources { [workers]: { type: number | null }; } } -Resource.register(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +register(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); From 730c62dff4ca09215eaa5d1e81a3d1b97a505089 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 16:22:23 -0700 Subject: [PATCH 051/127] ecs: keys are declared once, in the augmentation; register leaves the public API The build registers every declaration, so Component.register, Resource.register and the Trait namespace are gone, with the types that checked registrations against declarations. Defaults are a declared trait, a literal type or typeof a value. Code the plugin emits outside the package imports the registry from @adobe/ecs/declare, so a library's build ships no machine's paths. A key without a description is labelled by its binding. Registration rejects within and reference on a derived component, which only the types did. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/package.json | 4 + packages/ecs/src/exports.test.ts | 30 +--- packages/ecs/src/guide/01-resources.test.ts | 32 ++--- packages/ecs/src/guide/02-entities.test.ts | 14 +- packages/ecs/src/guide/03-systems.test.ts | 22 ++- packages/ecs/src/guide/04-ticks.test.ts | 13 +- packages/ecs/src/guide/05-scheduling.test.ts | 9 +- packages/ecs/src/guide/06-sync-points.test.ts | 11 +- .../ecs/src/guide/07-change-filters.test.ts | 30 ++-- packages/ecs/src/guide/08-input.test.ts | 6 +- packages/ecs/src/guide/09-rules.test.ts | 22 ++- packages/ecs/src/guide/10-finalizers.test.ts | 6 +- packages/ecs/src/guide/11-traits.test.ts | 20 +-- packages/ecs/src/guide/11-traits.type-test.ts | 11 +- packages/ecs/src/guide/13-indexes.test.ts | 22 ++- .../compiler/augmentations.test.ts | 8 ++ .../implementation/compiler/augmentations.ts | 136 +++++++++--------- .../implementation/compiler/hygiene.test.ts | 10 +- .../ecs/src/implementation/compiler/paths.ts | 3 + .../implementation/compiler/plugin.test.ts | 73 +++++++--- .../ecs/src/implementation/compiler/plugin.ts | 6 +- .../implementation/compiler/type-schemas.ts | 2 +- .../world/async-loading.test.ts | 22 +-- .../src/implementation/world/batches.test.ts | 10 +- .../src/implementation/world/builtins.test.ts | 6 - .../world/change-blocks.browser.test.ts | 11 +- .../world/change-blocks.fixture.ts | 7 +- .../world/change-blocks.test.ts | 17 +-- .../world/changes-finalizers.test.ts | 7 +- .../world/command-rings.fixture.ts | 19 +-- .../src/implementation/world/commands.test.ts | 25 +--- .../implementation/world/conditions.test.ts | 22 +-- .../src/implementation/world/create.test.ts | 14 +- .../implementation/world/directory.test.ts | 4 +- .../implementation/world/dispatch.fixture.ts | 18 +-- .../world/entity-lookup-workers.fixture.ts | 7 - .../world/entity-lookups.test-helper.ts | 12 +- .../implementation/world/finalizers.test.ts | 27 +--- .../implementation/world/gpu.browser.test.ts | 24 +--- .../world/ids-again.test-helper.ts | 27 ++++ .../ecs/src/implementation/world/ids.test.ts | 35 ++--- .../world/imported-lists.test-helper.ts | 4 - .../world/imported-lists.test.ts | 2 - .../world/index-buckets.test.ts | 15 +- .../world/index-build-csp.test.ts | 10 +- .../world/index-build.fixture.ts | 9 +- .../world/index-changed.test.ts | 11 +- .../implementation/world/index-joined.test.ts | 26 +--- .../world/index-kernels.test.ts | 22 +-- .../world/index-keyless.test.ts | 7 - .../world/index-lookups-csp.test.ts | 6 +- .../implementation/world/index-order.test.ts | 9 +- .../world/index-workers.fixture.ts | 26 +--- .../src/implementation/world/indexes.test.ts | 22 +-- .../world/intrinsics.browser.test.ts | 40 ------ .../ecs/src/implementation/world/join.test.ts | 16 +-- .../world/joined-observer.test.ts | 2 - .../world/joining.test-helper.ts | 11 +- .../kernel-imports-sparks.test-helper.ts | 4 +- .../world/kernel-imports.test.ts | 4 +- .../world/kernel-lookup.test-helper.ts | 7 +- .../world/kernel-path.test-helper.ts | 3 - .../world/kernel-spawns.test.ts | 17 +-- .../implementation/world/kernels-csp.test.ts | 5 +- .../src/implementation/world/kernels.test.ts | 8 +- .../world/layout-directory.browser.test.ts | 17 +-- .../world/layout-directory.fixture.ts | 17 +-- .../implementation/world/lifetime.fixture.ts | 13 +- .../src/implementation/world/lifetime.test.ts | 7 +- .../ecs/src/implementation/world/loop.test.ts | 3 +- .../src/implementation/world/move-log.test.ts | 9 +- .../src/implementation/world/names.test.ts | 5 - .../world/observe-cases.browser.test.ts | 8 +- .../world/observe-cases.test.ts | 25 +--- .../world/observe-factories.test.ts | 7 +- .../world/observe-joined-cases.test.ts | 17 +-- .../world/observe-ticks-cases.test.ts | 15 +- .../world/observed-workers.fixture.ts | 8 +- .../world/observer-contained.test-helper.ts | 8 +- .../world/observer-contained.test.ts | 11 +- .../world/observer-joined.test-helper.ts | 32 ++--- .../world/observer-joined.test.ts | 14 +- .../world/observer.browser.test.ts | 8 +- .../src/implementation/world/observer.test.ts | 7 +- .../implementation/world/pool-size.fixture.ts | 5 +- .../src/implementation/world/ports.fixture.ts | 2 - .../world/reductions.fixture.ts | 16 +-- .../implementation/world/reductions.test.ts | 40 ++---- .../world/reevaluation.test-helper.ts | 3 +- .../world/remote-finalizers.test-helper.ts | 61 ++------ .../world/remote-finalizers.test.ts | 10 +- .../world/remote-objects.test-helper.ts | 7 +- .../src/implementation/world/remote.test.ts | 9 +- .../world/remove.test-helper.ts | 11 +- .../src/implementation/world/remove.test.ts | 4 - .../world/render-order.browser.test.ts | 15 +- .../src/implementation/world/reports.test.ts | 6 +- .../ecs/src/implementation/world/rows.test.ts | 10 +- .../src/implementation/world/rules.test.ts | 20 +-- .../src/implementation/world/schedule.test.ts | 3 - .../implementation/world/semantics.test.ts | 7 +- .../src/implementation/world/strings.test.ts | 12 +- .../implementation/world/tails.test-helper.ts | 31 ++-- .../src/implementation/world/target.test.ts | 4 +- .../world/thread-systems.test-helper.ts | 32 ++--- .../world/ticks.browser.test.ts | 4 +- .../src/implementation/world/ticks.test.ts | 5 +- .../src/implementation/world/traits.test.ts | 7 +- .../world/transfers.test-helper.ts | 15 +- .../src/implementation/world/up-tree.test.ts | 6 - .../world/up-workers.fixture.ts | 4 - .../implementation/world/up.browser.test.ts | 10 +- .../ecs/src/implementation/world/up.test.ts | 8 -- .../implementation/world/wasm-kernels.test.ts | 21 +-- .../implementation/world/workers.fixture.ts | 26 +--- .../archetype/archetype.type-test.ts | 3 - .../src/interface/archetype/create.test.ts | 3 - .../src/interface/component/declaration.ts | 68 --------- .../component/declaration.type-test.ts | 88 ------------ packages/ecs/src/interface/component/index.ts | 1 - .../src/interface/component/key.type-test.ts | 2 - .../src/interface/component/register.test.ts | 43 ++---- .../ecs/src/interface/component/register.ts | 29 ---- .../src/interface/finalizer/create.test.ts | 17 +-- .../ecs/src/interface/gpu/gpu.type-test.ts | 1 - packages/ecs/src/interface/index.ts | 1 - packages/ecs/src/interface/index.type-test.ts | 4 - .../ecs/src/interface/indexes/create.test.ts | 14 +- .../src/interface/indexes/index.type-test.ts | 12 -- .../src/interface/registry/declarations.ts | 4 +- .../ecs/src/interface/registry/ids.test.ts | 38 ++--- .../ecs/src/interface/registry/names.test.ts | 13 +- packages/ecs/src/interface/registry/names.ts | 15 +- .../ecs/src/interface/report/components.ts | 8 +- .../ecs/src/interface/resource/declaration.ts | 30 ---- .../resource/declaration.type-test.ts | 90 ------------ packages/ecs/src/interface/resource/index.ts | 1 - .../src/interface/resource/register.test.ts | 66 ++++----- .../ecs/src/interface/resource/register.ts | 19 --- .../ecs/src/interface/resources/always.ts | 6 +- .../ecs/src/interface/resources/cadence.ts | 6 +- packages/ecs/src/interface/resources/clock.ts | 6 +- .../ecs/src/interface/resources/gpu-device.ts | 6 +- packages/ecs/src/interface/resources/wake.ts | 6 +- .../ecs/src/interface/resources/workers.ts | 6 +- .../ecs/src/interface/rule/rule.type-test.ts | 5 - packages/ecs/src/interface/schema/is-data.ts | 26 ---- .../src/interface/schema/schema.type-test.ts | 17 +-- .../interface/system/commands.type-test.ts | 6 - .../interface/system/conditions.type-test.ts | 6 - .../ecs/src/interface/system/create.test.ts | 23 +-- .../src/interface/system/system.type-test.ts | 13 -- .../src/interface/system/threads.type-test.ts | 8 -- .../ecs/src/interface/system/up.type-test.ts | 4 - .../src/interface/thread/thread.type-test.ts | 42 ------ packages/ecs/src/interface/trait/index.ts | 2 - packages/ecs/src/interface/trait/register.ts | 10 -- packages/ecs/src/interface/trait/required.ts | 8 -- .../ecs/src/interface/trait/symbol-keys.ts | 20 --- .../interface/trait/symbol-keys.type-test.ts | 15 -- packages/ecs/src/interface/trait/trait.ts | 4 - .../src/interface/trait/trait.type-test.ts | 82 +---------- .../src/interface/world/world.type-test.ts | 3 - packages/ecs/src/observe/observe.type-test.ts | 4 - packages/ecs/src/perftest/editor.ts | 12 -- packages/ecs/src/perftest/filters.ts | 11 +- packages/ecs/src/perftest/finalized.ts | 18 +-- packages/ecs/src/perftest/flat.ts | 28 ++-- packages/ecs/src/perftest/growth.ts | 12 +- packages/ecs/src/perftest/handoff.ts | 2 - packages/ecs/src/perftest/hierarchy.ts | 12 +- packages/ecs/src/perftest/indexes.ts | 20 +-- packages/ecs/src/perftest/log.ts | 6 +- packages/ecs/src/perftest/observed-gpu.ts | 8 +- packages/ecs/src/perftest/observed.ts | 9 +- packages/ecs/src/perftest/particles.ts | 16 +-- packages/ecs/src/perftest/reads.ts | 8 +- packages/ecs/src/perftest/remote.ts | 12 +- packages/ecs/src/perftest/render-order.ts | 7 +- packages/ecs/src/perftest/strings.ts | 3 - packages/ecs/src/perftest/structural.ts | 22 +-- packages/ecs/src/perftest/suite.ts | 26 ++-- packages/ecs/src/perftest/systems.ts | 2 - packages/ecs/src/perftest/tails.ts | 22 ++- packages/ecs/src/perftest/transfers.ts | 2 - packages/ecs/src/perftest/uploads.ts | 5 +- packages/ecs/src/perftest/values.ts | 8 +- .../src/samples/particles/gpu/particles.ts | 25 ++-- .../ecs/src/samples/particles/particles.ts | 19 +-- .../samples/particles/particles.type-test.ts | 8 -- .../samples/todo/features/completion/dom.ts | 9 +- .../src/samples/todo/features/filter/data.ts | 8 +- .../src/samples/todo/features/filter/dom.ts | 6 +- .../src/samples/todo/features/todos/data.ts | 15 +- .../src/samples/todo/features/todos/dom.ts | 9 +- .../todos/elements/elements.browser.test.ts | 2 - packages/ecs/src/samples/transfers/sprites.ts | 21 +-- packages/ecs/src/world.test.ts | 6 +- 198 files changed, 767 insertions(+), 2271 deletions(-) create mode 100644 packages/ecs/src/implementation/world/ids-again.test-helper.ts delete mode 100644 packages/ecs/src/interface/component/declaration.ts delete mode 100644 packages/ecs/src/interface/component/declaration.type-test.ts delete mode 100644 packages/ecs/src/interface/component/register.ts delete mode 100644 packages/ecs/src/interface/resource/declaration.ts delete mode 100644 packages/ecs/src/interface/resource/declaration.type-test.ts delete mode 100644 packages/ecs/src/interface/resource/register.ts delete mode 100644 packages/ecs/src/interface/schema/is-data.ts delete mode 100644 packages/ecs/src/interface/trait/index.ts delete mode 100644 packages/ecs/src/interface/trait/register.ts delete mode 100644 packages/ecs/src/interface/trait/required.ts delete mode 100644 packages/ecs/src/interface/trait/symbol-keys.ts delete mode 100644 packages/ecs/src/interface/trait/symbol-keys.type-test.ts delete mode 100644 packages/ecs/src/interface/trait/trait.ts delete mode 100644 packages/ecs/src/samples/particles/particles.type-test.ts diff --git a/packages/ecs/package.json b/packages/ecs/package.json index be1bf7b7..19702d25 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -65,6 +65,10 @@ "./compiler": { "import": "./dist/implementation/compiler/index.js", "types": "./dist/implementation/compiler/index.d.ts" + }, + "./declare": { + "import": "./dist/interface/registry/declare.js", + "types": "./dist/interface/registry/declare.d.ts" } } } diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index cc204203..52443fcf 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -29,16 +29,13 @@ const install = (): void => { }; const consumer = ` -import { Archetype, Component, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; +import { Archetype, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; export const position = Symbol("exportsTest/position"); export const element = Symbol("exportsTest/element"); export const shown = Symbol("exportsTest/shown"); declare module "@adobe/ecs" { interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } } } -Component.register(position, { type: { type: "number" } }); -Component.register(element, { thread: "main" }); -Component.register(shown, { type: { const: true }, derived: true }); export const Placed = Archetype.create([position, element]); export const showing = Rule.create({ components: { has: [element] }, derive: [shown] }); export const move = System.create({ name: "exportsTest/move", components: { write: [position] } }, row => { row[position] += 1; }); @@ -95,26 +92,13 @@ const compile = (source: string): { readonly messages: string[] } => { return { messages: messages([...ts.getPreEmitDiagnostics(program), ...result.diagnostics]) }; }; -/** A consumer that registers a symbol-keyed component beside the augmentation given. */ -const stringKeyed = (augmentation: string, registration = ""): string => ` -import { Component, Trait } from "@adobe/ecs"; -export const keyed = Symbol("exportsTest/keyed"); -declare module "@adobe/ecs" { - interface Components { [keyed]: { type: number } } - ${augmentation} -} -Component.register(keyed, { type: { type: "number" } }); -${registration} -`; - /** A consumer with a system whose declaration lists a key in both read and write, and composites of conflicting inputs. */ const misdeclared = ` -import { Component, System } from "@adobe/ecs"; +import { System } from "@adobe/ecs"; export const health = Symbol("exportsTest/health"); declare module "@adobe/ecs" { interface Components { [health]: { type: number } } } -Component.register(health, { type: { type: "number" } }); export const both = System.create({ components: { read: [health], write: [health] } }, () => {}); const near = System.create({}, (_context, input: { readonly distance: number }) => { void input; }); const far = System.create({}, (_context, input: { readonly distance: string }) => { void input; }); @@ -151,16 +135,6 @@ describe("package exports", () => { expect(reported.filter(message => message.includes(`has a different type in another member of this composite.`))).toHaveLength(4); expect(reported.some(message => message.startsWith("Type '[unique symbol]' is not assignable"))).toBe(true); }, 60_000); - it("fail every registration to compile while a component, resource or trait is keyed by a string, naming the keys", () => { - expect(compile(stringKeyed(`interface Components { "exportsTest/named": { type: number } }`)).messages.join("\n")) - .toContain(`'{ readonly "component keys must be symbols": "exportsTest/named"; }'`); - expect(compile(stringKeyed(`interface Resources { "exportsTest/time": { type: number } }`)).messages.join("\n")) - .toContain(`'{ readonly "resource keys must be symbols": "exportsTest/time"; }'`); - const trait = compile(stringKeyed(`interface Traits { readonly persisted: true }`, `Trait.register("persisted");`)).messages.join("\n"); - expect(trait).toContain(`'{ readonly "trait keys must be symbols": "persisted"; }'`); - // Registration takes only symbol keys, so a string-keyed trait can't be registered either. - expect(trait).toContain(`Argument of type '"persisted"' is not assignable`); - }, 60_000); }); describe("the package's dependencies", () => { diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index 8e19e05b..d66ff607 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -1,32 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Entity, Resource, System, World } from "../index.js"; +import { Entity, System, World } from "../index.js"; // A resource is a key whose value a world holds once. -export const gravity = Symbol("gravity"); -export const names = Symbol("names"); -export const audio = Symbol("audio"); -export const focused = Symbol("focused"); +export const gravity = Symbol(); +export const names = Symbol(); +export const audio = Symbol(); +export const focused = Symbol(); +const noNames: ReadonlyMap = new Map(); -// Its traits are declared; `type` is the value's type. +// It's declared once, with its traits: `type` is the value's type, and `default` the value a world starts it at, which +// a type allowing null may leave out. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { - [gravity]: { type: number }; - [names]: { type: ReadonlyMap }; + [gravity]: { type: number; default: -9.8 }; + // A default that isn't a literal names a value with typeof. + [names]: { type: ReadonlyMap; default: typeof noNames }; + // A value that isn't data, such as a class, is stored as it is. [audio]: { type: AudioContext | null }; - [focused]: { type: Entity }; + // No entity is Entity.none rather than null, so an entity stays data every thread reads. + [focused]: { type: Entity; default: typeof Entity.none }; } } -// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a -// value: its schema's default, or one beside it. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); -// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. -Resource.register(audio, {}); -// No entity is Entity.none rather than null, so an entity stays data every thread reads. -Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); - // Systems read a world: an observer only reads, after the world's changes apply. let values: readonly unknown[] = []; const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts index 55148ebd..524dd0ad 100644 --- a/packages/ecs/src/guide/02-entities.test.ts +++ b/packages/ecs/src/guide/02-entities.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Archetype, Component, System, Vec3, World } from "../index.js"; +import { Archetype, System, Vec3, World } from "../index.js"; -// A component is a key whose values entities hold, declared and registered as a resource is. -export const position = Symbol("position"); -export const velocity = Symbol("velocity"); -export const enemy = Symbol("enemy"); +// A component is a key whose values entities hold, declared as a resource is. +export const position = Symbol(); +export const velocity = Symbol(); +export const enemy = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -16,10 +16,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(enemy, { type: { const: true } }); - // An archetype names a list of components, once at module scope; it goes wherever a list of components does. export const Particle = Archetype.create([position, velocity]); diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index dbc7e81b..63c34669 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Report, Resource, System, Vec3, World } from "../index.js"; +import { Report, System, Vec3, World } from "../index.js"; -const position = Symbol("position"); -const velocity = Symbol("velocity"); -const frozen = Symbol("frozen"); -const delta = Symbol("delta"); -const frames = Symbol("frames"); +const position = Symbol(); +const velocity = Symbol(); +const frozen = Symbol(); +const delta = Symbol(); +const frames = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -15,17 +15,11 @@ declare module "@adobe/ecs" { [frozen]: { type: true }; } interface Resources { - [delta]: { type: number }; - [frames]: { type: number }; + [delta]: { type: number; default: 0.5 }; + [frames]: { type: number; default: 0 }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(frozen, { type: { const: true } }); -Resource.register(delta, { type: { type: "number", default: 0.5 } }); -Resource.register(frames, { type: { type: "number", default: 0 } }); - const spawn = System.create(({ commands }) => { commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); commands.spawn([position, velocity, frozen])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index 21e84f4f..7f020083 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -1,20 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { always, cadence, clock, Resource, System, wake, World } from "../index.js"; +import { always, cadence, clock, System, wake, World } from "../index.js"; -const elapsed = Symbol("elapsed"); -const steps = Symbol("steps"); +const elapsed = Symbol(); +const steps = Symbol(); declare module "@adobe/ecs" { interface Resources { - [elapsed]: { type: number }; - [steps]: { type: number }; + [elapsed]: { type: number; default: 0 }; + [steps]: { type: number; default: 0 }; } } -Resource.register(elapsed, { type: { type: "number", default: 0 } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); - // clock is the current tick's: delta is the milliseconds since the last one. const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { resources[elapsed] += resources[clock].delta; diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts index e91f2103..10c1d719 100644 --- a/packages/ecs/src/guide/05-scheduling.test.ts +++ b/packages/ecs/src/guide/05-scheduling.test.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const health = Symbol("health"); -const damage = Symbol("damage"); +const health = Symbol(); +const damage = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -12,9 +12,6 @@ declare module "@adobe/ecs" { } } -Component.register(health, { type: { type: "number" } }); -Component.register(damage, { type: { type: "number" } }); - const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); }); const hit = System.create({ components: { read: [damage], write: [health] } }, row => { row[health] -= row[damage]; diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts index e27a8156..852add38 100644 --- a/packages/ecs/src/guide/06-sync-points.test.ts +++ b/packages/ecs/src/guide/06-sync-points.test.ts @@ -1,22 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Resource, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const health = Symbol("health"); -const seen = Symbol("seen"); +const health = Symbol(); +const seen = Symbol(); declare module "@adobe/ecs" { interface Components { [health]: { type: number }; } interface Resources { - [seen]: { type: number }; + [seen]: { type: number; default: 0 }; } } -Component.register(health, { type: { type: "number" } }); -Resource.register(seen, { type: { type: "number", default: 0 } }); - // Value writes apply at once; commands wait for the sync point that ends each run, then apply in the order issued. const spawn = System.create(({ commands }) => { // spawn gives the entity at once, so later commands can name it. diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts index 457ba502..a5b9c924 100644 --- a/packages/ecs/src/guide/07-change-filters.test.ts +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Resource, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const score = Symbol("score"); -const shown = Symbol("shown"); -const boss = Symbol("boss"); -const level = Symbol("level"); -const paused = Symbol("paused"); -const map = Symbol("map"); -const steps = Symbol("steps"); +const score = Symbol(); +const shown = Symbol(); +const boss = Symbol(); +const level = Symbol(); +const paused = Symbol(); +const map = Symbol(); +const steps = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -17,21 +17,13 @@ declare module "@adobe/ecs" { [boss]: { type: true }; } interface Resources { - [level]: { type: number }; - [paused]: { type: boolean }; + [level]: { type: number; default: 1 }; + [paused]: { type: boolean; default: false }; [map]: { type: { readonly width: number } | null }; - [steps]: { type: number }; + [steps]: { type: number; default: 0 }; } } -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { type: "number" } }); -Component.register(boss, { type: { const: true } }); -Resource.register(level, { type: { type: "number", default: 1 } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(map, { type: { anyOf: [{ type: "object", properties: { width: { type: "number" } } }, { type: "null" }] } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); - // A resource in changed passes the first run and each run after it changes: no run-once flag needed. const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts index e7bc7f05..a6c23849 100644 --- a/packages/ecs/src/guide/08-input.test.ts +++ b/packages/ecs/src/guide/08-input.test.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, World } from "../index.js"; +import { Entity, System, World } from "../index.js"; -const health = Symbol("health"); +const health = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -10,8 +10,6 @@ declare module "@adobe/ecs" { } } -Component.register(health, { type: { type: "number" } }); - let player = Entity.none; const spawn = System.create(({ commands }) => { player = commands.spawn([health])({ [health]: 10 }); }); diff --git a/packages/ecs/src/guide/09-rules.test.ts b/packages/ecs/src/guide/09-rules.test.ts index 1f4d0ed2..5b2a8d5d 100644 --- a/packages/ecs/src/guide/09-rules.test.ts +++ b/packages/ecs/src/guide/09-rules.test.ts @@ -1,30 +1,24 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Rule, System, World } from "../index.js"; +import { Rule, System, World } from "../index.js"; -const mesh = Symbol("mesh"); -const sprite = Symbol("sprite"); -const hidden = Symbol("hidden"); -const drawn = Symbol("drawn"); -const layer = Symbol("layer"); +const mesh = Symbol(); +const sprite = Symbol(); +const hidden = Symbol(); +const drawn = Symbol(); +const layer = Symbol(); declare module "@adobe/ecs" { interface Components { [mesh]: { type: number }; [sprite]: { type: number }; [hidden]: { type: true }; - // Only rules add derived components, starting at their schema's default. + // Only rules add derived components, starting at their default. [drawn]: { type: true; derived: true }; - [layer]: { type: number; derived: true }; + [layer]: { type: number; derived: true; default: 1 }; } } -Component.register(mesh, { type: { type: "number" } }); -Component.register(sprite, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(drawn, { type: { const: true }, derived: true }); -Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); - // Entities matching a rule have what it derives, only while they match. Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. diff --git a/packages/ecs/src/guide/10-finalizers.test.ts b/packages/ecs/src/guide/10-finalizers.test.ts index 7aae82c3..1e6c9deb 100644 --- a/packages/ecs/src/guide/10-finalizers.test.ts +++ b/packages/ecs/src/guide/10-finalizers.test.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Finalizer, System, World } from "../index.js"; +import { Finalizer, System, World } from "../index.js"; -const handle = Symbol("handle"); +const handle = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -10,8 +10,6 @@ declare module "@adobe/ecs" { } } -Component.register(handle, { type: { type: "number" } }); - /** What handles name outside the world, as GPU buffers and DOM nodes are. */ const open = new Set(); diff --git a/packages/ecs/src/guide/11-traits.test.ts b/packages/ecs/src/guide/11-traits.test.ts index ac41a53f..fffbed32 100644 --- a/packages/ecs/src/guide/11-traits.test.ts +++ b/packages/ecs/src/guide/11-traits.test.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, World } from "../index.js"; +import { Entity, System, World } from "../index.js"; -const ship = Symbol("ship"); -const parent = Symbol("parent"); -const target = Symbol("target"); -const selected = Symbol("selected"); -const outline = Symbol("outline"); +const ship = Symbol(); +const parent = Symbol(); +const target = Symbol(); +const selected = Symbol(); +const outline = Symbol(); -// Traits are the fields of a key's declaration; type is one. Registration repeats them. +// Traits are the fields of a key's declaration; type is one. declare module "@adobe/ecs" { interface Components { [ship]: { type: true }; @@ -22,12 +22,6 @@ declare module "@adobe/ecs" { } } -Component.register(ship, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(target, { type: Entity.schema, reference: "weak" }); -Component.register(selected, { type: { const: true } }); -Component.register(outline, { type: { type: "number" }, within: selected }); - const spawn = System.create(({ commands }) => { const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); commands.spawn([parent])({ [parent]: mothership }); diff --git a/packages/ecs/src/guide/11-traits.type-test.ts b/packages/ecs/src/guide/11-traits.type-test.ts index 609ef53d..9a70b23b 100644 --- a/packages/ecs/src/guide/11-traits.type-test.ts +++ b/packages/ecs/src/guide/11-traits.type-test.ts @@ -1,28 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Trait } from "../index.js"; +import { Component } from "../index.js"; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; const check = <_T extends true>() => { }; // A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. -export const instanced = Symbol("instanced"); +export const instanced = Symbol(); declare module "@adobe/ecs" { interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; } } -Trait.register(instanced); -const transform = Symbol("transform"); -const name = Symbol("name"); +const transform = Symbol(); +const name = Symbol(); declare module "@adobe/ecs" { interface Components { [transform]: { type: readonly [number, number, number]; [instanced]: true }; [name]: { type: string }; } } -Component.register(transform, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, [instanced]: true }); -Component.register(name, { type: { type: "string" } }); // Types select components by trait. check, typeof transform | typeof name>, typeof transform>>(); diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts index 9c1d22f5..de033237 100644 --- a/packages/ecs/src/guide/13-indexes.test.ts +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -1,29 +1,23 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Index, System, World } from "../index.js"; +import { type I32, Index, System, type Vec2, World } from "../index.js"; -const cell = Symbol("cell"); -const crowded = Symbol("crowded"); -const score = Symbol("score"); -const player = Symbol("player"); -const serverId = Symbol("serverId"); +const cell = Symbol(); +const crowded = Symbol(); +const score = Symbol(); +const player = Symbol(); +const serverId = Symbol(); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [crowded]: { type: number }; [score]: { type: number }; [player]: { type: string }; - [serverId]: { type: number }; + [serverId]: { type: I32 }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer" }, minItems: 2, maxItems: 2 } }); -Component.register(crowded, { type: { type: "number" } }); -Component.register(score, { type: { type: "number" } }); -Component.register(player, { type: { type: "string" } }); -Component.register(serverId, { type: { type: "integer" } }); - // An index finds entities by their components' values: here, those with a crowded count, by their cell. const grid = Index.create({ key: [cell], components: { has: [crowded] } }); diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 6007cb07..7c90ff42 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -94,6 +94,12 @@ declare module "@adobe/ecs" { `declareResource$(total, { type: {"type":"number","precision":1,"default":0}, reduce: "+" });`, ]); }); + test("registers a block's traits before the components declaring them", async () => { + expect(await registrations(`${keys("marked", "mark")}declare module "@adobe/ecs" { + interface Components { [marked]: { type: true; [mark]: true } } + interface Traits { readonly [mark]: true } +}`)).toEqual([`declareTrait$(mark);`, `declareComponent$(marked, { type: {"const":true}, [mark]: true });`]); + }); test("follows a type named by Schema.Type and imported keys", async () => { const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;\nexport const level = Symbol();` }; expect(await registrations(`import { level, type Level } from "./units.js";\ndeclare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ @@ -117,5 +123,7 @@ declare module "@adobe/ecs" { expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type"); expect(await passed(block(`interface Traits { readonly layer: true }`))).toContain("keyed by a `Symbol()` constant"); + expect(await passed(block(`interface Components { "named": { type: number } }`))).toContain("a component's key is a `Symbol()` constant"); + expect(await passed(block(`interface Resources { "time": { type: number; default: 0 } }`))).toContain("a resource's key is a `Symbol()` constant"); }); }); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index dc00cbe1..5ba33edd 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -40,6 +40,13 @@ const qualifiedParts = (name: QueryName | Extract + path !== null && (path.startsWith("interface/") || path.startsWith("implementation/")) && !testModule.test(file); + /** The top-level statement holding a node of the module, by position. */ const statementAt = (module: Module, node: Node): Statement | undefined => module.program.body.find(statement => statement.start <= node.start && node.end <= statement.end); @@ -82,7 +89,7 @@ const allowsNullSchema = (schema: Schema): boolean => */ export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => { const { code, file } = module; - if (!code.includes("@adobe/ecs") || !code.includes("declare module")) return null; + if (!code.includes("@adobe/ecs") || !code.includes("declare module") || isLibraryModule(host.packagePath(file), file)) return null; const augmentations = augmentationsOf(module); if (augmentations.length === 0) return null; const converter = createConverter(host, file); @@ -127,71 +134,72 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH }; try { - for (const { block, interfaces } of augmentations) { - for (const declared of interfaces) { - const interfaceName = declared.id.name; - if (!isKind(interfaceName)) continue; - const kind = kinds[interfaceName]; - for (const member of declared.body.body) { - if (member.type !== "TSPropertySignature") { - if (member.type === "TSIndexSignature" || member.type === "TSMethodSignature") fail(module, member, `${interfaceName}'s members are keys and their declarations`); - continue; - } - const { key } = member; - if (kind === "trait") { - // String-named traits are the package's own. - if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); - await requireSymbol(key.name, member, "a trait's key"); - used.add("trait"); - insertions.push({ at: Math.max(block.end, endOfLocal(key.name)), text: `\n${names.trait}(${key.name});` }); - continue; - } - if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); - await requireSymbol(key.name, member, `a ${kind}'s key`); - const declaration = member.typeAnnotation?.typeAnnotation; - if (declaration?.type !== "TSTypeLiteral") return fail(module, member, `${key.name}'s declaration is a type literal of its traits: \`{ type: ... }\``); - converter.site.at = member.start; - let at = Math.max(block.end, endOfLocal(key.name)); - let typeNode: TypeNode | undefined; - let defaultNode: TypeNode | undefined; - const traits: string[] = []; - let thread = false; - for (const trait of declaration.members) { - if (trait.type !== "TSPropertySignature" || trait.typeAnnotation === null) return fail(module, trait, `${key.name}'s traits are properties with values`); - const property: Property = trait; - const value = trait.typeAnnotation.typeAnnotation; - const name = !property.computed && property.key.type === "Identifier" ? property.key.name - : property.computed && property.key.type === "Identifier" ? null - : fail(module, trait, `${key.name}'s traits are named, or keyed by a trait's \`Symbol()\` constant`); - if (name === "type") typeNode = value; - else if (name === "default") defaultNode = value; - else { - const text = await valueText(value, `${key.name}'s trait`); - if (text.local !== undefined) at = Math.max(at, endOfLocal(text.local)); - if (name === "thread") thread = true; - traits.push(name === null ? `[${property.key.type === "Identifier" ? property.key.name : ""}]: ${text.text}` : `${name}: ${text.text}`); - } - } - if (typeNode === undefined) return fail(module, member, `${key.name} declares its type: \`{ type: ... }\``); - let schema: Schema | undefined; - try { - schema = await converter.convert(typeNode, module, new Set(), "declaration"); - } - catch (error) { - if (!isNotData(error)) throw error; - if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); + // Traits first, so a component declaring one registers after it. + const ordered = augmentations.flatMap(({ block, interfaces }) => interfaces.map(declared => ({ block, declared }))) + .sort((a, b) => Number(b.declared.id.name === "Traits") - Number(a.declared.id.name === "Traits")); + for (const { block, declared } of ordered) { + const interfaceName = declared.id.name; + if (!isKind(interfaceName)) continue; + const kind = kinds[interfaceName]; + for (const member of declared.body.body) { + if (member.type !== "TSPropertySignature") { + if (member.type === "TSIndexSignature" || member.type === "TSMethodSignature") fail(module, member, `${interfaceName}'s members are keys and their declarations`); + continue; + } + const { key } = member; + if (kind === "trait") { + // String-named traits are the package's own. + if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); + await requireSymbol(key.name, member, "a trait's key"); + used.add("trait"); + insertions.push({ at: Math.max(block.end, endOfLocal(key.name)), text: `\n${names.trait}(${key.name});` }); + continue; + } + if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); + await requireSymbol(key.name, member, `a ${kind}'s key`); + const declaration = member.typeAnnotation?.typeAnnotation; + if (declaration?.type !== "TSTypeLiteral") return fail(module, member, `${key.name}'s declaration is a type literal of its traits: \`{ type: ... }\``); + converter.site.at = member.start; + let at = Math.max(block.end, endOfLocal(key.name)); + let typeNode: TypeNode | undefined; + let defaultNode: TypeNode | undefined; + const traits: string[] = []; + let thread = false; + for (const trait of declaration.members) { + if (trait.type !== "TSPropertySignature" || trait.typeAnnotation === null) return fail(module, trait, `${key.name}'s traits are properties with values`); + const property: Property = trait; + const value = trait.typeAnnotation.typeAnnotation; + const name = !property.computed && property.key.type === "Identifier" ? property.key.name + : property.computed && property.key.type === "Identifier" ? null + : fail(module, trait, `${key.name}'s traits are named, or keyed by a trait's \`Symbol()\` constant`); + if (name === "type") typeNode = value; + else if (name === "default") defaultNode = value; + else { + const text = await valueText(value, `${key.name}'s trait`); + if (text.local !== undefined) at = Math.max(at, endOfLocal(text.local)); + if (name === "thread") thread = true; + traits.push(name === null ? `[${property.key.type === "Identifier" ? property.key.name : ""}]: ${text.text}` : `${name}: ${text.text}`); } - const start = defaultNode === undefined ? undefined : await valueText(defaultNode, `${key.name}'s default`); - if (start?.local !== undefined) at = Math.max(at, endOfLocal(start.local)); - if (schema !== undefined && start?.value !== undefined && !matches(schema, start.value)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); - const allowsNull = schema === undefined ? allowsNullType(typeNode) : allowsNullSchema(schema) || "default" in schema; - if (kind === "resource" && start === undefined && !allowsNull) fail(module, member, `resource ${key.name} declares the value it starts at: \`default: ...\`, which a type allowing null may leave out`); - const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; - if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); - parts.push(...traits); - used.add(kind); - insertions.push({ at, text: `\n${names[kind]}(${key.name}, ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); } + if (typeNode === undefined) return fail(module, member, `${key.name} declares its type: \`{ type: ... }\``); + let schema: Schema | undefined; + try { + schema = await converter.convert(typeNode, module, new Set(), "declaration"); + } + catch (error) { + if (!isNotData(error)) throw error; + if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); + } + const start = defaultNode === undefined ? undefined : await valueText(defaultNode, `${key.name}'s default`); + if (start?.local !== undefined) at = Math.max(at, endOfLocal(start.local)); + if (schema !== undefined && start?.value !== undefined && !matches(schema, start.value)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); + const allowsNull = schema === undefined ? allowsNullType(typeNode) : allowsNullSchema(schema) || "default" in schema; + if (kind === "resource" && start === undefined && !allowsNull) fail(module, member, `resource ${key.name} declares the value it starts at: \`default: ...\`, which a type allowing null may leave out`); + const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; + if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); + parts.push(...traits); + used.add(kind); + insertions.push({ at, text: `\n${names[kind]}(${key.name}, ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); } } } diff --git a/packages/ecs/src/implementation/compiler/hygiene.test.ts b/packages/ecs/src/implementation/compiler/hygiene.test.ts index 3a98f1c4..6de4af83 100644 --- a/packages/ecs/src/implementation/compiler/hygiene.test.ts +++ b/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld } from "../index.js"; @@ -25,14 +25,6 @@ declare module "@adobe/ecs" { } } -const number = { type: { type: "number" } } as const; -Component.register(p, number); -Component.register(c, number); -Component.register(l, number); -Component.register(s, number); -Component.register(e, number); -Component.register(v0, number); -Component.register(v1, number); const spawn = System.create(({ commands }) => { const spawned = commands.spawn([p, c, l, s])({ [p]: 1, [c]: 2, [l]: 3, [s]: 4 }); diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 0fcbbe1e..014ca32c 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -194,3 +194,6 @@ export const typesFileOf = (file: string): string => { const declarations = path.replace(/\.([cm]?)js$/, ".d.$1ts"); return declarations !== path && existsSync(declarations) ? declarations : path; }; + +/** Whether a file is part of a copy of the package: its own sources and builds. */ +export const isInPackageCopy = (file: string): boolean => pathInCopy(fileOf(file)) !== null; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 28ce647b..2461cc3d 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -15,10 +15,10 @@ const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "fixture-app", type: "module" })); const fixture = join(directory, "fixture.js"); writeFileSync(fixture, `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); const kernel = System.create({ name: "fixture", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); @@ -31,9 +31,9 @@ mkdirSync(join(directory, "node_modules/fake-math/dist"), { recursive: true }); writeFileSync(join(directory, "node_modules/fake-math/package.json"), JSON.stringify({ name: "fake-math", type: "module", exports: "./dist/index.js" })); writeFileSync(join(directory, "node_modules/fake-math/dist/index.js"), "export const increment = (x) => x + 1;\n"); /** The linked kernel's keys, in a module of their own that the edit doesn't evaluate again. */ -writeFileSync(join(directory, "linked-keys.js"), `import { Component } from ${source("../../interface/component/component.ts")}; +writeFileSync(join(directory, "linked-keys.js"), `import { declareComponent } from ${source("../../interface/registry/declare.ts")}; export const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); `); writeFileSync(linked, `import { System } from ${source("../../interface/system/system.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; @@ -58,10 +58,10 @@ export const schemas = [push, label].map(system => Reflect.get(system, "input")) /** A kernel spawning through a list another module declares, which the test edits. */ const listing = join(directory, "listing.js"); const listed = join(directory, "listed.js"); -const writeListed = (keys: string) => writeFileSync(listed, `import { Component } from ${source("../../interface/component/component.ts")}; +const writeListed = (keys: string) => writeFileSync(listed, `import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { score } from "./linked-keys.js"; export const extra = Symbol("extra"); -Component.register(extra, { type: { const: true } }); +declareComponent(extra, { type: { const: true } }); export const Listed = [${keys}]; `); writeListed("score"); @@ -75,24 +75,24 @@ export const description = kernelOf(kernel); /** Two kernels in one module, writing different functions of the entity. */ const pair = join(directory, "pair.js"); writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); const doubled = System.create({ name: "doubled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); const tripled = System.create({ name: "tripled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ const keys = (declaration: string, released = "score") => `import { System as Systems } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { Finalizer } from ${source("../../interface/finalizer/finalizer.ts")}; import { Rule } from ${source("../../interface/rule/rule.ts")}; import { labelOf } from ${source("../../interface/registry/names.ts")}; export const score = Symbol("score"); const shown = Symbol("shown"); -Component.register(score, ${declaration}); -Component.register(shown, { type: { const: true }, derived: true }); +declareComponent(score, ${declaration}); +declareComponent(shown, { type: { const: true }, derived: true }); export const rule = Rule.create({ name: "keys/shown", components: { has: [score] }, derive: [shown] }); export const release = Finalizer.create({ name: "keys/release", components: { read: [${released}] } }, () => {}); export const system = Systems.create({ name: "keys/write", components: { write: [score] } }, (row) => { row[score] = 1; }); @@ -112,13 +112,13 @@ writeFileSync(join(directory, "node_modules/key-library/package.json"), JSON.str writeFileSync(join(directory, "node_modules/key-library/dist/index.js"), `export const marker = Symbol("marker");\n`); writeFileSync(join(directory, "worker-keys.js"), `export const message = Symbol("message");\nself.postMessage(String(message.description));\n`); writeFileSync(identified, `import { labelOf } from ${source("../../interface/registry/names.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { marker } from "key-library"; export const score = Symbol("score"); const twin = Symbol("score"); export default Symbol(); -Component.register(score, { type: { type: "number" } }); -Component.register(twin, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); +declareComponent(twin, { type: { type: "number" } }); export const labels = [labelOf(score), labelOf(twin), labelOf(marker)]; export const worker = () => new Worker(new URL("./worker-keys.js", import.meta.url), { type: "module" }); `); @@ -127,11 +127,11 @@ mkdirSync(join(directory, "node_modules/external-keys"), { recursive: true }); writeFileSync(join(directory, "node_modules/external-keys/package.json"), JSON.stringify({ name: "external-keys", type: "module", exports: "./index.js" })); writeFileSync(join(directory, "node_modules/external-keys/index.js"), `export const heat = Symbol("heat");\nexport const Pair = [Symbol("left"), Symbol("right")];\n`); const externalKernel = (name: string, added: string) => `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { declareComponent } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); const kernel = System.create({ name: "${name}", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; @@ -175,6 +175,8 @@ const installCopy = async (project: string): Promise => { const copy = join(project, "node_modules/@adobe/ecs"); mkdirSync(copy, { recursive: true }); writeFileSync(join(copy, "package.json"), readFileSync(join(own, "package.json"))); + // The package ships its sources beside its build, as types for its build read them. + cpSync(join(own, "src"), join(copy, "src"), { recursive: true }); for (const [path, code] of await distFromSources()) { mkdirSync(dirname(join(copy, "dist", path)), { recursive: true }); writeFileSync(join(copy, "dist", path), code); @@ -323,9 +325,11 @@ test("an application's build compiles the kernels a library ships uncompiled", a writeFileSync(join(project, "package.json"), JSON.stringify({ name: "shipped-app", type: "module" })); mkdirSync(join(project, "node_modules/kernel-library/dist"), { recursive: true }); writeFileSync(join(project, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); - writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { Component, System } from "@adobe/ecs"; + // As the library's own build with the plugin left it, registering its keys through the copy it resolves. + writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { System } from "@adobe/ecs"; +import { declareComponent as declareComponent$ } from "@adobe/ecs/declare"; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); `); // The test hook comes from the installed copy, which the library's compiled kernel attaches to. @@ -347,6 +351,35 @@ export const description = kernelOf(kernel); expect(description === undefined ? [] : runs(module, description)).toEqual([6, 8]); }); +test("a build registers the keys a module declares, through the copy of the package the module resolves", async () => { + const project = join(directory, "declaring"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "declaring-app", version: "1.0.0", type: "module" })); + const keys = join(project, "keys.ts"); + writeFileSync(keys, `import type { F32 } from "@adobe/ecs"; +export const speed = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [speed]: { type: F32; default: 2 } } +} +`); + const application = join(project, "application.js"); + writeFileSync(application, `export { speed } from "./keys.ts";\n`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + // A library's build ships this: no path of the machine that built it. + expect(chunks.map(chunk => chunk.code).join("\n")).toContain(`from "@adobe/ecs/declare"`); + const main = chunks.find(chunk => chunk.facadeModuleId === application); + const { speed }: { speed: symbol } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + // The registry of the copy the build's import resolves to. + const { declarationOf }: { declarationOf: (key: unknown) => unknown } = await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/interface/registry/names.js")).href); + expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1, default: 2 } }); +}); + test("serve accepts a module of keys evaluated again, which makes the same keys, systems, rules and finalizers by their ids", async () => { await serve(async server => { const first = await server.ssrLoadModule(evaluated); @@ -437,7 +470,7 @@ test("compiled code imports the runtime from the copy of the package its module const module = join(project, "copied.js"); writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +declareComponent(score, { type: { type: "number" } }); export const kernel = System.create({ name: "copied", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const spawner = System.create({ name: "copiedSpawner", components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index b52d1229..52d6d5df 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -11,7 +11,7 @@ import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; import { augmentationInsertions, type AugmentationHost } from './augmentations.js'; import { inputInsertions, type InputHost } from './input-schemas.js'; -import { copyForImporter, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; +import { copyForImporter, isInPackageCopy, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; import { moduleOf, type TypedProgram } from './type-schemas.js'; import { insertInto, locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; @@ -158,7 +158,9 @@ const inputPlugin = (): Plugin => { }, load: file => readFile(file, "utf8"), packagePath: packagePathOf, - declareModule: importer => copyForImporter(importer).declare, + // The package's own modules share the registry of the copy they're in; any other module's registrations, + // a library's shipped among them, import the copy it resolves. + declareModule: importer => isInPackageCopy(importer) ? copyForImporter(importer).declare : `${packageName}/declare`, isSystem: async (specifier, importer) => { const found = await resolved(specifier, importer); return found !== null && (found.external ? specifier === packageName : namespaceCopyOf(found.id, "System") !== null); diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts index c1dfeb30..0d86be13 100644 --- a/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -601,7 +601,7 @@ export const createConverter = (host: TypeHost, root: string) => { return { anyOf: values.map(value => ({ const: value })) }; }; - return { convert, read, site, fail, typeNamed, constantNamed, valueOfType, valueOf, load }; + return { convert, read, site, fail, constantNamed, valueOfType }; }; /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts index e99347e5..9bf4bb41 100644 --- a/packages/ecs/src/implementation/world/async-loading.test.ts +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, Finalizer, Resource, Rule, System, World } from "../../index.js"; +import { Entity, Finalizer, Rule, type Schema, System, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { createWorld } from "../index.js"; @@ -18,28 +18,16 @@ declare module "@adobe/ecs" { [meshSource]: { type: string }; [hidden]: { type: true }; /** The token of the slot's load; a new one each time the slot starts over. */ - [meshSlot]: { type: number; derived: true }; + [meshSlot]: { type: Schema.Type<{ type: "integer" }>; derived: true; default: 0 }; /** The loaded mesh's handle, which exists only while its slot does. */ - [meshReady]: { type: number; within: typeof meshSlot }; + [meshReady]: { type: Schema.Type<{ type: "integer" }>; within: typeof meshSlot }; } interface Resources { - [nextToken]: { type: number }; - [requests]: { type: readonly { readonly entity: Entity; readonly token: number; readonly path: string }[] }; + [nextToken]: { type: Schema.Type<{ type: "integer" }>; default: 1 }; + [requests]: { type: Schema.Type<{ type: "array"; items: { type: "object"; properties: { entity: { type: "integer"; format: "entity" }; token: { type: "integer" }; path: { type: "string" } } } }>; default: [] }; } } -Component.register(meshSource, { type: { type: "string" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(meshSlot, { type: { type: "integer", default: 0 }, derived: true }); -Component.register(meshReady, { type: { type: "integer" }, within: meshSlot }); -Resource.register(nextToken, { type: { type: "integer", default: 1 } }); -Resource.register(requests, { - type: { - type: "array", - items: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, path: { type: "string" } } }, - default: [], - }, -}); /** Meshes loaded outside the world, by handle: as GPU buffers are. */ const loaded = new Map(); diff --git a/packages/ecs/src/implementation/world/batches.test.ts b/packages/ecs/src/implementation/world/batches.test.ts index 58fb30f4..920df34d 100644 --- a/packages/ecs/src/implementation/world/batches.test.ts +++ b/packages/ecs/src/implementation/world/batches.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -12,6 +11,7 @@ import { createBuffer } from "./buffer.js"; import { createCommands } from "./commands.js"; import { createState } from "./state.js"; import { runReported } from "./reports.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -21,16 +21,12 @@ const name = Symbol("batchesTest/name"); const tag = Symbol("batchesTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [score]: { type: F32 }; [name]: { type: string }; [tag]: { type: true }; } } -Component.register(position, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(name, { type: { type: "string" } }); -Component.register(tag, { type: { const: true } }); describe("staged spawns", () => { it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => { diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts index 6909271a..951c30c8 100644 --- a/packages/ecs/src/implementation/world/builtins.test.ts +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; @@ -23,11 +22,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(bits, { type: Vec3.U32.schema }); -Component.register(counts, { type: Vec3.U32.schema }); -Component.register(sums, { type: Vec3.I32.schema }); const spawn = System.create({ name: "builtinsTest/spawn" }, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([position, velocity])({ [position]: [i, i % 5, -i], [velocity]: [1, 0.5 * (i % 3), 0.25] }); diff --git a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts index 6dcba44a..e929d7fa 100644 --- a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, join } from "./create.js"; +import type { F32, U32 } from "../../interface/math/index.js"; // A GPU kernel stamps a whole column once; before a joined thread's systems filter on it, its rows, chunks and blocks // take the stamp, so the thread's change filters find every row, and then only rows written after. @@ -19,15 +19,12 @@ declare module "@adobe/ecs" { changeBlocksGpuTest: true; } interface Components { - [value]: { type: number }; - [at]: { type: number }; - [note]: { type: { readonly text: string } | null; thread: "changeBlocksGpuTest" }; + [value]: { type: F32 }; + [at]: { type: U32 }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksGpuTest"; default: { text: "none" } }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(note, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "changeBlocksGpuTest" }); const rows = 4500; let entities: Entity[] = []; diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts index d475bb20..22f82b48 100644 --- a/packages/ecs/src/implementation/world/change-blocks.fixture.ts +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { I32 } from "../../interface/math/i32/index.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; /** Scenarios of chunk and block ticks on workers, run built in node and from source in chromium. */ @@ -15,14 +15,11 @@ const seen = Symbol("changeBlocksWorkersTest/seen"); declare module "@adobe/ecs" { export interface Components { [value]: { type: number }; - [at]: { type: number }; + [at]: { type: U32 }; [seen]: { type: number }; } } -Component.register(value, { type: { type: "number" } }); -Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(seen, { type: { type: "number" } }); const rows = 4500; diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index add37fb4..85fce5a1 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Archetype } from "../../interface/archetype/archetype.js"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import type { I32 } from "../../interface/math/i32/index.js"; @@ -11,6 +10,7 @@ import type { World } from "../../interface/world/world.js"; import { createWorld, join } from "./create.js"; import { blockShift, chunkShift } from "./kernel.js"; import { readEach, select } from "./reads.test-helper.js"; +import type { U32 } from "../../interface/math/index.js"; // Change filters skip chunks and blocks whose ticks aren't newer than their `lastRun`, so every writer of a row's tick // raises its chunk's and block's. Each case writes a few rows far apart in an archetype of many chunks, through one @@ -32,26 +32,17 @@ declare module "@adobe/ecs" { } interface Components { [value]: { type: number }; - [at]: { type: number }; + [at]: { type: U32 }; [seen]: { type: number }; - [group]: { type: number }; + [group]: { type: U32 }; [tagged]: { type: true }; - [note]: { type: { readonly text: string } | null; thread: "changeBlocksTest" }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksTest"; default: { text: "none" } }; [parent]: { type: Entity; reference: "owner" }; [extra]: { type: number }; [height]: { type: number }; } } -Component.register(value, { type: { type: "number" } }); -Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(seen, { type: { type: "number" } }); -Component.register(group, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(tagged, { type: { const: true } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(note, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "changeBlocksTest" }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(extra, { type: { type: "number" } }); -Component.register(height, { type: { type: "number" } }); /** Rows in the archetype: five chunks, the last partial. */ const rows = 4500; diff --git a/packages/ecs/src/implementation/world/changes-finalizers.test.ts b/packages/ecs/src/implementation/world/changes-finalizers.test.ts index b3c04aa2..7fa51e4a 100644 --- a/packages/ecs/src/implementation/world/changes-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/changes-finalizers.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Finalizer, Resource, System, World } from "../../index.js"; +import { Finalizer, System, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { createWorld } from "../index.js"; @@ -14,13 +14,10 @@ declare module "@adobe/ecs" { [score]: { type: number }; } interface Resources { - [journal]: { type: readonly string[] }; + [journal]: { type: readonly string[]; default: [] }; } } -Component.register(name, { type: { type: "string" } }); -Component.register(score, { type: { type: "number" } }); -Resource.register(journal, { type: { type: "array", items: { type: "string" }, default: [] } }); // A changes finalizer sees each sync point's structural changes to the components it watches, grouped by how // they changed, with values before and after: for persistence, replication and undo. diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index 2f0a65e0..ad987853 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -9,6 +8,7 @@ import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; import { read, readEach, select } from "./reads.test-helper.js"; import { runReported } from "./reports.test-helper.js"; +import type { U32, Vec4 } from "../../interface/math/index.js"; /** Scenarios of workers' command rings against the main thread alone, run built in node and from source in chromium. */ @@ -26,28 +26,19 @@ const tally = Symbol("commandRingsTest/tally"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; + [index]: { type: U32 }; [weight]: { type: number }; - [extent]: { type: readonly [number, number, number, number] }; - [made]: { type: number }; + [extent]: { type: Vec4 }; + [made]: { type: U32 }; [doomed]: { type: true }; [target]: { type: Entity }; - [name]: { type: string | null }; + [name]: { type: string | null; default: "unnamed" }; [maybe]: { type: string | null }; [tally]: { type: number | null }; } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(extent, { type: vec4 }); -Component.register(made, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(doomed, { type: { const: true } }); -Component.register(target, { type: Entity.schema }); /** Objects: a nullable string starting at "unnamed", a nullable string, and a nullable number. */ -Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: "unnamed" } }); -Component.register(maybe, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(tally, { type: { anyOf: [{ type: "number" }, { type: "null" }] } }); const seed = System.create({ name: "commandRingsTest/seed" }, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index a6620763..d43d9653 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from "vitest"; import { Archetype } from "../../interface/archetype/archetype.js"; import { Report } from "../../interface/report/report.js"; -import { Component } from "../../interface/component/component.js"; import type { Essential } from "../../interface/component/value.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { Rule } from "../../interface/rule/rule.js"; import type { Schema } from "../../interface/schema/schema.js"; import type { Commands } from "../../interface/system/commands.js"; @@ -16,6 +14,7 @@ import { Observe } from "../../observe/index.js"; import { count, read, readEntity, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -26,6 +25,7 @@ const label = Symbol("commandsTest/label"); const tag = Symbol("commandsTest/tag"); const other = Symbol("commandsTest/other"); const slot = Symbol("commandsTest/slot"); +// Never declared, so never registered. const unregistered = Symbol("commandsTest/unregistered"); const level = Symbol("commandsTest/level"); const bit0 = Symbol("commandsTest/bit0"); @@ -35,35 +35,22 @@ const bit3 = Symbol("commandsTest/bit3"); const bits = [bit0, bit1, bit2, bit3] as const; declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [score]: { type: F32 }; [health]: { type: number }; [label]: { type: string | null }; [tag]: { type: true }; [other]: { type: true }; - [slot]: { type: number; derived: true }; - [unregistered]: { type: number }; + [slot]: { type: number; derived: true; default: 0 }; [bit0]: { type: true }; [bit1]: { type: true }; [bit2]: { type: true }; [bit3]: { type: true }; } export interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 0 }; } } -Component.register(position, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(health, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(tag, { type: { const: true } }); -Component.register(other, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(bit0, { type: { const: true } }); -Component.register(bit1, { type: { const: true } }); -Component.register(bit2, { type: { const: true } }); -Component.register(bit3, { type: { const: true } }); -Resource.register(level, { type: { type: "number", default: 0 } }); Rule.create({ name: "commandsTest/slots", components: { has: [other, score] }, derive: [slot] }); const unit = Archetype.create([position, score, tag]); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index baeaccc4..57506f1d 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { read, readResource, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; @@ -18,27 +16,19 @@ const value = Symbol("conditionsTest/value"); declare module "@adobe/ecs" { interface Resources { - [paused]: { type: boolean }; + [paused]: { type: boolean; default: false }; [level]: { type: { readonly size: number } | null }; - [ticks]: { type: number }; - [after]: { type: number }; - [sizes]: { type: number }; - [total]: { type: number; reduce: "+" }; - [count]: { type: number }; + [ticks]: { type: number; default: 0 }; + [after]: { type: number; default: 0 }; + [sizes]: { type: number; default: 0 }; + [total]: { type: number; reduce: "+"; default: 0 }; + [count]: { type: number; default: 0 }; } interface Components { [value]: { type: number }; } } -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }] } }); -Resource.register(ticks, { type: { type: "number", default: 0 } }); -Resource.register(after, { type: { type: "number", default: 0 } }); -Resource.register(sizes, { type: { type: "number", default: 0 } }); -Resource.register(count, { type: { type: "number", default: 0 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Component.register(value, { type: { type: "number" } }); const pause = System.create({ name: "conditionsTest/pause", resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); const tick = System.create({ name: "conditionsTest/tick", resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index b9eaee3c..7fe5766e 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { System } from "../../interface/system/system.js"; import { count, read, readEach, readEntity, readResource, select } from "./reads.test-helper.js"; @@ -12,6 +10,7 @@ import { attachKernel } from "./attach-kernel.js"; import { createWorld } from "../index.js"; import { fillReports, reportOf, runReported } from "./reports.test-helper.js"; import { Report } from "../../interface/report/report.js"; +import type { Schema } from "../../interface/schema/schema.js"; type Vec2 = readonly [number, number]; @@ -32,18 +31,11 @@ declare module "@adobe/ecs" { [marker]: { type: number }; } export interface Resources { - [spawnCount]: { type: number }; - [seen]: { type: number }; + [spawnCount]: { type: Schema.Type<{ type: "integer" }>; default: 3 }; + [seen]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(frozen, { type: { const: true } }); -Component.register(marker, { type: { type: "number" } }); -Resource.register(spawnCount, { type: { type: "integer", default: 3 } }); -Resource.register(seen, { type: { type: "integer", default: 0 } }); const spawn = System.create({ name: "createTest/spawn", diff --git a/packages/ecs/src/implementation/world/directory.test.ts b/packages/ecs/src/implementation/world/directory.test.ts index e1c2d620..f2167f4c 100644 --- a/packages/ecs/src/implementation/world/directory.test.ts +++ b/packages/ecs/src/implementation/world/directory.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource } from "../../interface/resource/resource.js"; import { indexOf, reserve } from "./entities.js"; import { recordFlags, recordStorages, recordWords, resourceEntityWord, resourceFlag, storageColumn, storageComponent, storageWords, structureWord, tableOffsetWord } from "./layout-directory.js"; import { ensureResources } from "./resources.js"; @@ -10,10 +9,9 @@ import { applySync, prepareSync } from "./synchronize.js"; const gravity = Symbol("directoryTest/gravity"); declare module "@adobe/ecs" { export interface Resources { - [gravity]: { type: number }; + [gravity]: { type: number; default: -9.8 }; } } -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); describe("the layout directory", () => { it("keeps the structure epoch odd from a sync point's start until its directory writes, and even otherwise, once a thread may read it", () => { diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts index ec892fd7..44b0eb27 100644 --- a/packages/ecs/src/implementation/world/dispatch.fixture.ts +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; import type { World } from "../../interface/world/world.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of dispatching tasks to workers, run built in node and from source in chromium. */ @@ -24,23 +24,13 @@ const tagG = Symbol("dispatchTest/g"); const tags = [tagA, tagB, tagC, tagD, tagE, tagF, tagG] as const; declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; - [offset]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [offset]: { type: F32 }; [tagA]: { type: true }; [tagB]: { type: true }; [tagC]: { type: true }; [tagD]: { type: true }; [tagE]: { type: true }; [tagF]: { type: true }; [tagG]: { type: true }; } } -Component.register(position, { type: vec2 }); -Component.register(velocity, { type: vec2 }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(tagA, { type: { const: true } }); -Component.register(tagB, { type: { const: true } }); -Component.register(tagC, { type: { const: true } }); -Component.register(tagD, { type: { const: true } }); -Component.register(tagE, { type: { const: true } }); -Component.register(tagF, { type: { const: true } }); -Component.register(tagG, { type: { const: true } }); const scatter = System.create({ name: "dispatchTest/scatter" }, ({ commands }) => { for (let i = 0; i < 128 * 64; i++) { diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 8f744f1c..26fd861f 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; @@ -26,12 +25,6 @@ declare module "@adobe/ecs" { [banned]: { type: true }; } } -Component.register(weight, { type: { type: "number" } }); -Component.register(mark, { type: { type: "number" } }); -Component.register(follows, { type: Entity.schema }); -Component.register(kernelSeen, { type: { type: "number" } }); -Component.register(plainSeen, { type: { type: "number" } }); -Component.register(banned, { type: { const: true } }); /** * Counts, in base 4 by run, what the lookups gave the entity a row follows: 0 not found, 1 unchanged, 2 changed, and diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index 54375d60..309476dd 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { fillReports } from "./reports.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; // Lookups by entity with `changed`, where systems run: probes on the world's thread, and on thread // "entityLookupsTest", which a joined thread fills, as they read a probe's object there. @@ -19,20 +19,14 @@ declare module "@adobe/ecs" { entityLookupsTest: true; } export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [other]: { type: number }; [tag]: { type: true }; [extra]: { type: number }; [target]: { type: Entity }; - [label]: { type: { readonly text: string } | null; thread: "entityLookupsTest" }; + [label]: { type: { readonly text: string } | null; thread: "entityLookupsTest"; default: { text: "probe" } }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(other, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); -Component.register(extra, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: { text: "probe" } }, thread: "entityLookupsTest" }); /** * What a probe saw of its target in one run: each lookup's `changed`, or null where it found nothing; the value it diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index a4f4e2d5..487a039b 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { Resource } from "../../interface/resource/resource.js"; import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -37,7 +35,7 @@ declare module "@adobe/ecs" { [handle]: { type: number }; [hidden]: { type: true }; [source]: { type: number }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; [parent]: { type: Entity; reference: "owner" }; [label]: { type: string }; [late]: { type: number }; @@ -49,29 +47,12 @@ declare module "@adobe/ecs" { [disposing]: { type: number }; } export interface Resources { - [released]: { type: number }; - [level]: { type: number }; - [written]: { type: number }; + [released]: { type: number; default: 0 }; + [level]: { type: number; default: 0 }; + [written]: { type: number; default: 0 }; } } -Component.register(handle, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(source, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(label, { type: { type: "string" } }); -Component.register(late, { type: { type: "number" } }); -Resource.register(released, { type: { type: "number", default: 0 } }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(written, { type: { type: "number", default: 0 } }); -Component.register(counted, { type: { type: "number" } }); -Component.register(pendingHandle, { type: { type: "number" } }); -Component.register(respawned, { type: { type: "number" } }); -Component.register(ordered, { type: { type: "number" } }); -Component.register(writing, { type: { type: "number" } }); -Component.register(disposing, { type: { type: "number" } }); -Component.register(reloaded, { type: { type: "number" } }); Rule.create({ name: "finalizersTest/slots", components: { has: [source] }, derive: [slot] }); diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 6b748274..92d43679 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -1,9 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Gpu } from "../../interface/gpu/gpu.js"; -import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; +import { type F32, Mat4x4, Quat, type U32, Vec3 } from "../../interface/math/index.js"; import type { Schema } from "../../interface/schema/schema.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { System } from "../../interface/system/system.js"; @@ -30,12 +29,12 @@ const ownedBy = Symbol("gpuTest/ownedBy"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [score]: { type: F32 }; [tag]: { type: true }; - [code]: { type: number }; - [amount]: { type: number }; + [code]: { type: U32 }; + [amount]: { type: F32 }; [flag]: { type: boolean }; [followed]: { type: Entity }; [bits]: { type: Vec3.U32 }; @@ -44,15 +43,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(tag, { type: { const: true } }); -Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(amount, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(followed, { type: Entity.schema }); -Component.register(ownedBy, { type: Entity.schema, reference: "owner" }); const count = 5000; @@ -166,8 +156,6 @@ const transform = System.create({ name: "gpuTest/transform", kernel: "gpu", comp row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); }); -Component.register(bits, { type: Vec3.U32.schema }); -Component.register(sums, { type: Vec3.I32.schema }); const spawnIntegers = System.create({ name: "gpuTest/spawnIntegers" }, ({ commands }) => { for (let i = 0; i < 100; i++) { diff --git a/packages/ecs/src/implementation/world/ids-again.test-helper.ts b/packages/ecs/src/implementation/world/ids-again.test-helper.ts new file mode 100644 index 00000000..f98e75c6 --- /dev/null +++ b/packages/ecs/src/implementation/world/ids-again.test-helper.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { Rule } from "../../interface/rule/rule.js"; + +// `ids.test.ts`'s keys module, evaluated again: new symbols under the ids of its keys, which the build registers once +// a test imports it. +export const scoreAgain = Symbol("score"); +export const shownAgain = Symbol("shown"); + +identify(scoreAgain, "worldIdsTest/keys.ts#score"); +identify(shownAgain, "worldIdsTest/keys.ts#shown"); + +declare module "@adobe/ecs" { + export interface Components { + [scoreAgain]: { type: number }; + [shownAgain]: { type: true; derived: true }; + } +} + +/** The module's rule and finalizer, evaluated again, the finalizer recording into `changes`. */ +export const evaluateAgain = (changes: (readonly symbol[])[]): void => { + Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [scoreAgain] }, derive: [shownAgain] }, "worldIdsTest/systems.ts#shown")); + Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [scoreAgain] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { + for (const group of groups) changes.push(group.added); + }); +}; diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts index 74e0025c..09d74a6c 100644 --- a/packages/ecs/src/implementation/world/ids.test.ts +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; @@ -10,44 +9,26 @@ import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { read } from "./reads.test-helper.js"; -// Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. +// A module's keys, which `ids-again.test-helper.ts` makes again under the same ids: one id, two symbols each. const score = Symbol("score"); -const scoreAgain = Symbol("score"); const shown = Symbol("shown"); -const shownAgain = Symbol("shown"); identify(score, "worldIdsTest/keys.ts#score"); -identify(scoreAgain, "worldIdsTest/keys.ts#score"); identify(shown, "worldIdsTest/keys.ts#shown"); -identify(shownAgain, "worldIdsTest/keys.ts#shown"); declare module "@adobe/ecs" { export interface Components { [score]: { type: number }; - [scoreAgain]: { type: number }; [shown]: { type: true; derived: true }; - [shownAgain]: { type: true; derived: true }; } } -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { const: true }, derived: true }); Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }, "worldIdsTest/systems.ts#shown")); const changes: (readonly symbol[])[] = []; Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [score] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { for (const group of groups) changes.push(group.added); }); -// The module evaluated again, after the world met its first keys. -const evaluateAgain = (): void => { - Component.register(scoreAgain, { type: { type: "number" } }); - Component.register(shownAgain, { type: { const: true }, derived: true }); - Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [scoreAgain] }, derive: [shownAgain] }, "worldIdsTest/systems.ts#shown")); - Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [scoreAgain] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { - for (const group of groups) changes.push(group.added); - }); -}; - const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); const spawned: Entity[] = []; @@ -55,15 +36,17 @@ describe("a world given keys with ids", () => { it("stores the symbols of one id as one component, giving values by the newest", async () => { const world = createWorld({ [workers]: 0 }); await world.run(spawn); - evaluateAgain(); - const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([scoreAgain])({ [scoreAgain]: 2 })); }); - const add = System.create({ name: "worldIdsTest/add", components: { write: [scoreAgain] } }, row => { row[scoreAgain] += 10; }); + // The module evaluated again, after the world met its first keys. + const again = await import("./ids-again.test-helper.js"); + again.evaluateAgain(changes); + const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([again.scoreAgain])({ [again.scoreAgain]: 2 })); }); + const add = System.create({ name: "worldIdsTest/add", components: { write: [again.scoreAgain] } }, row => { row[again.scoreAgain] += 10; }); await world.run(spawnAgain); await world.run(add); const [first, second] = spawned; - expect([await read(world, first, score), await read(world, first, scoreAgain), await read(world, second, score)]).toEqual([11, 11, 12]); - expect([await read(world, first, shownAgain), await read(world, second, shown)]).toEqual([true, true]); - expect(changes).toEqual([[score], [scoreAgain]]); + expect([await read(world, first, score), await read(world, first, again.scoreAgain), await read(world, second, score)]).toEqual([11, 11, 12]); + expect([await read(world, first, again.shownAgain), await read(world, second, shown)]).toEqual([true, true]); + expect(changes).toEqual([[score], [again.scoreAgain]]); await world.dispose(); }); }); diff --git a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts index 8050b1c8..3452c142 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from '../../interface/archetype/archetype.js'; -import { Component } from '../../interface/component/component.js'; /** Keys and lists another module's kernels import, in `imported-lists.test.ts`. */ @@ -15,9 +14,6 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } } -Component.register(ember, { type: { type: "number" } }); -Component.register(glow, { type: { const: true } }); -Component.register(marked, { type: { const: true } }); export const Spark = Archetype.create([ember, glow]); export const Marks = [ember, marked] as const; diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts index 8610134a..e40c2312 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { count, read, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; @@ -14,7 +13,6 @@ declare module "@adobe/ecs" { [heat]: { type: number }; } } -Component.register(heat, { type: { type: "number" } }); const seed = System.create({ name: "importedListsTest/seed" }, ({ commands }) => { commands.spawn([heat])({ [heat]: 2 }); diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts index 666a12a1..6c8b00c6 100644 --- a/packages/ecs/src/implementation/world/index-buckets.test.ts +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, type CreatedWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; // Long buckets of a hash index, which a change may leave unsorted until a list is read by position: every reader still // gets each bucket's rows in entity order. @@ -20,22 +20,15 @@ const sumByHand = Symbol("indexBucketsTest/sumByHand"); declare module "@adobe/ecs" { interface Components { - [group]: { type: number }; - [serial]: { type: number }; + [group]: { type: U32 }; + [serial]: { type: U32 }; [hidden]: { type: true }; [extra]: { type: true }; - [reads]: { type: number }; + [reads]: { type: U32 }; [sum]: { type: number }; [sumByHand]: { type: number }; } } -Component.register(group, { type: { type: "integer", minimum: 0, maximum: 2 } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 10000000 } }); -Component.register(hidden, { type: { const: true } }); -Component.register(extra, { type: { const: true } }); -Component.register(reads, { type: { type: "integer", minimum: 0, maximum: 2 } }); -Component.register(sum, { type: { type: "number" } }); -Component.register(sumByHand, { type: { type: "number" } }); const groups = [0, 1, 2]; const byGroup = Index.create({ name: "indexBucketsTest/byGroup", key: [group], components: { has: [serial], not: [hidden] } }); diff --git a/packages/ecs/src/implementation/world/index-build-csp.test.ts b/packages/ecs/src/implementation/world/index-build-csp.test.ts index 6092ba39..09d552f9 100644 --- a/packages/ecs/src/implementation/world/index-build-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-build-csp.test.ts @@ -1,24 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { Vec2 } from "../../interface/math/index.js"; +import type { Schema } from "../../interface/schema/schema.js"; const spot = Symbol("indexBuildCspTest/spot"); const size = Symbol("indexBuildCspTest/size"); const bulk = Symbol("indexBuildCspTest/bulk"); declare module "@adobe/ecs" { interface Components { - [spot]: { type: readonly [number, number] }; + [spot]: { type: Vec2.I32 }; [size]: { type: number }; - [bulk]: { type: number }; + [bulk]: { type: Schema.Type<{ type: "number"; precision: 2 }> }; } } -Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(size, { type: { type: "number" } }); -Component.register(bulk, { type: { type: "number", precision: 2 } }); /** A key of a pair of i32s and an f64, so the steps encode three kinds of leaf. */ const bySpot = Index.create({ key: [spot, bulk], components: { has: [size] } }); diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts index 003233c6..21e90ffc 100644 --- a/packages/ecs/src/implementation/world/index-build.fixture.ts +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; /** Scenarios of indexes built again across the pool, run built in node and from source in chromium. */ @@ -14,14 +14,11 @@ const weight = Symbol("indexBuildTest/weight"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; - [serial]: { type: number }; + [cell]: { type: Vec2.I32 }; + [serial]: { type: U32 }; [weight]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 10000000 } }); -Component.register(weight, { type: { type: "number" } }); const grid = Index.create({ name: "indexBuildTest/grid", key: [cell], components: { has: [weight] } }); const serials = Index.create({ name: "indexBuildTest/serials", key: [serial], unique: true }); diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index f3f11696..849b81f3 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -1,13 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Index } from "../../interface/indexes/index-type.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; // An index lookup's `changed: true`: the index's entries changing is a change filter of its system. @@ -18,18 +17,14 @@ const paused = Symbol("indexChangedTest/paused"); declare module "@adobe/ecs" { interface Components { - [group]: { type: number }; + [group]: { type: U32 }; [weight]: { type: number }; [total]: { type: number }; } interface Resources { - [paused]: { type: boolean }; + [paused]: { type: boolean; default: false }; } } -Component.register(group, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); const byGroup = Index.create({ name: "indexChangedTest/byGroup", key: [group], components: { has: [weight] } }); const inOrder = Index.create({ name: "indexChangedTest/inOrder", key: [group], sorted: true }); diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 8aadce65..c41dc546 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import { Observe } from "../../observe/index.js"; @@ -12,6 +11,7 @@ import type { World } from "../../interface/world/world.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; import { backToBack } from "./loop.js"; import { dropReports, runReported } from "./reports.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; // Systems on a joined thread looking up through the world's indexes, the world and the thread on this one, against a // world running them itself. @@ -34,35 +34,23 @@ declare module "@adobe/ecs" { indexJoinedTest: true; } interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; - [serial]: { type: number }; - [target]: { type: number }; - [lane]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; + [lane]: { type: U32 }; [rank]: { type: number }; - [label]: { type: { readonly text: string }; thread: "indexJoinedTest" }; + [label]: { type: { readonly text: string }; thread: "indexJoinedTest"; default: typeof blank }; [name]: { type: { readonly text: string }; thread: "indexJoinedTest" }; [title]: { type: string }; - [band]: { type: number }; + [band]: { type: U32 }; [bandReader]: { type: true }; [marked]: { type: true }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; /** A label's start, which a command here gives it by this value. */ const blank = { text: "" }; -Component.register(label, { type: { ...text, default: blank }, thread: "indexJoinedTest" }); -Component.register(name, { type: text, thread: "indexJoinedTest" }); -Component.register(title, { type: { type: "string" } }); -Component.register(band, { type: { type: "integer", minimum: 0, maximum: 1 } }); -Component.register(bandReader, { type: { const: true } }); -Component.register(marked, { type: { const: true } }); const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index 980831ff..3e7aadfe 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -7,6 +7,7 @@ import { Observe } from "../../observe/index.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, type CreatedWorld } from "./create.js"; +import type { F32, U32, Vec2 } from "../../interface/math/index.js"; const cell = Symbol("indexKernelsTest/cell"); const weight = Symbol("indexKernelsTest/weight"); @@ -22,30 +23,19 @@ const ranked = Symbol("indexKernelsTest/ranked"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; - [weight]: { type: number }; + [cell]: { type: Vec2.I32 }; + [weight]: { type: F32 }; [total]: { type: number }; [count]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [rank]: { type: number }; - [serial]: { type: number }; - [target]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; [seen]: { type: Entity }; [changes]: { type: number }; [ranked]: { type: true }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number", precision: 1 } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(seen, { type: Entity.schema }); -Component.register(changes, { type: { type: "number" } }); -Component.register(ranked, { type: { const: true } }); const grid = Index.create({ name: "indexKernelsTest/grid", key: [cell], components: { has: [weight] } }); const lanes = Index.create({ name: "indexKernelsTest/lanes", key: [lane, rank], sorted: true, components: { has: [ranked] } }); diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index 552b45cb..8c8a741c 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -29,13 +29,6 @@ declare module "@adobe/ecs" { [visited]: { type: number }; } } -Component.register(weight, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(color, { type: { type: "number" } }); -Component.register(spin, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(visited, { type: { type: "number" } }); const shown = Index.create({ name: "indexKeylessTest/shown", key: [], components: { has: [weight], not: [hidden] } }); const everything = Index.create({ name: "indexKeylessTest/everything", key: [] }); diff --git a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts index a28b9a40..96dc81b4 100644 --- a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts @@ -1,21 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { Vec2 } from "../../interface/math/index.js"; const spot = Symbol("indexLookupsCspTest/spot"); const size = Symbol("indexLookupsCspTest/size"); declare module "@adobe/ecs" { interface Components { - [spot]: { type: readonly [number, number] }; + [spot]: { type: Vec2.I32 }; [size]: { type: number }; } } -Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -100, maximum: 100 }, minItems: 2, maxItems: 2 } }); -Component.register(size, { type: { type: "number" } }); const bySpot = Index.create({ key: [spot], components: { has: [size] } }); const bySize = Index.create({ key: [size], sorted: true, unique: true }); diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index 9fedb072..12970bd6 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { Observe } from "../../observe/index.js"; import { always } from "../../interface/resources/always.js"; @@ -9,6 +8,7 @@ import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { backToBack } from "./loop.js"; +import type { U32 } from "../../interface/math/index.js"; const rank = Symbol("indexOrderTest/rank"); const lane = Symbol("indexOrderTest/lane"); @@ -19,17 +19,12 @@ const marker = Symbol("indexOrderTest/marker"); declare module "@adobe/ecs" { interface Components { [rank]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [weight]: { type: number }; [extra]: { type: true }; [marker]: { type: number }; } } -Component.register(rank, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 100 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(extra, { type: { const: true } }); -Component.register(marker, { type: { type: "number" } }); /** By lane, then rank, of the entities that have a weight. */ const ranked = Index.create({ name: "indexOrderTest/ranked", key: [lane, rank], sorted: true, components: { has: [weight] } }); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index e2f4d400..b73dee8c 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; @@ -7,6 +6,7 @@ import { System } from "../../interface/system/system.js"; import { readEntity, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; /** Scenarios of kernels looking up through indexes across workers, run built in node and from source in chromium. */ @@ -27,36 +27,22 @@ const bandSumByHand = Symbol("indexWorkersTest/bandSumByHand"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; [total]: { type: number }; [count]: { type: number }; [changes]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [rank]: { type: number }; [seen]: { type: Entity }; - [serial]: { type: number }; - [target]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; [tally]: { type: number }; - [band]: { type: number }; + [band]: { type: U32 }; [bandSum]: { type: number }; [bandSumByHand]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(changes, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(seen, { type: Entity.schema }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(tally, { type: { type: "number" } }); -Component.register(band, { type: { type: "integer", minimum: 0, maximum: 1 } }); -Component.register(bandSum, { type: { type: "number" } }); -Component.register(bandSumByHand, { type: { type: "number" } }); const grid = Index.create({ name: "indexWorkersTest/grid", key: [cell], components: { has: [weight] } }); const lanes = Index.create({ name: "indexWorkersTest/lanes", key: [lane, rank], sorted: true }); diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index fe6a9629..b2a3b308 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Index } from "../../interface/indexes/index-type.js"; @@ -13,6 +12,7 @@ import { createWorld, type CreatedWorld } from "./create.js"; import { tablesAfterCollecting } from "./lifetime.test-helper.js"; import { backToBack } from "./loop.js"; import { runReported } from "./reports.test-helper.js"; +import type { I32, U32, Vec2 } from "../../interface/math/index.js"; const cell = Symbol("indexesTest/cell"); const weight = Symbol("indexesTest/weight"); @@ -24,23 +24,16 @@ const order = Symbol("indexesTest/order"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; [label]: { type: string }; - [serverId]: { type: number }; + [serverId]: { type: U32 }; [owner]: { type: Entity; reference: "weak" }; [hidden]: { type: true }; [order]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(serverId, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(owner, { type: Entity.schema, reference: "weak" }); -Component.register(hidden, { type: { const: true } }); -Component.register(order, { type: { type: "number" } }); const grid = Index.create({ name: "indexesTest/grid", key: [cell], components: { has: [weight], not: [hidden] } }); const byServerId = Index.create({ name: "indexesTest/byServerId", key: [serverId], unique: true }); @@ -325,7 +318,6 @@ declare module "@adobe/ecs" { [folded]: { type: number }; } } -Component.register(folded, { type: { type: "number" } }); /** A list's entities folded into one number, by position: what `foldNear` writes. */ const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity, i) => sum + (entity % 997) * (i + 1), 0); @@ -398,15 +390,11 @@ const pair = Symbol("indexesTest/pair"); declare module "@adobe/ecs" { interface Components { [rank]: { type: number }; - [lane]: { type: number }; + [lane]: { type: I32 }; [flag]: { type: boolean }; - [pair]: { type: readonly [number, number] }; + [pair]: { type: Vec2 }; } } -Component.register(rank, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: -100, maximum: 100 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(pair, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); const byRank = Index.create({ name: "indexesTest/byRank", key: [rank], sorted: true }); const byLaneRank = Index.create({ name: "indexesTest/byLaneRank", key: [lane, rank], sorted: true, components: { has: [weight] } }); diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts index b60844c3..f7db2f9b 100644 --- a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { F32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../interface/math/index.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; @@ -92,45 +91,6 @@ declare module "@adobe/ecs" { } } -Component.register(f2a, { type: Vec2.schema }); -Component.register(f2b, { type: Vec2.schema }); -Component.register(f2p, { type: Vec2.schema }); -Component.register(f2o, { type: Vec2.schema }); -Component.register(i2a, { type: Vec2.I32.schema }); -Component.register(u2a, { type: Vec2.U32.schema }); -Component.register(i2b, { type: Vec2.I32.schema }); -Component.register(u2b, { type: Vec2.U32.schema }); -Component.register(i2o, { type: Vec2.I32.schema }); -Component.register(u2o, { type: Vec2.U32.schema }); -Component.register(f3a, { type: Vec3.schema }); -Component.register(f3b, { type: Vec3.schema }); -Component.register(f3p, { type: Vec3.schema }); -Component.register(f3o, { type: Vec3.schema }); -Component.register(i3a, { type: Vec3.I32.schema }); -Component.register(u3a, { type: Vec3.U32.schema }); -Component.register(i3b, { type: Vec3.I32.schema }); -Component.register(u3b, { type: Vec3.U32.schema }); -Component.register(i3o, { type: Vec3.I32.schema }); -Component.register(u3o, { type: Vec3.U32.schema }); -Component.register(f4a, { type: Vec4.schema }); -Component.register(f4b, { type: Vec4.schema }); -Component.register(f4p, { type: Vec4.schema }); -Component.register(f4o, { type: Vec4.schema }); -Component.register(i4a, { type: Vec4.I32.schema }); -Component.register(u4a, { type: Vec4.U32.schema }); -Component.register(i4b, { type: Vec4.I32.schema }); -Component.register(u4b, { type: Vec4.U32.schema }); -Component.register(i4o, { type: Vec4.I32.schema }); -Component.register(u4o, { type: Vec4.U32.schema }); -Component.register(fo, { type: F32.schema }); -Component.register(ma, { type: Mat4x4.schema }); -Component.register(mb, { type: Mat4x4.schema }); -Component.register(mo, { type: Mat4x4.schema }); -Component.register(qa, { type: Quat.schema }); -Component.register(qb, { type: Quat.schema }); -Component.register(qo, { type: Quat.schema }); -Component.register(ua, { type: U32.schema }); -Component.register(uo, { type: U32.schema }); /** A value in [-1, 1) that differs per row and lane. */ const unit = (row: number, lane: number): number => ((Math.imul(row * 31 + lane * 7 + 1, 0x9E3779B9) >>> 0) / 2 ** 32) * 2 - 1; diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index c6ad5721..3059ca2e 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { labelOfItem } from "../../interface/registry/index.js"; import { Observe } from "../../observe/index.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -27,18 +25,12 @@ declare module "@adobe/ecs" { [words]: { type: ReadonlySet }; } export interface Resources { - [total]: { type: number }; - [names]: { type: readonly string[] }; - [counts]: { type: ReadonlyMap }; + [total]: { type: number; default: 0 }; + [names]: { type: readonly string[]; default: [] }; + [counts]: { type: ReadonlyMap; default: typeof countsStart }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); -Component.register(words, { type: { type: "set", items: { type: "string" } } }); -Resource.register(counts, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); +const countsStart = new Map(); /** Adds its struct input to `total`, and its object input's names to `names`. */ const add = System.create({ name: "joinTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { diff --git a/packages/ecs/src/implementation/world/joined-observer.test.ts b/packages/ecs/src/implementation/world/joined-observer.test.ts index 6b7856f2..e10d3c41 100644 --- a/packages/ecs/src/implementation/world/joined-observer.test.ts +++ b/packages/ecs/src/implementation/world/joined-observer.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { definitionOf } from "../../interface/registry/index.js"; import { isLeaf } from "../../interface/registry/schedule.js"; import { System } from "../../interface/system/system.js"; @@ -20,7 +19,6 @@ declare module "@adobe/ecs" { [amount]: { type: number }; } } -Component.register(amount, { type: { type: "number" } }); /** What the observer system read, were it run: this test stands for the thread, so it never runs. */ const seen: number[] = []; diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 79ea705a..d37b091c 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -1,6 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { Observe } from "../../observe/index.js"; import type { World } from "../../interface/world/world.js"; @@ -20,15 +18,10 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } export interface Resources { - [total]: { type: number }; - [names]: { type: readonly string[] }; + [total]: { type: number; default: 0 }; + [names]: { type: readonly string[]; default: [] }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); /** Appends a digit to `total`, so the order of runs shows in it. */ export const add = System.create({ name: "joiningTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { diff --git a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts index f7b5f879..c2531f1a 100644 --- a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Vec3 } from "../../index.js"; +import { Archetype, Vec3 } from "../../index.js"; export const position = Symbol("kernelImports/position"); export const spark = Symbol("kernelImports/spark"); @@ -11,8 +11,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(spark, { type: { const: true } }); /** A list a kernel imports: the build reads its components here, and compiles the kernel again when this module changes. */ export const sparks = Archetype.create([position, spark]); diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts index d8dcc455..a71e304b 100644 --- a/packages/ecs/src/implementation/world/kernel-imports.test.ts +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, Vec3, World } from "../../index.js"; +import { Entity, System, Vec3, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { position, spark, sparks } from "./kernel-imports-sparks.test-helper.js"; import { steer } from "./kernel-imports-steer.test-helper.js"; @@ -17,8 +17,6 @@ declare module "@adobe/ecs" { } } -Component.register(velocity, { type: Vec3.schema }); -Component.register(target, { type: Entity.schema }); const spawn = System.create({ name: "kernelImports/spawn" }, ({ commands }) => { const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [0, 0, 0] }); diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts index 40a8876a..d208454a 100644 --- a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from '../../interface/component/component.js'; import { Entity } from '../../interface/entity/entity.js'; import { System } from '../../interface/system/system.js'; +import type { F32 } from '../../interface/math/index.js'; /** A kernel whose lookup reads a value outside linear memory, which `System.create` rejects as this module runs. */ @@ -12,12 +12,9 @@ declare module "@adobe/ecs" { export interface Components { [target]: { type: Entity }; [label]: { type: string }; - [offset]: { type: number }; + [offset]: { type: F32 }; } } -Component.register(target, { type: Entity.schema }); -Component.register(label, { type: { type: "string" } }); -Component.register(offset, { type: { type: "number", precision: 1 } }); export const labels = System.create({ name: "kernelLookupTest/labels", diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts index cecea482..7a30af6f 100644 --- a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from '../../interface/component/component.js'; import { System } from '../../interface/system/system.js'; /** A kernel reading a leaf its component's schema doesn't have, which registering its compiled kernel rejects as this module runs. */ @@ -12,8 +11,6 @@ declare module "@adobe/ecs" { [sum]: { type: number }; } } -Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(sum, { type: { type: "number" } }); export const beyond = System.create({ name: "kernelPathTest/beyond", kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { // @ts-expect-error a pair has no third item diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index 97820ed9..7bf89bbe 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Archetype } from "../../interface/archetype/archetype.js"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { count, read, readEach, select } from "./reads.test-helper.js"; @@ -9,6 +8,7 @@ import { createWorld } from "./create.js"; import { indexOf } from "./entities.js"; import { retainedRuns } from "./reports.js"; import { fillReports } from "./reports.test-helper.js"; +import type { F32, U32, Vec2 } from "../../interface/math/index.js"; const index = Symbol("kernelSpawnsTest/index"); const value = Symbol("kernelSpawnsTest/value"); @@ -20,23 +20,16 @@ const position = Symbol("kernelSpawnsTest/position"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; - [value]: { type: number }; - [extra]: { type: number }; + [index]: { type: U32 }; + [value]: { type: F32 }; + [extra]: { type: F32 }; [flag]: { type: true }; [made]: { type: true }; [name]: { type: string | null }; - [position]: { type: readonly [number, number] }; + [position]: { type: Vec2 }; } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(extra, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); -Component.register(made, { type: { const: true } }); -Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(position, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); const spawned = Archetype.create([value, made]); diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts index 5d795d82..476d695f 100644 --- a/packages/ecs/src/implementation/world/kernels-csp.test.ts +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -1,20 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const value = Symbol("kernelsCspTest/value"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); const seed = System.create({ name: "kernelsCspTest/seed" }, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts index 7b5a56bb..697076db 100644 --- a/packages/ecs/src/implementation/world/kernels.test.ts +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { World } from "../../interface/world/world.js"; import { definitionOf } from "../../interface/registry/index.js"; @@ -21,15 +19,11 @@ declare module "@adobe/ecs" { [label]: { type: string | null }; } export interface Resources { - [scale]: { type: number }; + [scale]: { type: number; default: 1 }; } } const label = Symbol("kernelsTest/label"); -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(scale, { type: { type: "number", default: 1 } }); /** Reads `value` and `other`, writes nothing, reads no resources. */ const system = System.create({ name: "kernelsTest/read", kernel: "cpu", components: { read: [value, other] } }, row => { diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index a2777a7a..df9ba86b 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { workers } from "../../interface/resources/workers.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; +import type { F32 } from "../../interface/math/index.js"; const value = Symbol("layoutDirectoryTest/value"); const target = Symbol("layoutDirectoryTest/target"); @@ -23,9 +23,9 @@ const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [seen]: { type: number }; + [seen]: { type: F32 }; [child]: { type: true }; [tag0]: { type: true }; [tag1]: { type: true }; @@ -37,17 +37,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(seen, { type: { type: "number", precision: 1 } }); -Component.register(child, { type: { const: true } }); -Component.register(tag0, { type: { const: true } }); -Component.register(tag1, { type: { const: true } }); -Component.register(tag2, { type: { const: true } }); -Component.register(tag3, { type: { const: true } }); -Component.register(tag4, { type: { const: true } }); -Component.register(tag5, { type: { const: true } }); -Component.register(tag6, { type: { const: true } }); /** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index 18cdc1e6..0d8a2d47 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -7,6 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of workers reading layouts from the directory, run built in node and from source in chromium. */ @@ -25,9 +25,9 @@ const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [seen]: { type: number }; + [seen]: { type: F32 }; [child]: { type: true }; [tag0]: { type: true }; [tag1]: { type: true }; @@ -39,17 +39,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(seen, { type: { type: "number", precision: 1 } }); -Component.register(child, { type: { const: true } }); -Component.register(tag0, { type: { const: true } }); -Component.register(tag1, { type: { const: true } }); -Component.register(tag2, { type: { const: true } }); -Component.register(tag3, { type: { const: true } }); -Component.register(tag4, { type: { const: true } }); -Component.register(tag5, { type: { const: true } }); -Component.register(tag6, { type: { const: true } }); /** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 53aae275..43917a70 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld, type Tables } from "./create.js"; @@ -9,6 +7,7 @@ import { collector, freshKernel, tablesAfterCollecting } from "./lifetime.test-h import { readResource } from "./reads.test-helper.js"; import { fillReports, runOf } from "./reports.test-helper.js"; import { Report } from "../../interface/report/report.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of what kernels hold across the pool, run built in node and from source in chromium. */ @@ -19,19 +18,15 @@ const total = Symbol("lifetimeFixture/total"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [out]: { type: number }; + [out]: { type: F32 }; } export interface Resources { - [total]: { type: number; reduce: "+" }; + [total]: { type: number; reduce: "+"; default: 0 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(out, { type: { type: "number", precision: 1 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); export const rows = 4_096; diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts index e505d447..34702a2f 100644 --- a/packages/ecs/src/implementation/world/lifetime.test.ts +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { workers } from "../../interface/resources/workers.js"; @@ -20,13 +18,10 @@ declare module "@adobe/ecs" { [parent]: { type: Entity; reference: "owner" }; } interface Resources { - [score]: { type: number }; + [score]: { type: number; default: 7 }; } } -Component.register(count, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Resource.register(score, { type: { type: "number", default: 7 } }); const seed = System.create({ name: "lifetimeTest/seed" }, ({ commands }) => { const root = commands.spawn([count])({ [count]: 1 }); diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts index 11532205..c37e8001 100644 --- a/packages/ecs/src/implementation/world/loop.test.ts +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; import { requireDefinition, type Composite } from "../../interface/registry/index.js"; import type { Cadence } from "../../interface/resources/cadence.js"; import { backToBack, createLoop, type Clock, type LoopSettings } from "./loop.js"; @@ -15,7 +15,6 @@ declare module "@adobe/ecs" { [count]: { type: number }; } } -Component.register(count, { type: { type: "number" } }); const increment = requireDefinition(System.create({ name: "loopTest/increment", components: { write: [count] } }, row => { row[count] += 1; })); const reader = requireDefinition(System.create({ name: "loopTest/reader", components: { read: [count] } }, row => { void row[count]; })); diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index 54c1372d..f0b74709 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { createBuffer, addCommand, pushCommand, pushComponent, removeCommand, type CommandBuffer } from "./buffer.js"; import { readCell } from "./cells.js"; import { createCommands } from "./commands.js"; @@ -35,21 +34,15 @@ declare module "@adobe/ecs" { [uid]: { type: number }; [amount]: { type: number }; [flag]: { type: true }; - [label]: { type: { readonly text: string } | null; thread: "moveLogTest" }; + [label]: { type: { readonly text: string } | null; thread: "moveLogTest"; default: typeof none }; [note]: { type: Note | null; thread: "moveLogTest" }; [own]: { type: string }; } } -Component.register(uid, { type: { type: "number" } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(flag, { type: { const: true } }); /** A value that isn't data. */ type Note = { readonly text: string; readonly read: () => string }; /** The label's start, which a command on another thread than its own gives it by this value. */ const none = { text: "none" }; -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "moveLogTest" }); -Component.register(note, { thread: "moveLogTest" }); -Component.register(own, { type: { type: "string" } }); const remoteKeys = [label, note] as const; diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index ad5a43fc..1baf3e12 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; @@ -24,10 +23,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Component.register(first, { type: { const: true }, derived: true }); -Component.register(second, { type: { const: true }, derived: true }); // Two libraries, written apart, both name a rule, a finalizer and a system "linked". Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [first] }); diff --git a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts index 37aed0d0..f28637ec 100644 --- a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -8,6 +7,7 @@ import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; import { Observe } from "../../observe/index.js"; import { settle } from "./observe.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; // The observer factories of `src/observe` over values GPU kernels wrote. @@ -18,13 +18,11 @@ const rate = Symbol("observeCasesGpuTest/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); let spawned: Entity[] = []; const spawn = System.create({ name: "observeCasesGpuTest/spawn" }, ({ commands }) => { diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 2edd18f2..2b66146d 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "../index.js"; @@ -11,6 +9,7 @@ import { settle } from "./observe.test-helper.js"; import { fillReports, runReported, runThenNext } from "./reports.test-helper.js"; import { retainedRuns } from "./reports.js"; import { Report } from "../../interface/report/report.js"; +import type { F32 } from "../../interface/math/index.js"; // The observer factories of `src/observe`. Each case subscribes, settles, then expects, so it holds whenever a first // value comes. @@ -22,19 +21,15 @@ const weight = Symbol("observeCasesTest/weight"); declare module "@adobe/ecs" { interface Resources { - [level]: { type: number }; - [other]: { type: number }; - [total]: { type: number; reduce: "+" }; + [level]: { type: number; default: 0 }; + [other]: { type: number; default: 0 }; + [total]: { type: number; reduce: "+"; default: 0 }; } interface Components { [weight]: { type: number }; } } -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(other, { type: { type: "number", default: 0 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Component.register(weight, { type: { type: "number" } }); const raise = System.create({ name: "observeCasesTest/raise", resources: { write: [level] } }, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; @@ -82,10 +77,6 @@ declare module "@adobe/ecs" { } } -Component.register(heat, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); -Component.register(flag, { type: { const: true } }); -Component.register(filler, { type: { type: "number" } }); /** The entity the systems below act on, set by `create`. */ let target = Entity.none; @@ -114,9 +105,6 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); -Component.register(benched, { type: { const: true } }); -Component.register(marker, { type: { const: true } }); /** Players the systems below act on, in spawn order, set by `players`. */ let roster: Entity[] = []; @@ -157,17 +145,14 @@ declare module "@adobe/ecs" { } } -Component.register(rank, { type: { type: "number" } }); -Component.register(second, { type: { type: "number" } }); const coarse = Symbol("observeCasesTest/coarse"); declare module "@adobe/ecs" { interface Components { - [coarse]: { type: number }; + [coarse]: { type: F32 }; } } // Kept at 32 bits, so a 64-bit bound may fall between two values it keeps. -Component.register(coarse, { type: { type: "number", precision: 1 } }); let coarseValues: readonly number[] = []; const spawnCoarse = System.create({ name: "observeCasesTest/spawnCoarse" }, ({ commands }) => { for (const each of coarseValues) commands.spawn([coarse])({ [coarse]: each }); diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index 42381980..d3106dc5 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { Observe } from "../../observe/index.js"; @@ -24,12 +22,9 @@ declare module "@adobe/ecs" { [other]: { type: number }; } interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 0 }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); let spawned: Entity[] = []; const seed = System.create({ name: "observeFactoriesTest/seed" }, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index be0c1629..452674b0 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -31,26 +29,17 @@ declare module "@adobe/ecs" { [label]: { type: string }; [marked]: { type: true }; [place]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "observeJoinedTest" }; + [caption]: { type: { readonly text: string } | null; thread: "observeJoinedTest"; default: typeof none }; [widget]: { type: { readonly draw: () => string } | null; thread: "observeJoinedTest" }; [kept]: { type: string }; } export interface Resources { - [total]: { type: number }; - [names]: { type: readonly string[] }; + [total]: { type: number; default: 0 }; + [names]: { type: readonly string[]; default: [] }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Component.register(place, { type: { type: "number" } }); /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "observeJoinedTest" }); -Component.register(widget, { thread: "observeJoinedTest" }); -Component.register(kept, { type: { type: "string" } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); const add = System.create({ name: "observeJoinedTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 40a9208c..788ee088 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Resource, System, always, cadence, clock, wake, workers } from "../../index.js"; +import { always, cadence, clock, type F32, System, wake, workers } from "../../index.js"; import type { World } from "../../interface/world/world.js"; import { createWorld } from "./create.js"; import { Observe } from "../../observe/index.js"; @@ -15,20 +15,15 @@ const watched = Symbol("observeTicksCasesTest/watched"); const flag = Symbol("observeTicksCasesTest/flag"); declare module "@adobe/ecs" { interface Resources { - [frames]: { type: number }; - [seen]: { type: number }; + [frames]: { type: number; default: 0 }; + [seen]: { type: number; default: -1 }; } interface Components { - [value]: { type: number }; - [watched]: { type: number }; + [value]: { type: F32 }; + [watched]: { type: F32 }; [flag]: { type: true }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); -Resource.register(seen, { type: { type: "number", default: -1 } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(watched, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); const count = System.create({ name: "observeTicksCasesTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); const watch = System.create({ name: "observeTicksCasesTest/watch", resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts index f27c2fa4..31a9da53 100644 --- a/packages/ecs/src/implementation/world/observed-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of tick stamps on workers, run built in node and from source in chromium. */ @@ -12,13 +12,11 @@ const seen = Symbol("observedWorkersTest/seen"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; - [seen]: { type: number }; + [value]: { type: F32 }; + [seen]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(seen, { type: { type: "number", precision: 1 } }); const seed = System.create({ name: "observedWorkersTest/seed" }, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, seen])({ [value]: i, [seen]: 0 }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 092123f4..6f83e6ef 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the observer-contained fixtures import: a counter the world bumps, and an observer // system reading only resources that a joined thread adds, which can hold its run until the world's thread releases it. @@ -9,13 +9,11 @@ export const tally = Symbol("observerContainedTest/tally"); export const holding = Symbol("observerContainedTest/holding"); declare module "@adobe/ecs" { export interface Resources { - [tally]: { type: number }; - [holding]: { type: number }; + [tally]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [holding]: { type: number; default: 0 }; } } -Resource.register(tally, { type: { type: "integer", default: 0 } }); /** While negative, each run of the watcher says it reached its value and waits until the world's thread releases it. */ -Resource.register(holding, { type: { type: "number", default: 0 } }); /** Shared words: the value a held run reached, and the value the world's thread released runs through. */ export const reachedWord = 0; diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index a2d69767..6371ed9e 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource, System, workers } from "../../index.js"; +import { System, workers } from "../../index.js"; import { createWorld, join } from "./create.js"; // Observer systems reading only resources on a joined thread, whose posts hold copies of what they read, with the world @@ -11,15 +11,12 @@ const paused = Symbol("observerContainedTest/paused"); const steps = Symbol("observerContainedTest/steps"); declare module "@adobe/ecs" { interface Resources { - [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number] }; - [paused]: { type: boolean }; - [steps]: { type: number }; + [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number]; default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }; + [paused]: { type: boolean; default: false }; + [steps]: { type: number; default: 0 }; } } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ -Resource.register(wide, { type: { type: "array", items: { type: "number" }, minItems: 12, maxItems: 12, default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); const widen = System.create({ name: "observerContainedTest/widen", resources: { write: [wide] } }, ({ resources }) => { const first = resources[wide][0] + 1; diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 04e00468..20eb4ad0 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { F32, Vec3 } from "../../interface/math/index.js"; // Keys and systems both threads of the observer-joined fixtures import: world systems writing every row's stamp each // tick, and observer systems a joined thread adds, reading those rows as of one sync point. @@ -20,38 +20,28 @@ export const motto = Symbol("observerJoinedWorkerTest/motto"); export const mottoRound = Symbol("observerJoinedWorkerTest/mottoRound"); declare module "@adobe/ecs" { export interface Components { - [stamp]: { type: number }; - [spot]: { type: readonly [number, number, number] }; - [heat]: { type: number }; - [rate]: { type: number }; + [stamp]: { type: Schema.Type<{ type: "integer" }> }; + [spot]: { type: Vec3 }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; [word]: { type: string }; } export interface Resources { - [counter]: { type: number }; - [stall]: { type: number }; - [phase]: { type: number }; - [motto]: { type: string }; - [mottoRound]: { type: number }; + [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [stall]: { type: number; default: 0 }; + [phase]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [motto]: { type: string; default: "motto 0" }; + [mottoRound]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } -Component.register(stamp, { type: { type: "integer" } }); -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); /** A string the world writes each tick, which lives in shared memory where the observer system reads it. */ -Component.register(word, { type: { type: "string" } }); -Resource.register(counter, { type: { type: "integer", default: 0 } }); /** A string resource the world writes with the round it's of, which an observer system on the thread reads alone. */ -Resource.register(motto, { type: { type: "string", default: "motto 0" } }); -Resource.register(mottoRound, { type: { type: "integer", default: 0 } }); /** Moves on once a joined thread asked the world to run its observer system, so the world's thread knows it did. */ -Resource.register(phase, { type: { type: "integer", default: 0 } }); /** * Milliseconds the observer systems spin partway through each run, so the world's next writer is likely ready before * they finish; negative, they wait there on the gate instead, until the world's thread releases them once a writer * copied. */ -Resource.register(stall, { type: { type: "number", default: 0 } }); const spin = (milliseconds: number): void => { const until = performance.now() + milliseconds; diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 8d3dfd98..00b0d1af 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Resource, System, workers } from "../../index.js"; +import { Index, System, workers } from "../../index.js"; import type { Entity } from "../../interface/entity/entity.js"; import type { World } from "../../interface/world/world.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; @@ -24,21 +24,15 @@ declare module "@adobe/ecs" { [amount]: { type: number }; [cell]: { type: number }; [tagged]: { type: true }; - [label]: { type: { readonly text: string }; thread: "observerJoinedTest" }; - [name]: { type: string }; + [label]: { type: { readonly text: string }; thread: "observerJoinedTest"; default: typeof blank }; + [name]: { type: string; default: "" }; } interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 0 }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(cell, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); /** The label's start, which a command here gives it by this value. */ const blank = { text: "" }; -Component.register(label, { type: { type: "object", properties: { text: { type: "string" } }, default: blank }, thread: "observerJoinedTest" }); -Component.register(name, { type: { type: "string", default: "" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); const byCell = Index.create({ name: "observerJoinedTest/byCell", key: [cell], components: { has: [amount] } }); const allTagged = Index.create({ name: "observerJoinedTest/allTagged", key: [], components: { has: [tagged] } }); diff --git a/packages/ecs/src/implementation/world/observer.browser.test.ts b/packages/ecs/src/implementation/world/observer.browser.test.ts index 6ed774f2..0fb70a2d 100644 --- a/packages/ecs/src/implementation/world/observer.browser.test.ts +++ b/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -1,24 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const heat = Symbol("observerGpuTest/heat"); const rate = Symbol("observerGpuTest/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); const spawn = System.create({ name: "observerGpuTest/spawn" }, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index 0aadabbb..3eec2f0e 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Report, Resource, System, workers } from "../../index.js"; +import { Index, Report, System, workers } from "../../index.js"; import { isObserver, requireDefinition } from "../../interface/registry/index.js"; import type { World } from "../../interface/world/world.js"; import { createWorld } from "./create.js"; @@ -18,12 +18,9 @@ declare module "@adobe/ecs" { [cell]: { type: number }; } interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 0 }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(cell, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); const byCell = Index.create({ name: "observerTest/byCell", key: [cell] }); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index 0cc42c53..9a672ebc 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -1,21 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { Observe } from "../../observe/index.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of the `workers` resource sizing the pool, run built in node and from source in chromium. */ const value = Symbol("poolSizeTest/value"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); const seed = System.create({ name: "poolSizeTest/seed" }, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts index 733e9ae3..fe0527a1 100644 --- a/packages/ecs/src/implementation/world/ports.fixture.ts +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../../interface/component/component.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -17,7 +16,6 @@ declare module "@adobe/ecs" { [value]: { type: number }; } } -Component.register(value, { type: { type: "number" } }); const seed = System.create({ name: "portsTest/seed" }, ({ commands }) => { for (let i = 0; i < 100_000; i++) commands.spawn([value])({ [value]: i }); diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index 565b225d..610d10e0 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -1,10 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { readResource } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of reductions across workers, run built in node and from source in chromium. */ @@ -16,21 +15,16 @@ const largest = Symbol("reductionsBrowserTest/largest"); declare module "@adobe/ecs" { export interface Components { - [weight]: { type: number }; + [weight]: { type: F32 }; [sized]: { type: true }; } export interface Resources { - [count]: { type: number; reduce: "+" }; - [sum]: { type: number; reduce: "+" }; - [largest]: { type: number; reduce: "max" }; + [count]: { type: number; reduce: "+"; default: 0 }; + [sum]: { type: number; reduce: "+"; default: 0 }; + [largest]: { type: number; reduce: "max"; default: 0 }; } } -Component.register(weight, { type: { type: "number", precision: 1 } }); -Component.register(sized, { type: { const: true } }); -Resource.register(count, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(largest, { type: { type: "number", default: 0 }, reduce: "max" }); export const rows = 100_000; export const weightAt = (i: number): number => Math.fround(Math.sin(i * 0.37) * 1000); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index f83d79a7..c88bf064 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Vec3 } from "../../interface/math/index.js"; -import { Resource } from "../../interface/resource/resource.js"; +import { type I32, type U32, Vec3 } from "../../interface/math/index.js"; +import { declareResource } from "../../interface/registry/declare.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { readResource } from "./reads.test-helper.js"; @@ -29,34 +28,21 @@ const summed = Symbol("reductionsTest/summed"); declare module "@adobe/ecs" { export interface Components { [position]: { type: Vec3 }; - [mass]: { type: number }; + [mass]: { type: U32 }; [heavy]: { type: true }; } export interface Resources { - [total]: { type: number; reduce: "+" }; - [low]: { type: readonly [number, number, number]; reduce: "min" }; - [bits]: { type: number; reduce: "|" }; - [least]: { type: number; reduce: "min" }; - [any]: { type: boolean; reduce: "|" }; - [level]: { type: number }; - [seen]: { type: number }; - [wrapped]: { type: number; reduce: "+" }; - [summed]: { type: boolean; reduce: "|" }; + [total]: { type: number; reduce: "+"; default: 0 }; + [low]: { type: Vec3; reduce: "min"; default: [0, 0, 0] }; + [bits]: { type: U32; reduce: "|"; default: 0 }; + [least]: { type: U32; reduce: "min"; default: 0 }; + [any]: { type: boolean; reduce: "|"; default: false }; + [level]: { type: number; default: 0 }; + [seen]: { type: number; default: 0 }; + [wrapped]: { type: I32; default: 5; reduce: "+" }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(mass, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(heavy, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(low, { type: { ...Vec3.schema, default: [0, 0, 0] }, reduce: "min" }); -Resource.register(bits, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "|" }); -Resource.register(least, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "min" }); -Resource.register(any, { type: { type: "boolean", default: false }, reduce: "|" }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(seen, { type: { type: "number", default: 0 } }); -Resource.register(wrapped, { type: { type: "integer", minimum: -(2 ** 31), maximum: 2 ** 31 - 1, default: 5 }, reduce: "+" }); - const rows = 3000; const positionAt = (i: number): Vec3 => [Math.sin(i) * 100, (i % 97) - 50, i / 7]; const massAt = (i: number): number => (i * 2654435761) >>> 20; @@ -247,8 +233,8 @@ describe("reductions", () => { }); it("reject what can't be reduced", async () => { - // @ts-expect-error booleans reduce bitwise - expect(() => Resource.register(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); + // Booleans reduce bitwise. + expect(() => declareResource(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error a contributor repeats its resource's operator expect(() => System.create({ name: "reductionsTest/other", components: { has: [mass] }, reduce: { [total]: "max" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total" with "max", but it's declared reduce: "+"`); // @ts-expect-error only a resource with a reduce trait is reduced diff --git a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts index dadb9bf7..dab5712f 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; // A module the identity example evaluates twice, as a dev server does after an edit. export const score = Symbol("reevaluation/score"); @@ -10,7 +10,6 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); export const bump = System.create({ name: "reevaluation/bump", components: { write: [score] } }, row => { row[score] += 1; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 6d473b38..d411fe31 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { Vec3 } from "../../interface/math/index.js"; // Keys, systems and a finalizer both threads of the remote finalizers tests import: a finalizer on thread // "remoteFinalizersTest", which a joined thread fills, reading structs, a constant, that thread's objects and its @@ -33,72 +33,39 @@ declare module "@adobe/ecs" { remoteFinalizersTest: true; } export interface Components { - [serial]: { type: number }; + [serial]: { type: Schema.Type<{ type: "integer" }> }; [place]: { type: readonly [number, number] }; [marked]: { type: true }; [flagged]: { type: true }; [widget]: { type: Widget | null; thread: "remoteFinalizersTest" }; - [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest" }; + [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest"; default: { text: "none" } }; [title]: { type: string }; [headed]: { type: true }; } export interface Resources { - [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; - [heading]: { type: string }; - [subheading]: { type: string }; - [level]: { type: number }; - [origin]: { type: readonly [number, number] }; - [caption]: { type: string }; - [lit]: { type: boolean }; - [tint]: { type: readonly [number, number, number] }; - [sample]: { type: Sample }; - [material]: { type: Material }; + [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest"; default: { text: "quiet" } }; + [heading]: { type: string; default: "h" }; + [subheading]: { type: string; default: "s" }; + [level]: { type: number; default: -1 }; + [origin]: { type: readonly [number, number]; default: [-1, -1] }; + [caption]: { type: string; default: "c" }; + [lit]: { type: boolean; default: false }; + [tint]: { type: Vec3; default: [-1, -1, -1] }; + [sample]: { type: Schema.Type<{ type: "object"; properties: { on: { type: "boolean" }; count: { type: "integer"; minimum: 0; maximum: 4294967295 }; weight: { type: "number" }; ratio: { type: "number"; precision: 1 } } }>; default: { on: false; count: 0; weight: -1; ratio: -1 } }; + [material]: { type: Schema.Type<{ type: "object"; properties: { alpha: { type: "number"; precision: 1 }; color: { type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }; glossy: { type: "boolean" } }; layout: "storage" }>; default: { alpha: -1; color: [-1, -1, -1]; glossy: false } }; } } /** A value that isn't data, which only its thread holds. */ type Widget = { readonly tag: string; readonly draw: () => string }; -Component.register(serial, { type: { type: "integer" } }); -Component.register(place, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(marked, { type: { const: true } }); -Component.register(flagged, { type: { const: true } }); -Component.register(widget, { thread: "remoteFinalizersTest" }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(label, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "remoteFinalizersTest" }); -Resource.register(tone, { type: { ...text, default: { text: "quiet" } }, thread: "remoteFinalizersTest" }); /** A string, in shared memory, which the world's thread writes and the finalizer on the thread reads. */ -Component.register(title, { type: { type: "string" } }); -Component.register(headed, { type: { const: true } }); /** A string resource, which the world's thread writes and the finalizer on the thread reads. */ -Resource.register(heading, { type: { type: "string", default: "h" } }); -Resource.register(subheading, { type: { type: "string", default: "s" } }); /** Linear resources, a number and a struct, which the world's thread writes and the finalizer on the thread reads. */ -Resource.register(level, { type: { type: "number", default: -1 } }); -Resource.register(origin, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, default: [-1, -1] } }); /** Resources of mixed widths: a string, a boolean (1 byte), an f32 vec3 (12), a padded CPU struct (24) and a storage struct (32). */ type Sample = { readonly on: boolean; readonly count: number; readonly weight: number; readonly ratio: number }; type Material = { readonly alpha: number; readonly color: readonly [number, number, number]; readonly glossy: boolean }; const f32 = { type: "number", precision: 1 } as const; const vec3 = { type: "array", items: f32, minItems: 3, maxItems: 3 } as const; -Resource.register(caption, { type: { type: "string", default: "c" } }); -Resource.register(lit, { type: { type: "boolean", default: false } }); -Resource.register(tint, { type: { ...vec3, default: [-1, -1, -1] } }); -Resource.register(sample, { - type: { - type: "object", - properties: { on: { type: "boolean" }, count: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF }, weight: { type: "number" }, ratio: f32 }, - required: ["on", "count", "weight", "ratio"], - default: { on: false, count: 0, weight: -1, ratio: -1 }, - }, -}); -Resource.register(material, { - type: { - type: "object", - properties: { alpha: f32, color: vec3, glossy: { type: "boolean" } }, - required: ["alpha", "color", "glossy"], - layout: "storage", - default: { alpha: -1, color: [-1, -1, -1], glossy: false }, - }, -}); /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index b605f7e9..35cc5fcf 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -4,7 +4,6 @@ import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Observe } from "../../observe/index.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -19,14 +18,11 @@ const second = Symbol("remoteFinalizersTest/second"); const third = Symbol("remoteFinalizersTest/third"); declare module "@adobe/ecs" { export interface Resources { - [later]: { type: number }; - [second]: { type: number }; - [third]: { type: number }; + [later]: { type: number; default: 0 }; + [second]: { type: number; default: 0 }; + [third]: { type: number; default: 0 }; } } -Resource.register(later, { type: { type: "number", default: 0 } }); -Resource.register(second, { type: { type: "number", default: 0 } }); -Resource.register(third, { type: { type: "number", default: 0 } }); const bump = System.create({ name: "remoteFinalizersTest/bump", resources: { write: [later] } }, ({ resources }) => { resources[later] += 1; }); diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index 1d952d71..8227a92d 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -20,15 +19,11 @@ declare module "@adobe/ecs" { [serial]: { type: number }; [phase]: { type: number }; [flag]: { type: true }; - [caption]: { type: { readonly text: string } | null; thread: "remoteObjects" }; + [caption]: { type: { readonly text: string } | null; thread: "remoteObjects"; default: typeof none }; } } -Component.register(serial, { type: { type: "number" } }); -Component.register(phase, { type: { type: "number" } }); -Component.register(flag, { type: { const: true } }); /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteObjects" }); /** A caption's text, null for none, as the watchers record it. */ const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index e7aae3be..f1722008 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Observe } from "../../observe/index.js"; @@ -25,7 +24,7 @@ declare module "@adobe/ecs" { } export interface Components { [point]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "remoteTest" }; + [caption]: { type: { readonly text: string } | null; thread: "remoteTest"; default: typeof none }; [widget]: { type: Widget | null; thread: "remoteTest" }; [kept]: { type: string }; [guarded]: { type: Widget | null; thread: "remoteTestFinalized" }; @@ -34,15 +33,9 @@ declare module "@adobe/ecs" { } /** A value that isn't data. */ type Widget = { readonly draw: () => string }; -Component.register(point, { type: { type: "number" } }); /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteTest" }); -Component.register(widget, { thread: "remoteTest" }); -Component.register(kept, { type: { type: "string" } }); /** A widget, or the number a kernel gives one, which only the world's thread could hold. */ -Component.register(handle, { thread: "remoteTest" }); -Component.register(guarded, { thread: "remoteTestFinalized" }); const spawn = System.create({ name: "remoteTest/spawn" }, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index e6f3e519..8b55e55c 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the removal tests import: systems on thread "removeTest", which a joined thread // fills, the first of each pair removing the second from the task they run in. @@ -14,17 +14,14 @@ declare module "@adobe/ecs" { removeTest: true; } export interface Resources { - [mark]: { type: { readonly text: string }; thread: "removeTest" }; - [pause]: { type: number }; - [secondRuns]: { type: number }; + [mark]: { type: { readonly text: string }; thread: "removeTest"; default: { text: "m" } }; + [pause]: { type: number; default: 0 }; + [secondRuns]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } /** An object resource of the thread, which places the systems reading it there. */ -Resource.register(mark, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "m" } }, thread: "removeTest" }); /** How long, in milliseconds, `first` and `firstTail` spin before they return. */ -Resource.register(pause, { type: { type: "number", default: 0 } }); /** How many times `second` ran, wherever it ran. */ -Resource.register(secondRuns, { type: { type: "integer", default: 0 } }); /** How many times each system's body was called on this thread. */ export const calls = { first: 0, second: 0, firstTail: 0, secondTail: 0 }; diff --git a/packages/ecs/src/implementation/world/remove.test.ts b/packages/ecs/src/implementation/world/remove.test.ts index 945d3157..e8afc749 100644 --- a/packages/ecs/src/implementation/world/remove.test.ts +++ b/packages/ecs/src/implementation/world/remove.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Observe } from "../../observe/index.js"; import { always } from "../../interface/resources/always.js"; @@ -24,9 +23,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(shared, { type: { type: "number" } }); /** How many times each system's body was called. */ const calls = { kept: 0, held: 0, alone: 0, other: 0, late: 0, self: 0, writer: 0, reader: 0 }; diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts index 3bed86a2..295e7ff5 100644 --- a/packages/ecs/src/implementation/world/render-order.browser.test.ts +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Gpu } from "../../interface/gpu/gpu.js"; import { Index } from "../../interface/indexes/index-type.js"; @@ -8,6 +7,7 @@ import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import type { F32, U32, Vec3 } from "../../interface/math/index.js"; const depth = Symbol("renderOrderTest/depth"); const code = Symbol("renderOrderTest/code"); @@ -19,23 +19,16 @@ const hidden = Symbol("renderOrderTest/hidden"); declare module "@adobe/ecs" { export interface Components { - [depth]: { type: number }; - [code]: { type: number }; + [depth]: { type: F32 }; + [code]: { type: U32 }; [shape]: { type: true }; [image]: { type: true }; - [size]: { type: readonly [number, number, number] }; + [size]: { type: Vec3 }; [visible]: { type: true }; [hidden]: { type: true }; } } -Component.register(depth, { type: { type: "number", precision: 1 } }); -Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(shape, { type: { const: true } }); -Component.register(image, { type: { const: true } }); -Component.register(size, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(visible, { type: { const: true } }); -Component.register(hidden, { type: { const: true } }); const byDepth = Index.create({ name: "renderOrderTest/byDepth", key: [depth], sorted: true }); diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index ab80b52e..ad3e236e 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Report, Resource, System, clock, workers } from "../../index.js"; +import { Index, Report, System, clock, workers } from "../../index.js"; import { createWorld } from "./create.js"; import { retainedRuns } from "./reports.js"; import { reportOf, runOf } from "./reports.test-helper.js"; @@ -12,11 +12,9 @@ declare module "@adobe/ecs" { [amount]: { type: number }; } interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 0 }; } } -Component.register(amount, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); const nothing = System.create({ name: "reportsTest/nothing" }, () => {}); const fail = System.create({ name: "reportsTest/fail" }, () => { throw new Error("run failed"); }); diff --git a/packages/ecs/src/implementation/world/rows.test.ts b/packages/ecs/src/implementation/world/rows.test.ts index 4269f9fd..71baba00 100644 --- a/packages/ecs/src/implementation/world/rows.test.ts +++ b/packages/ecs/src/implementation/world/rows.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { U32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -17,17 +17,13 @@ const tag = Symbol("rowsTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [count]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [count]: { type: U32 }; [flag]: { type: boolean }; [tag]: { type: true }; } } -Component.register(position, { type: vec3 }); -Component.register(count, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(tag, { type: { const: true } }); const seed = System.create({ name: "rowsTest/seed" }, ({ commands }) => { for (let i = 0; i < 4; i++) commands.spawn(i < 2 ? [position, count, flag, tag] : [position, count, flag])({ [position]: [i, i + 0.5, -i], [count]: i, [flag]: i % 2 === 0 }); diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index c36f5e4b..822baaca 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -8,6 +8,7 @@ import { createWorld } from "./create.js"; import { count, read } from "./reads.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; +import { declareComponent } from "../../interface/registry/declare.js"; import { labelOfItem, programRules } from "../../interface/registry/index.js"; const source = Symbol("rulesTest/source"); @@ -31,7 +32,7 @@ declare module "@adobe/ecs" { [source]: { type: number }; [hidden]: { type: true }; [sprite]: { type: true }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 7 }; [visible]: { type: true; derived: true }; [ready]: { type: number; within: typeof slot }; [glow]: { type: true; within: typeof ready }; @@ -39,27 +40,12 @@ declare module "@adobe/ecs" { [follows]: { type: Entity; reference: "weak" }; [mark]: { type: number }; [left]: { type: true; within: typeof right }; - [right]: { type: true; within: typeof left }; [late]: { type: true; derived: true }; [tagged]: { type: true }; [tag]: { type: true; derived: true }; } } -Component.register(source, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(sprite, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 7 }, derived: true }); -Component.register(visible, { type: { const: true }, derived: true }); -Component.register(ready, { type: { type: "number" }, within: slot }); -Component.register(glow, { type: { const: true }, within: ready }); -Component.register(owned, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(mark, { type: { type: "number" } }); -Component.register(left, { type: { const: true }, within: right }); -Component.register(late, { type: { const: true }, derived: true }); -Component.register(tagged, { type: { const: true } }); -Component.register(tag, { type: { const: true }, derived: true }); Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); Rule.create({ name: "rulesTest/visible", components: { has: [slot] }, derive: [visible] }); @@ -159,7 +145,7 @@ describe("rules", () => { // @ts-expect-error a reference changes only through commands expect(() => System.create({ name: "rulesTest/writeReference", components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. - expect(() => Component.register(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); + expect(() => declareComponent(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); }); }); diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts index a7cfdfdd..ac5e0ab5 100644 --- a/packages/ecs/src/implementation/world/schedule.test.ts +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Composite } from "../../interface/system/composite.js"; import type { Declaration } from "../../interface/system/declaration.js"; import type { Definition } from "../../interface/registry/index.js"; @@ -17,8 +16,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); /** Systems built as values, not registered, so each test's are its own. */ const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts index 73fae368..d7f2f1fb 100644 --- a/packages/ecs/src/implementation/world/semantics.test.ts +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -17,16 +16,12 @@ const score = Symbol("semanticsTest/score"); const tag = Symbol("semanticsTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; [target]: { type: Entity }; [score]: { type: number }; [tag]: { type: true }; } } -Component.register(position, { type: vec2 }); -Component.register(target, { type: Entity.schema }); -Component.register(score, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); const seed = System.create({ name: "semanticsTest/seed" }, ({ commands }) => { // @ts-expect-error untyped callers can store any number as an entity diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index 923f1019..996f1ba8 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, join } from "./create.js"; @@ -24,17 +22,11 @@ declare module "@adobe/ecs" { [note]: { type: string | null }; } export interface Resources { - [mode]: { type: "edit" | "view" }; - [title]: { type: string }; + [mode]: { type: "edit" | "view"; default: "view" }; + [title]: { type: string; default: "" }; } } -Component.register(label, { type: { type: "string" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); -Component.register(note, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" } }); -Resource.register(title, { type: { type: "string", default: "" } }); /** Text of every kind a string holds: none, letters, astral characters as surrogate pairs, and a long run. */ const textOf = (n: number): string => n % 4 === 0 ? "" : n % 4 === 1 ? `item ${n}` : n % 4 === 2 ? `✓ ${n} 😀 naïve` : `${"long ".repeat(40)}${n}`; diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index 4f387eab..ae1b1791 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { Vec3 } from "../../interface/math/index.js"; // Keys and systems both threads of the tails fixtures import: a world system writing every row's stamp each tick, and // terminal systems on thread "tailsTest", which a joined thread fills, running there as tails. @@ -20,38 +20,27 @@ declare module "@adobe/ecs" { tailsTest: true; } export interface Components { - [stamp]: { type: number }; - [spot]: { type: readonly [number, number, number] }; - [label]: { type: { readonly text: string } | null; thread: "tailsTest" }; + [stamp]: { type: Schema.Type<{ type: "integer" }> }; + [spot]: { type: Vec3 }; + [label]: { type: { readonly text: string } | null; thread: "tailsTest"; default: null }; [word]: { type: string }; } export interface Resources { - [counter]: { type: number }; - [stall]: { type: { readonly mode: number; readonly pause: number } }; - [spawning]: { type: number }; - [failAt]: { type: number }; - [canvas]: { type: { readonly text: string }; thread: "tailsTest" }; + [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [stall]: { type: Schema.Type<{ type: "object"; properties: { mode: { type: "integer" }; pause: { type: "number" } } }>; default: { mode: 0; pause: 0 } }; + [spawning]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [failAt]: { type: Schema.Type<{ type: "integer" }>; default: -1 }; + [canvas]: { type: { readonly text: string }; thread: "tailsTest"; default: { text: "canvas" } }; } } -Component.register(stamp, { type: { type: "integer" } }); -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); /** An object of the tail's thread, which the tail writes. */ const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(label, { type: { anyOf: [text, { type: "null" }], default: null }, thread: "tailsTest" }); /** A string the world writes each tick, which lives in shared memory where the tail reads it. */ -Component.register(word, { type: { type: "string" } }); -Resource.register(counter, { type: { type: "integer", default: 0 } }); /** * How the tails stall, for milliseconds `pause`: `"none"`, `"chunk"` in the watcher at the end of its first chunk, so * a writer is ready mid-way, or `"before"` in a tail ahead of it, so the watcher is late. */ -Resource.register(stall, { - type: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } }, required: ["mode", "pause"], default: { mode: 0, pause: 0 } }, -}); -Resource.register(spawning, { type: { type: "integer", default: 0 } }); -Resource.register(failAt, { type: { type: "integer", default: -1 } }); /** An object resource of the tail's thread, which places the systems reading it there. */ -Resource.register(canvas, { type: { ...text, default: { text: "canvas" } }, thread: "tailsTest" }); export const noStall = 0; export const stallAtChunk = 1; diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts index 1a9a1c95..b7131acc 100644 --- a/packages/ecs/src/implementation/world/target.test.ts +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Entity, System, workers } from "../../index.js"; +import { Entity, System, workers } from "../../index.js"; import { createWorld } from "./create.js"; const amount = Symbol("targetTest/amount"); @@ -11,8 +11,6 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); let spawned: Entity[] = []; const spawn = System.create({ name: "targetTest/spawn" }, ({ commands }) => { diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index bd995cdd..3efeb1aa 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -1,11 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Observe } from "../../observe/index.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { runReported, type Reported } from "./reports.test-helper.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the thread systems fixtures import: systems on thread "threadSystemsTest", which a // joined thread fills, beside systems on the world's thread, and what each world holds after the same runs. @@ -28,41 +27,28 @@ declare module "@adobe/ecs" { threadSystemsTest: true; } export interface Components { - [uid]: { type: number }; + [uid]: { type: Schema.Type<{ type: "integer" }> }; [position]: { type: readonly [number, number] }; [velocity]: { type: number }; [flagged]: { type: true }; - [caption]: { type: { readonly text: string } | null; thread: "threadSystemsTest" }; + [caption]: { type: { readonly text: string } | null; thread: "threadSystemsTest"; default: { text: "none" } }; [note]: { type: Note | null; thread: "threadSystemsTest" }; [kept]: { type: string }; } export interface Resources { - [total]: { type: number; reduce: "+" }; - [changes]: { type: number }; - [paint]: { type: { readonly text: string }; thread: "threadSystemsTest" }; - [painted]: { type: number }; - [enabled]: { type: boolean }; - [nextUid]: { type: number }; + [total]: { type: number; reduce: "+"; default: 0 }; + [changes]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [paint]: { type: { readonly text: string }; thread: "threadSystemsTest"; default: { text: "plain" } }; + [painted]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [enabled]: { type: boolean; default: true }; + [nextUid]: { type: Schema.Type<{ type: "integer" }>; default: 1 }; } } /** A value that isn't data, which only its thread holds. */ type Note = { readonly text: string; readonly read: () => string }; -Component.register(uid, { type: { type: "integer" } }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(flagged, { type: { const: true } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(caption, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "threadSystemsTest" }); -Component.register(note, { thread: "threadSystemsTest" }); /** An object of the world's thread. */ -Component.register(kept, { type: { type: "string" } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(changes, { type: { type: "integer", default: 0 } }); /** An object resource of the thread, which only systems there read and write. */ -Resource.register(paint, { type: { ...text, default: { text: "plain" } }, thread: "threadSystemsTest" }); -Resource.register(painted, { type: { type: "integer", default: 0 } }); -Resource.register(enabled, { type: { type: "boolean", default: true } }); -Resource.register(nextUid, { type: { type: "integer", default: 1 } }); const makeNote = (text: string): Note => ({ text, read: () => text }); diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index 19617036..859015fb 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource } from "../../interface/resource/resource.js"; import { always } from "../../interface/resources/always.js"; import { clock } from "../../interface/resources/clock.js"; import { System } from "../../interface/system/system.js"; @@ -11,10 +10,9 @@ import { workers } from "../../interface/resources/workers.js"; const frames = Symbol("ticksBrowserTest/frames"); declare module "@adobe/ecs" { export interface Resources { - [frames]: { type: number }; + [frames]: { type: number; default: 0 }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); const count = System.create({ name: "ticksBrowserTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); const start = System.create({ name: "ticksBrowserTest/start", resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 7ef52049..0910343e 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource, System, clock, workers } from "../../index.js"; +import { System, clock, workers } from "../../index.js"; import { createWorld } from "./create.js"; // The cases of ticks that read through observers are in `observe-ticks-cases.test.ts`. @@ -8,10 +8,9 @@ import { createWorld } from "./create.js"; const frames = Symbol("ticksTest/frames"); declare module "@adobe/ecs" { interface Resources { - [frames]: { type: number }; + [frames]: { type: number; default: 0 }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); const count = System.create({ name: "ticksTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts index 49e2d1a4..5a22f894 100644 --- a/packages/ecs/src/implementation/world/traits.test.ts +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { count, read } from "./reads.test-helper.js"; @@ -15,16 +14,12 @@ const parent = Symbol("traitsTest/parent"); declare module "@adobe/ecs" { export interface Components { [score]: { type: number }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; [ready]: { type: true; within: typeof slot }; [parent]: { type: Entity; reference: "owner" }; } } -Component.register(score, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(ready, { type: { const: true }, within: slot }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); describe("traits", () => { it("keep derived components out of commands, for untyped callers too", async () => { diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts index 7614da63..a1363f71 100644 --- a/packages/ecs/src/implementation/world/transfers.test-helper.ts +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems every thread of the transfer tests imports: systems on thread "transfersTest", which a joined thread // fills, and on the world's, taking Transferable values in their input, and `transfer` components holding them. @@ -29,20 +28,12 @@ declare module "@adobe/ecs" { [depth]: { type: number }; } export interface Resources { - [anchor]: { type: { readonly text: string }; thread: "transfersTest" }; - [order]: { type: number }; + [anchor]: { type: { readonly text: string }; thread: "transfersTest"; default: { text: "anchor" } }; + [order]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } /** An object resource of "transfersTest", which places the systems reading it there. */ -Resource.register(anchor, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "anchor" } }, thread: "transfersTest" }); /** Counts the runs that read it, in the order they ran. */ -Resource.register(order, { type: { type: "integer", default: 0 } }); -Component.register(port, { thread: "transfersTest", transfer: true }); -Component.register(bitmap, { thread: "transfersTest", transfer: true }); -Component.register(portHere, { transfer: true }); -Component.register(bitmapHere, { transfer: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(depth, { type: { type: "number" } }); const any = { allOf: [] } as const; diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts index 591ae175..351c163c 100644 --- a/packages/ecs/src/implementation/world/up-tree.test.ts +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -24,11 +23,6 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(full, { type: { type: "number" } }); -Component.register(filtered, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "weak" }); -Component.register(hidden, { type: { const: true } }); const propagateFull = System.create({ name: "upTreeTest/propagateFull", diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts index c38b777c..ab80305b 100644 --- a/packages/ecs/src/implementation/world/up-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; @@ -21,9 +20,6 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); const propagate = System.create({ name: "upWorkersTest/propagate", diff --git a/packages/ecs/src/implementation/world/up.browser.test.ts b/packages/ecs/src/implementation/world/up.browser.test.ts index c081bee4..edc5e765 100644 --- a/packages/ecs/src/implementation/world/up.browser.test.ts +++ b/packages/ecs/src/implementation/world/up.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -15,18 +14,15 @@ const marked = Symbol("upBrowserTest/marked"); declare module "@adobe/ecs" { interface Components { [parent]: { type: Entity; reference: "owner" }; - [offset]: { type: number }; - [placed]: { type: number }; + [offset]: { type: F32 }; + [placed]: { type: F32 }; [marked]: { type: true }; } } -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(placed, { type: { type: "number", precision: 1 } }); -Component.register(marked, { type: { const: true } }); import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const place = System.create({ name: "upBrowserTest/place", diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index 27ff139d..d15e933b 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { definitionOf } from "../../interface/registry/index.js"; @@ -31,13 +30,6 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(label, { type: { type: "string" } }); -Component.register(visible, { type: { type: "boolean" } }); -Component.register(shown, { type: { type: "boolean" } }); -Component.register(anchor, { type: { const: true } }); const ran: string[] = []; const propagate = System.create({ diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 2837c543..214fb44b 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -13,6 +12,7 @@ import { createWorld } from "./create.js"; import { bindKernel } from "./kernels.js"; import { createState } from "./state.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32, I32 } from "../../interface/math/index.js"; type Vec2 = readonly [number, number]; const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; @@ -28,27 +28,20 @@ const target = Symbol("wasmTest/target"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; - [bounces]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2; layout: "storage" }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [bounces]: { type: I32 }; [flipped]: { type: boolean }; [score]: { type: number }; [target]: { type: Entity }; } export interface Resources { - [seconds]: { type: number }; - [floor]: { type: number }; + [seconds]: { type: number; default: typeof secondsStart }; + [floor]: { type: F32; default: -2.5 }; } } -Component.register(position, { type: { ...vec2, layout: "storage" } }); -Component.register(velocity, { type: vec2 }); -Component.register(bounces, { type: { type: "integer", minimum: -2147483648, maximum: 2147483647 } }); -Component.register(flipped, { type: { type: "boolean" } }); -Component.register(score, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Resource.register(seconds, { type: { type: "number", default: 1 / 60 } }); -Resource.register(floor, { type: { type: "number", precision: 1, default: -2.5 } }); +const secondsStart = 1 / 60; const seed = System.create({ name: "wasmTest/seed" }, ({ commands }, input: { readonly count: number }) => { let previous = Entity.none; diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index fec596d0..ca3a48f8 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -10,6 +8,7 @@ import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; import { countingWorkers } from "./platforms.test-helper.js"; import type { WorkerPlatform } from "./worker-platform.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of kernels on workers against the main thread alone, run built in node and from source in chromium. */ @@ -35,10 +34,10 @@ const seconds = Symbol("workersTest/seconds"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; [target]: { type: Entity }; - [offset]: { type: number }; + [offset]: { type: F32 }; [spawned]: { type: true }; [tagA]: { type: true }; [tagB]: { type: true }; @@ -51,24 +50,10 @@ declare module "@adobe/ecs" { [tagI]: { type: true }; } export interface Resources { - [seconds]: { type: number }; + [seconds]: { type: number; default: 0.25 }; } } -Component.register(position, { type: vec2 }); -Component.register(velocity, { type: vec2 }); -Component.register(target, { type: Entity.schema }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(spawned, { type: { const: true } }); -Component.register(tagA, { type: { const: true } }); -Component.register(tagB, { type: { const: true } }); -Component.register(tagC, { type: { const: true } }); -Component.register(tagD, { type: { const: true } }); -Component.register(tagE, { type: { const: true } }); -Component.register(tagF, { type: { const: true } }); -Component.register(tagG, { type: { const: true } }); -Component.register(tagH, { type: { const: true } }); -Component.register(tagI, { type: { const: true } }); /** 64 entities in each of the 512 archetypes the tags combine into. */ const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => { @@ -78,7 +63,6 @@ const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5] }); } }); -Resource.register(seconds, { type: { type: "number", default: 0.25 } }); /** Spawns `count` moving entities, each following the one before it. */ const seed = System.create({ name: "workersTest/seed" }, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts index c89bbf29..395e60bf 100644 --- a/packages/ecs/src/interface/archetype/archetype.type-test.ts +++ b/packages/ecs/src/interface/archetype/archetype.type-test.ts @@ -15,9 +15,6 @@ declare module "@adobe/ecs" { [scale]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Resource.register(scale, { type: { type: "number", default: 0 } }); type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; const expectType = (): T | undefined => undefined; diff --git a/packages/ecs/src/interface/archetype/create.test.ts b/packages/ecs/src/interface/archetype/create.test.ts index c45e116c..fa5c13e5 100644 --- a/packages/ecs/src/interface/archetype/create.test.ts +++ b/packages/ecs/src/interface/archetype/create.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { archetypeIndexOf } from "../registry/index.js"; import { Archetype } from "./archetype.js"; @@ -12,8 +11,6 @@ declare module "@adobe/ecs" { [velocity]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); describe("Archetype.create", () => { it("returns its argument, with a program-wide index of its own", () => { diff --git a/packages/ecs/src/interface/component/declaration.ts b/packages/ecs/src/interface/component/declaration.ts deleted file mode 100644 index 02125114..00000000 --- a/packages/ecs/src/interface/component/declaration.ts +++ /dev/null @@ -1,68 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsData } from "../schema/is-data.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Required } from "../trait/required.js"; -import type { Traits } from "../trait/types.js"; -import type { SymbolKeys } from "../trait/symbol-keys.js"; -import type { Key } from "./key.js"; -import type { Components } from "./types.js"; - -/** A value starts at `default`, which a type that allows `null` may leave out: it's `null` then. */ -export type Starting = null extends T ? { readonly default?: T } : { readonly default: T }; - -type Same = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) ? true : false; - -/** How deep `Flattened` goes: deeper values are compared as they are, so recursive types stay finite. */ -type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7]; - -/** `T` with every object, at any depth up to eight, as one object type, so `A & B` compares equal to the object it is. */ -type Flattened = - [D] extends [never] ? T : - T extends string | number | boolean | bigint | symbol | null | undefined | ((...parameters: never[]) => unknown) ? T : - T extends ReadonlyMap ? (T extends Map ? T : ReadonlyMap, Flattened>) : - T extends ReadonlySet ? (T extends Set ? T : ReadonlySet>) : - { [K in keyof T]: Flattened }; - -/** Whether `A` and `B` are the same type, flattened only when they aren't identical. */ -type Equivalent = Same extends true ? true : Same, Flattened>; - -/** - * Nothing when schema `S` describes exactly the declared type `T` and its `default`, if any, is a `T`; else a required - * property naming the problem, so registration fails to compile and says why. A check rather than `type`'s own type, - * so `type` is inferred from the schema alone. - */ -export type Describing = - ((S extends Schema ? Equivalent, T> : false) extends true ? {} : { readonly "the schema describes another type than the declared one": T }) - & (S extends { readonly default: infer V } ? ([V] extends [T] ? {} : { readonly "the schema's default isn't of the declared type": T }) : {}); - -/** A derived component starts at its schema's `default`, at its `const`, or at `null` when its type allows it. */ -type DerivedStart = - S extends { readonly const: unknown } | { readonly default: unknown } ? {} : - null extends T ? {} : - { readonly "a derived component's schema has a default, which it starts at": T }; - -/** - * The value type's part of a declaration: `type`, schema `S`, describing exactly the declared type `T` when it's data. - * A type that isn't data has no `type`, and a derived one starts at the declaration's `default`. - */ -type TypePart = - IsData extends true - ? [S] extends [never] ? { readonly type: Schema } : Describing & { readonly default?: never } & (Derived extends true ? DerivedStart : {}) - : { readonly type?: never } & (Derived extends true ? Starting : { readonly default?: never }); - -/** - * What `register(K, ...)` takes: exactly the traits `Components[K]` declares, `type` as schema `S` for the declared - * type. A trait whose declared value its definition doesn't allow on `K`, or one a value of its type must declare and - * doesn't, becomes `never`, so registration fails to compile, as every registration does while a component, resource - * or trait is keyed by a string (`SymbolKeys`). - */ -export type Declaration = - { readonly type?: S } - & TypePart - & { readonly [T in Exclude]: - T extends TraitKey ? (Components[K][T] extends Traits[T] ? Components[K][T] : never) : - never } - & { readonly [T in Exclude]?: never } - & Required - & SymbolKeys; diff --git a/packages/ecs/src/interface/component/declaration.type-test.ts b/packages/ecs/src/interface/component/declaration.type-test.ts deleted file mode 100644 index c2cbee4f..00000000 --- a/packages/ecs/src/interface/component/declaration.type-test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from '../entity/entity.js'; -import { Vec3 } from '../math/index.js'; -import { Component } from './component.js'; - -type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; -const check = <_T extends true>() => { }; - -const position = Symbol("declarationTypeTest/position"); -const score = Symbol("declarationTypeTest/score"); -const parent = Symbol("declarationTypeTest/parent"); -const follows = Symbol("declarationTypeTest/follows"); -const tags = Symbol("declarationTypeTest/tags"); -const weights = Symbol("declarationTypeTest/weights"); -const element = Symbol("declarationTypeTest/element"); -const handle = Symbol("declarationTypeTest/handle"); -const cache = Symbol("declarationTypeTest/cache"); -const level = Symbol("declarationTypeTest/level"); - -// The declared traits are the source of truth; registration must give exactly them. -declare module '@adobe/ecs' { - export interface Components { - [position]: { type: Vec3 }; - [score]: { type: number }; - [parent]: { type: Entity; reference: "owner" }; - [follows]: { type: Entity; reference: "weak" }; - [tags]: { type: ReadonlySet }; - [weights]: { type: ReadonlyMap }; - [element]: { type: HTMLElement; thread: "main" }; - [handle]: { type: { readonly release: () => void } }; - [cache]: { type: Map }; - [level]: { type: number; derived: true }; - } -} - -Component.register(position, { type: Vec3.schema }); -Component.register(score, { type: { type: "integer" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(tags, { type: { type: "set", items: { type: "string" } } }); -Component.register(weights, { type: { type: "map", keys: Entity.schema, values: { type: "number" } } }); -Component.register(level, { type: { type: "number", default: 0 }, derived: true }); - -// A schema describes exactly the declared type. -// @ts-expect-error a variable-length array isn't a Vec3 -Component.register(position, { type: { type: "array", items: { type: "number" } } }); -// @ts-expect-error a string isn't a number -Component.register(score, { type: { type: "string" } }); -// @ts-expect-error a number's schema isn't an entity's -Component.register(parent, { type: { type: "integer" }, reference: "owner" }); -// @ts-expect-error a set of numbers isn't a set of strings -Component.register(tags, { type: { type: "set", items: { type: "number" } } }); -// @ts-expect-error an array isn't a set -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -// @ts-expect-error a map's keys are entities -Component.register(weights, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } } }); - -// Every declared trait is registered, with its declared value, and nothing undeclared is. -// @ts-expect-error a declared trait is missing -Component.register(parent, { type: Entity.schema }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, thread: "main" }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, derived: true }); -// @ts-expect-error a trait's value differs from the declared one -Component.register(follows, { type: Entity.schema, reference: "owner" }); -// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. -const placed = { type: { type: "number" }, thread: "main" } as const; -// @ts-expect-error an undeclared trait is given -Component.register(score, placed); - -// A type that's data is registered with its schema; one that isn't data has none, and is stored as it is. -// @ts-expect-error a number is data, so it has a schema -Component.register(score, {}); -// @ts-expect-error a set of strings is data too -Component.register(tags, {}); -Component.register(element, { thread: "main" }); -Component.register(handle, {}); -// A Set or Map changes in place, so it isn't data. -Component.register(cache, {}); -// @ts-expect-error a value that isn't data has no schema -Component.register(handle, { type: { type: "object", properties: {} } }); - -// Value types are the declared ones. -check, Vec3>>(); -check, ReadonlySet>>(); -check, ReadonlyMap>>(); -check, HTMLElement>>(); diff --git a/packages/ecs/src/interface/component/index.ts b/packages/ecs/src/interface/component/index.ts index ac4e9182..4c6a6b0a 100644 --- a/packages/ecs/src/interface/component/index.ts +++ b/packages/ecs/src/interface/component/index.ts @@ -1,4 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './register.js'; export type { Key } from './key.js'; export type { Value, With } from './value.js'; diff --git a/packages/ecs/src/interface/component/key.type-test.ts b/packages/ecs/src/interface/component/key.type-test.ts index a5582d81..06644e20 100644 --- a/packages/ecs/src/interface/component/key.type-test.ts +++ b/packages/ecs/src/interface/component/key.type-test.ts @@ -16,8 +16,6 @@ declare module "@adobe/ecs" { readonly [mark]: true; } } -Component.register(position, { type: { type: "number" } }); -Resource.register(scale, { type: { type: "number", default: 0 } }); // Components, resources and traits are named by keys; systems, rules and finalizers by names. export const component: Component.Key = position; diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index c2666f77..5287f0e5 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -1,9 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; +import { declareComponent, declareTrait } from "../registry/declare.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; -import { Trait } from "../trait/trait.js"; -import { Component } from "./component.js"; const parent = Symbol("typesComponentTest/parent"); const count = Symbol("typesComponentTest/count"); @@ -28,32 +27,17 @@ declare module "@adobe/ecs" { [position]: { type: readonly [number, number] }; [bitmap]: { type: ImageBitmap; transfer: true }; [marked]: { type: true; [mark]: true }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; } export interface Traits { readonly [mark]: true; - readonly [late]: true; } } -Trait.register(mark); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(count, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(placed, { type: { type: "array", items: { type: "string" } }, thread: "typesComponentTest" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(bitmap, { transfer: true }); -Component.register(marked, { type: { const: true }, [mark]: true }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +/** Registers a declaration as the build does, which a test may write wrong. */ +const register = (key: unknown, declaration: object): void => declareComponent(key, declaration); -const untyped: (...parameters: never[]) => unknown = Component.register; - -/** Registers as an untyped caller would, bypassing the types' checks. */ -const register = (key: unknown, declaration: object): void => { - Reflect.apply(untyped, undefined, [key, declaration]); -}; - -describe("Component.register", () => { +describe("registering a component", () => { it("records the declaration, and its thread: none for a struct or a string, else its own or \"world\"", () => { expect(declarationOf(parent)).toEqual({ type: Entity.schema, reference: "owner" }); expect(kindOf(parent)).toBe("component"); @@ -68,26 +52,27 @@ describe("Component.register", () => { expect(threadOfDeclaration({ type: { anyOf: [{ type: "string" }, { type: "number" }] } })).toBe("world"); }); it("accepts the same declaration again, as a module evaluated again makes it, and rejects a different one or a key of another kind", () => { - expect(() => Component.register(count, { type: { type: "number" } })).not.toThrow(); + expect(() => register(count, { type: { type: "number" } })).not.toThrow(); expect(() => register(count, { type: { type: "integer" } })).toThrow(`Cannot register component "typesComponentTest/count": already registered as a component, with another declaration.`); expect(declarationOf(count)).toEqual({ type: { type: "number" } }); const claimed = Symbol("typesComponentTest/claimed"); register(claimed, { type: { const: true } }); - expect(() => Reflect.apply(Trait.register, undefined, [claimed])).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); + expect(() => declareTrait(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); expect(kindOf(count)).toBe("component"); }); it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { - // @ts-expect-error a key is a symbol, and derived is a built-in trait's name - expect(() => Component.register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); + expect(() => register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); expect(() => register(Symbol.for("typesComponentTest/shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); expect(() => register(Symbol.asyncIterator, { type: { const: true } })).toThrow("Symbol.asyncIterator is a well-known symbol."); expect(kindOf("derived")).toBe(undefined); }); - it("rejects what types reject, for untyped callers, recording nothing", () => { + it("rejects a declaration whose traits can't hold, recording nothing", () => { expect(() => register(Symbol("typesComponentTest/strayTrait"), { type: { type: "number" }, unknown: true })).toThrow("unknown isn't a registered trait"); expect(() => register(Symbol("typesComponentTest/badReference"), { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component's type has a default"); expect(() => register(Symbol("typesComponentTest/reduced"), { type: { type: "number" }, reduce: "+" })).toThrow("only a resource is reduced"); + expect(() => register(Symbol("typesComponentTest/derivedWithin"), { type: { type: "number", default: 0 }, derived: true, within: count })).toThrow("a derived component's rule covers within"); + expect(() => register(Symbol("typesComponentTest/derivedReference"), { type: { ...Entity.schema, default: Entity.none }, derived: true, reference: "owner" })).toThrow("a rule can't set a derived component's target"); const badReference = Symbol("typesComponentTest/badReference"); expect(() => register(badReference, { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); expect(declarationOf(badReference)).toBe(undefined); @@ -108,7 +93,7 @@ describe("Component.register", () => { it("records no type for a value that isn't data", () => { expect(declarationOf(bitmap)).toEqual({ transfer: true }); expect(Reflect.has(declarationOf(bitmap) ?? {}, "type")).toBe(false); - expect(() => Component.register(bitmap, { transfer: true })).not.toThrow(); + expect(() => register(bitmap, { transfer: true })).not.toThrow(); }); it("rejects a default beside a component's schema, which holds it", () => { expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).toThrow("its schema holds its default"); @@ -116,10 +101,10 @@ describe("Component.register", () => { it("takes traits other packages register, in the namespace components share", () => { const early = Symbol("typesComponentTest/early"); expect(() => register(early, { type: { const: true }, [late]: true })).toThrow(`typesComponentTest/late isn't a registered trait`); - Trait.register(late); + declareTrait(late); expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); - expect(() => Trait.register(mark)).not.toThrow(); + expect(() => declareTrait(mark)).not.toThrow(); expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); }); }); diff --git a/packages/ecs/src/interface/component/register.ts b/packages/ecs/src/interface/component/register.ts deleted file mode 100644 index 99afedc9..00000000 --- a/packages/ecs/src/interface/component/register.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; -import type { Resource } from "../resource/resource.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Declaration } from "./declaration.js"; -import type { Key } from "./key.js"; - -const registry = createRegistry("component"); - -/** - * Registers the component `key`, a plain `Symbol(...)` whose traits `Components` declares, with exactly those traits: - * `type` as a schema that describes the declared type, left out only for a type that isn't data, which is stored as - * it is. - * ```ts - * declare module "@adobe/ecs" { interface Components { [position]: { type: Vec3 } } } - * Component.register(position, { type: Vec3.schema }); - * ``` - * The declaration is fixed from then on. The same declaration again is accepted, and so is a new symbol with the id - * of a component, as a module evaluated again makes it, which is then that key. A key also declared as a resource or - * a trait can't be registered: names are unique across the three. Throws for a key that isn't a plain symbol (a - * `Symbol.for` or well-known symbol isn't), one registered, or whose id is registered, as another kind or with another - * declaration, a trait that isn't registered, a trait's value that can't hold for the declaration, or a schema asking - * for a GPU layout it can't meet. - */ -export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; -export function register(key: unknown, declaration: AnyDeclaration): void { - registry(key, declaration); -} diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index cba1ad24..b26b4fbd 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { identify } from "../registry/ids.test-helper.js"; import { labelOfItem, programFinalizers, programRules } from "../registry/index.js"; -import { Resource } from "../resource/resource.js"; import { Rule } from "../rule/rule.js"; import { clock } from "../resources/index.js"; import { Finalizer } from "./finalizer.js"; @@ -22,27 +20,18 @@ declare module "@adobe/ecs" { export interface Components { [node]: { type: HTMLElement; thread: "main" }; [label]: { type: string }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; [mark]: { type: true; derived: true }; [other]: { type: true; derived: true }; [tags]: { type: readonly string[] }; } export interface Resources { - [total]: { type: number; reduce: "+" }; - [time]: { type: number }; + [total]: { type: number; reduce: "+"; default: 0 }; + [time]: { type: number; default: 0 }; [page]: { type: Document | null; thread: "main" }; } } -Component.register(node, { thread: "main" }); -Component.register(label, { type: { type: "string" } }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(page, { thread: "main" }); -Component.register(mark, { type: { const: true }, derived: true }); -Component.register(other, { type: { const: true }, derived: true }); /** Declares as an untyped caller would, bypassing the types' checks. */ const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object): unknown => Reflect.apply(constructor, undefined, [declaration, () => {}]); diff --git a/packages/ecs/src/interface/gpu/gpu.type-test.ts b/packages/ecs/src/interface/gpu/gpu.type-test.ts index 090e0711..63334575 100644 --- a/packages/ecs/src/interface/gpu/gpu.type-test.ts +++ b/packages/ecs/src/interface/gpu/gpu.type-test.ts @@ -12,7 +12,6 @@ declare module '@adobe/ecs' { [tint]: { type: number }; } } -Component.register(tint, { type: { type: "number" } }); declare const wgsl: typeof Gpu.wgsl; declare const layout: typeof Gpu.layout; diff --git a/packages/ecs/src/interface/index.ts b/packages/ecs/src/interface/index.ts index 1afca4f3..f6c0066c 100644 --- a/packages/ecs/src/interface/index.ts +++ b/packages/ecs/src/interface/index.ts @@ -12,7 +12,6 @@ export { Resource } from './resource/resource.js'; export { Rule } from './rule/rule.js'; export type { Schema } from './schema/schema.js'; export { System } from './system/system.js'; -export { Trait } from './trait/trait.js'; export type { World } from './world/world.js'; export type { Components } from './component/types.js'; export type { Resources } from './resource/types.js'; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts index fc0fbaee..74141d22 100644 --- a/packages/ecs/src/interface/index.type-test.ts +++ b/packages/ecs/src/interface/index.type-test.ts @@ -18,10 +18,6 @@ declare module "@adobe/ecs" { [scale]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(element, { thread: "main" }); -Resource.register(scale, { type: { type: "number", default: 1 } }); export const move = System.create({ name: "indexTypeTest/move", components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); export const show = System.create({ name: "indexTypeTest/show", components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); export const all = System.create({ name: "indexTypeTest/all", systems: [move, show] }); diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index 8ab3007d..4a77b2b9 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { Entity } from "../entity/entity.js"; import { Finalizer } from "../finalizer/finalizer.js"; import { identify } from "../registry/ids.test-helper.js"; @@ -9,6 +8,7 @@ import { accessOf } from "../registry/schedule.js"; import { threadOf } from "../registry/threads.js"; import { System } from "../system/system.js"; import { Index } from "./index-type.js"; +import type { Vec2 } from "../math/index.js"; const cell = Symbol("indexCreateTest/cell"); const point = Symbol("indexCreateTest/point"); @@ -24,7 +24,7 @@ const view = Symbol("indexCreateTest/view"); declare module "@adobe/ecs" { export interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [point]: { type: { readonly x: number; readonly y: number } }; [amount]: { type: number }; [name]: { type: string }; @@ -36,16 +36,6 @@ declare module "@adobe/ecs" { [view]: { type: { readonly label: string }; thread: "main" }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(point, { type: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(name, { type: { type: "string" } }); -Component.register(node, { type: { type: "object", properties: { label: { type: "string" } } } }); -Component.register(tags, { type: { type: "set", items: { type: "string" } } }); -Component.register(shown, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(handle, {}); -Component.register(view, { type: { type: "object", properties: { label: { type: "string" } } }, thread: "main" }); /** Declares as an untyped caller would, bypassing the types' checks. */ const untyped = (declaration: object): unknown => Reflect.apply(Index.create, undefined, [declaration]); diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index d1c50e39..ab857ee6 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -38,18 +38,6 @@ declare module '@adobe/ecs' { [label]: { type: string }; } } -Component.register(cell, { type: Vec3.I32.schema }); -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(serverId, { type: { type: "number" } }); -Component.register(health, { type: { type: "number" } }); -Component.register(track, { type: Entity.schema }); -Component.register(start, { type: { type: "number" } }); -Component.register(clip, { type: { type: "number" } }); -Component.register(playhead, { type: { type: "number" } }); -Component.register(owner, { type: Entity.schema, reference: "owner" }); -Component.register(archived, { type: { const: true } }); -Component.register(label, { type: { type: "string" } }); // An index is typed by its declaration, as written. const grid = Index.create({ name: "indexTest/grid", key: [cell], components: { has: [position] } }); diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index 2227946f..b647fd2c 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -12,7 +12,7 @@ import { isTrait } from './traits.js'; * Without `type`, the value isn't data. A resource's `default`, or a derived object's, is what it starts at, when its * schema has none. */ -export type AnyDeclaration = { readonly type?: Schema; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; +export type AnyDeclaration = { readonly type?: Schema & { readonly default?: unknown }; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; /** The value schema of any registered component or resource, or undefined for a value that isn't data. */ export const schemaOf = (key: unknown): Schema | undefined => declarationOf(key)?.type; @@ -64,6 +64,8 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl cycle.push(first); } if (declaration.derived !== undefined && kind !== "component") fail("only a component is derived."); + if (declaration.derived === true && declaration.within !== undefined) fail("a derived component's rule covers within."); + if (declaration.derived === true && declaration.reference !== undefined) fail("a rule can't set a derived component's target, so it's no reference."); if (declaration.derived === true && type !== undefined && !("default" in type || "const" in type || allowsNull(type))) { fail("a derived component's type has a default, which it starts at."); } diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index af81d82d..2019d70f 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -1,11 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { Finalizer } from "../finalizer/finalizer.js"; -import { Resource } from "../resource/resource.js"; import { Rule } from "../rule/rule.js"; import { System } from "../system/system.js"; -import { Trait } from "../trait/trait.js"; +import { declareComponent, declareResource, declareTrait } from "./declare.js"; import { identify } from "./ids.test-helper.js"; import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; @@ -45,14 +43,11 @@ declare module "@adobe/ecs" { [shown]: { type: true; derived: true }; [shownAgain]: { type: true; derived: true }; [changed]: { type: number }; - [changedAgain]: { type: string }; [crossed]: { type: number }; - [unnamed]: { type: number }; } export interface Resources { - [level]: { type: number }; - [levelAgain]: { type: number }; - [crossedAgain]: { type: number }; + [level]: { type: number; default: 0 }; + [levelAgain]: { type: number; default: 0 }; } export interface Traits { readonly [tagged]: true; @@ -60,40 +55,37 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { const: true }, derived: true }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Component.register(changed, { type: { type: "number" } }); -Component.register(crossed, { type: { type: "number" } }); -Trait.register(tagged); +// Keys registered late, or wrongly, aren't declared, so the build doesn't register them. +/** Creates a system naming keys no declaration types. */ +const untypedSystem = (declaration: object, body: (row: { [key: symbol]: number }) => void): unknown => Reflect.apply(System.create, undefined, [declaration, body]); describe("keys with ids", () => { it("labels a key by its description, and by its id once another registered key has its description", () => { expect(labelOf(score)).toBe("score"); - Component.register(unnamed, { type: { type: "number" } }); + declareComponent(unnamed, { type: { type: "number" } }); expect([labelOf(score), labelOf(unnamed)]).toEqual(["idsTest/keys.ts#score", "idsTest/other.ts#score"]); expect(labelOf(Symbol("score"))).toBe("score"); }); it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { - Component.register(scoreAgain, { type: { type: "number" } }); - Component.register(shownAgain, { type: { const: true }, derived: true }); - Resource.register(levelAgain, { type: { type: "number", default: 0 } }); - Trait.register(taggedAgain); + declareComponent(scoreAgain, { type: { type: "number" } }); + declareComponent(shownAgain, { type: { const: true }, derived: true }); + declareResource(levelAgain, { type: { type: "number", default: 0 } }); + declareTrait(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); expect(labelOf(scoreAgain)).toBe("idsTest/keys.ts#score"); }); it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { - expect(() => Component.register(changedAgain, { type: { type: "string" } })).toThrow( + expect(() => declareComponent(changedAgain, { type: { type: "string" } })).toThrow( `Cannot register component "idsTest/keys.ts#changed": its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); - expect(() => Resource.register(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( + expect(() => declareResource(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( `Cannot register resource "idsTest/keys.ts#crossed": its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); it("keeps keys of different ids apart, though their descriptions match", () => { - Component.register(unnamed, { type: { type: "number" } }); + declareComponent(unnamed, { type: { type: "number" } }); System.create(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), row => { row[score] = 1; }); - expect(() => System.create(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), row => { row[unnamed] = 1; })).toThrow("reload the page"); + expect(() => untypedSystem(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), row => { row[unnamed] = 1; })).toThrow("reload the page"); }); }); diff --git a/packages/ecs/src/interface/registry/names.test.ts b/packages/ecs/src/interface/registry/names.test.ts index c3ec8e15..dd15eb94 100644 --- a/packages/ecs/src/interface/registry/names.test.ts +++ b/packages/ecs/src/interface/registry/names.test.ts @@ -66,7 +66,18 @@ describe("labelOf", () => { claimKey(third, "trait"); expect([labelOf(first), labelOf(second), labelOf(third)]).toEqual(["labelOf/twin#2", "labelOf/twin", "labelOf/twin#3"]); }); - it("gives a key without a description a numbered label, and names other values as text", () => { + it("labels a key without a description by its binding, which the plugin's id names, else by its id", () => { + const speed = Symbol(); + const other = Symbol(); + identify(speed, "app@1.0.0/keys.ts#labelOfSpeed"); + identify(other, "app@1.0.0/other.ts#labelOfSpeed"); + expect(labelOf(speed)).toBe("labelOfSpeed"); + claimKey(speed, "resource", number); + expect(labelOf(speed)).toBe("labelOfSpeed"); + claimKey(other, "component", number); + expect([labelOf(speed), labelOf(other)]).toEqual(["app@1.0.0/keys.ts#labelOfSpeed", "app@1.0.0/other.ts#labelOfSpeed"]); + }); + it("gives a key without a description or an id a numbered label, and names other values as text", () => { const anonymous = Symbol(); expect(labelOf(anonymous)).toBe("Symbol()"); claimKey(anonymous, "trait"); diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts index a6c2be9d..54e9c863 100644 --- a/packages/ecs/src/interface/registry/names.ts +++ b/packages/ecs/src/interface/registry/names.ts @@ -33,8 +33,13 @@ const labels = new Set(); /** How many registered keys have each description. */ const described = new Map(); -/** A symbol's description, `Symbol()` without one. */ -const descriptionOf = (key: symbol): string => key.description === undefined || key.description === "" ? "Symbol()" : key.description; +/** A symbol's description; without one, its binding, which the plugin's id names, or `Symbol()` without an id. */ +const descriptionOf = (key: symbol): string => { + if (key.description !== undefined && key.description !== "") return key.description; + const id = idOfKey(key); + const binding = id === undefined ? "" : id.slice(id.lastIndexOf("#") + 1); + return binding === "" ? "Symbol()" : binding; +}; /** `Symbol.iterator` and the rest, which the platform shares, so they can't be keys. */ const wellKnown: ReadonlyMap = new Map(Reflect.ownKeys(Symbol).flatMap((name): [symbol, string][] => { @@ -63,9 +68,9 @@ export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? e export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.newest ?? key : key; /** - * How errors and reports name a key: its description, `Symbol()` without one, while no other registered key has it; - * else its id when the plugin gave it one, and without one, for a registered key, its description numbered by - * registration order. Any other value is named as text. + * How errors and reports name a key: its description, or without one its binding from its id, while no other + * registered key has it; else its id when the plugin gave it one, and without one, for a registered key, its + * description numbered by registration order. Any other value is named as text. */ export const labelOf = (key: unknown): string => { if (typeof key !== "symbol") return String(key); diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index 1caf2bbf..4a433bf8 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../component/register.js'; +import { declareComponent } from '../registry/declare.js'; import type { Failure, Warning } from './failure.js'; /** @@ -17,7 +17,7 @@ export const run = Symbol("Report/run"); */ export const errors = Symbol("Report/errors"); -declare module '../component/types.js' { +declare module '@adobe/ecs' { export interface Components { [run]: { type: { @@ -38,7 +38,7 @@ declare module '../component/types.js' { const text = { type: "string" } as const; const count = { type: "integer", minimum: 0 } as const; -register(run, { +declareComponent(run, { type: { type: "object", properties: { @@ -47,7 +47,7 @@ register(run, { }, }, }); -register(errors, { +declareComponent(errors, { type: { type: "object", properties: { diff --git a/packages/ecs/src/interface/resource/declaration.ts b/packages/ecs/src/interface/resource/declaration.ts deleted file mode 100644 index db397652..00000000 --- a/packages/ecs/src/interface/resource/declaration.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Describing, Starting } from "../component/declaration.js"; -import type { IsData } from "../schema/is-data.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Required } from "../trait/required.js"; -import type { Traits } from "../trait/types.js"; -import type { SymbolKeys } from "../trait/symbol-keys.js"; -import type { Key } from "./key.js"; -import type { Resources } from "./types.js"; - -/** - * A resource always has a value: its schema's `default` or the declaration's, of the declared type `T`, either left - * out only when `T` allows `null`. A type that isn't data has no schema, so only the declaration's. - */ -type TypePart = - IsData extends true - ? [S] extends [never] ? { readonly type: Schema } : Describing & (S extends { readonly default: unknown } ? { readonly default?: never } : Starting) - : { readonly type?: never } & Starting; - -/** As for components, with the value a resource starts at. */ -export type Declaration = - { readonly type?: S } - & TypePart - & { readonly [T in Exclude]: - T extends TraitKey ? (Resources[K][T] extends Traits[T] ? Resources[K][T] : never) : - never } - & { readonly [T in Exclude]?: never } - & Required - & SymbolKeys; diff --git a/packages/ecs/src/interface/resource/declaration.type-test.ts b/packages/ecs/src/interface/resource/declaration.type-test.ts deleted file mode 100644 index 9df0bcec..00000000 --- a/packages/ecs/src/interface/resource/declaration.type-test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from './resource.js'; - -const gravity = Symbol("declarationTypeTest/gravity"); -const total = Symbol("declarationTypeTest/total"); -const names = Symbol("declarationTypeTest/names"); -const tags = Symbol("declarationTypeTest/tags"); -const selected = Symbol("declarationTypeTest/selected"); -const device = Symbol("declarationTypeTest/device"); -const page = Symbol("declarationTypeTest/page"); -const handles = Symbol("declarationTypeTest/handles"); -const candidates = Symbol("declarationTypeTest/candidates"); -const misnamed = Symbol("declarationTypeTest/misnamed"); - -type Box = { readonly x: number; readonly y: number }; -type Candidate = Box & { readonly id: number }; - -declare module '@adobe/ecs' { - export interface Resources { - [gravity]: { type: number }; - [total]: { type: number; reduce: "+" }; - [names]: { type: ReadonlyMap }; - [tags]: { type: ReadonlySet }; - [selected]: { type: string | null }; - [device]: { type: GPUDevice | null }; - [page]: { type: Document; thread: "main" }; - [handles]: { type: Map }; - [candidates]: { type: readonly Candidate[] }; - [misnamed]: { type: readonly Candidate[] }; - } -} - -const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; - -// A resource starts at its schema's default or its declaration's. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(names, { type: namesSchema, default: new Map() }); -Resource.register(tags, { type: { type: "set", items: { type: "string" } }, default: new Set(["a"]) }); -// A type that allows null starts at null without one. -Resource.register(selected, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(device, {}); -Resource.register(page, { default: document, thread: "main" }); -Resource.register(handles, { default: new Map() }); - -// A schema describes exactly the declared type. -// @ts-expect-error a string isn't a number -Resource.register(gravity, { type: { type: "string", default: "" } }); -// @ts-expect-error a map's values are numbers -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "string" } }, default: new Map() }); - -// An intersection describes the object it equals. -const number = { type: "number" } as const; -Resource.register(candidates, { type: { type: "array", items: { type: "object", properties: { id: number, x: number, y: number }, required: ["id", "x", "y"] }, default: [] } }); -// @ts-expect-error a member's name differs -Resource.register(misnamed, { type: { type: "array", items: { type: "object", properties: { key: number, x: number, y: number }, required: ["key", "x", "y"] }, default: [] } }); - -// Every declared trait is registered, with its declared value, and nothing undeclared is. -// @ts-expect-error a declared trait is missing -Resource.register(total, { type: { type: "number", default: 0 } }); -// @ts-expect-error an undeclared trait is given -Resource.register(gravity, { type: { type: "number", default: 0 }, reduce: "+" }); -// @ts-expect-error a trait's value differs from the declared one -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "max" }); -// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. -const reduced = { type: { type: "number", default: 0 }, reduce: "+" } as const; -// @ts-expect-error an undeclared trait is given -Resource.register(gravity, reduced); - -// A type that's data is registered with its schema; one that isn't data has none. -// @ts-expect-error a map of strings to numbers is data, so it has a schema -Resource.register(names, { default: new Map() }); -// @ts-expect-error a string is data, so it has a schema, though it may start at null -Resource.register(selected, {}); -// @ts-expect-error a value that isn't data has no schema -Resource.register(handles, { type: { type: "object", properties: {} }, default: new Map() }); - -// A default is a value of the declared type, given once. -// @ts-expect-error the schema's default isn't a number -Resource.register(gravity, { type: { type: "number", default: "down" } }); -// @ts-expect-error the declaration's default isn't a map of strings to numbers -Resource.register(names, { type: namesSchema, default: new Set() }); -// @ts-expect-error a default isn't a Map -Resource.register(handles, { default: "map" }); -// @ts-expect-error a type without null starts at a default -Resource.register(gravity, { type: { type: "number" } }); -// @ts-expect-error a value that isn't data, without null, starts at a default -Resource.register(handles, {}); -// @ts-expect-error a schema's default and the declaration's can't both be given -Resource.register(gravity, { type: { type: "number", default: 0 }, default: 1 }); diff --git a/packages/ecs/src/interface/resource/index.ts b/packages/ecs/src/interface/resource/index.ts index 540c26d7..fafde2f6 100644 --- a/packages/ecs/src/interface/resource/index.ts +++ b/packages/ecs/src/interface/resource/index.ts @@ -1,4 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './register.js'; export type { Key } from './key.js'; export type { Value } from './value.js'; diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index aa81d1d8..b55b438e 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -1,57 +1,40 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; +import { declareComponent, declareResource } from "../registry/declare.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; import { always, cadence, clock, gpuDevice, wake, workers } from "../resources/index.js"; -import { Resource } from "./resource.js"; const time = Symbol("typesResourceTest/time"); const symbol = Symbol("typesResourceTest/symbol"); -const shared = Symbol("typesResourceTest/shared"); const cache = Symbol("typesResourceTest/cache"); const port = Symbol("typesResourceTest/port"); const total = Symbol("typesResourceTest/total"); const flags = Symbol("typesResourceTest/flags"); const names = Symbol("typesResourceTest/names"); const tags = Symbol("typesResourceTest/tags"); +const cacheStart = new Map(); +const namesStart = new Map([["a", 1]]); +const tagsStart = new Set(["x"]); declare module "@adobe/ecs" { export interface Resources { - [time]: { type: number }; - [symbol]: { type: boolean }; - [shared]: { type: true }; - [cache]: { type: Map; thread: "main" }; + [time]: { type: number; default: 0 }; + [symbol]: { type: boolean; default: false }; + [cache]: { type: Map; thread: "main"; default: typeof cacheStart }; [port]: { type: MessagePort | null; transfer: true }; - [total]: { type: number; reduce: "+" }; - [flags]: { type: boolean; reduce: "|" }; - [names]: { type: ReadonlyMap }; - [tags]: { type: ReadonlySet }; - } - export interface Components { - [shared]: { type: true }; + [total]: { type: number; default: 0; reduce: "+" }; + [flags]: { type: boolean; default: false; reduce: "|" }; + [names]: { type: ReadonlyMap; default: typeof namesStart }; + [tags]: { type: ReadonlySet; default: typeof tagsStart }; } } -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(symbol, { type: { type: "boolean", default: false } }); -// @ts-expect-error a key declared as both a component and a resource can't be registered as either -Component.register(shared, { type: { const: true } }); -Resource.register(cache, { default: new Map(), thread: "main" }); -Resource.register(port, { transfer: true }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(flags, { type: { type: "boolean", default: false }, reduce: "|" }); -const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; -Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) }); -Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } }); - -const untyped: (...parameters: never[]) => unknown = Resource.register; +/** Registers a declaration as the build does, which a test may write wrong. */ +const register = (key: unknown, declaration: object): void => declareResource(key, declaration); -/** Registers as an untyped caller would, bypassing the types' checks. */ -const register = (key: unknown, declaration: object): void => { - Reflect.apply(untyped, undefined, [key, declaration]); -}; +const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; -describe("Resource.register", () => { +describe("registering a resource", () => { it("records the declaration by its symbol, which needs no description", () => { expect(declarationOf(time)).toEqual({ type: { type: "number", default: 0 } }); expect(declarationOf(symbol)).toEqual({ type: { type: "boolean", default: false } }); @@ -64,17 +47,18 @@ describe("Resource.register", () => { expect(() => register("typesResourceTest/text", { type: { type: "number", default: 0 } })).toThrow("a key is a plain Symbol(...), not a string."); }); it("accepts the same declaration again, and rejects a different one or one also declared as a component", () => { - expect(() => Resource.register(time, { type: { type: "number", default: 0 } })).not.toThrow(); - expect(() => Resource.register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); - // @ts-expect-error a key declared as both a component and a resource can't be registered as either - expect(() => Resource.register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); + expect(() => register(time, { type: { type: "number", default: 0 } })).not.toThrow(); + expect(() => register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); + const shared = Symbol("typesResourceTest/shared"); + declareComponent(shared, { type: { const: true } }); + expect(() => register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); }); it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { - expect(declarationOf(names)).toEqual({ type: namesSchema, default: new Map([["a", 1]]) }); - expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) })).not.toThrow(); - expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 2]]) })).toThrow("with another declaration"); - expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } })).not.toThrow(); - expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["y"]) } })).toThrow("with another declaration"); + expect(declarationOf(names)).toEqual({ type: { ...namesSchema, default: new Map([["a", 1]]) } }); + expect(() => register(names, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); + expect(() => register(names, { type: { ...namesSchema, default: new Map([["a", 2]]) } })).toThrow("with another declaration"); + expect(() => register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } })).not.toThrow(); + expect(() => register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["y"]) } })).toThrow("with another declaration"); expect(() => register(Symbol("typesResourceTest/twice"), { type: { type: "number", default: 0 }, default: 1 })).toThrow("its default is given twice"); expect(threadOfDeclaration(declarationOf(names) ?? {})).toBe("world"); }); diff --git a/packages/ecs/src/interface/resource/register.ts b/packages/ecs/src/interface/resource/register.ts deleted file mode 100644 index 4b5351d2..00000000 --- a/packages/ecs/src/interface/resource/register.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from "../component/component.js"; -import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Declaration } from "./declaration.js"; -import type { Key } from "./key.js"; - -const registry = createRegistry("resource"); - -/** - * Registers the resource `key`, a plain `Symbol(...)` whose traits `Resources` declares, as `Component.register` - * does: `Resource.register(gravity, { type: { type: "number", default: -9.8 } })`. A resource always has a value: its - * schema's `default` or the declaration's, either left out only when its type allows `null`, which it then starts at. - */ -export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; -export function register(key: unknown, declaration: AnyDeclaration): void { - registry(key, declaration); -} diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index 4fc0b6c4..f64f395c 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ export const always = Symbol("always"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [always]: { type: boolean }; } } -register(always, { type: { type: "boolean", default: false } }); +declareResource(always, { type: { type: "boolean", default: false } }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index b7776183..0fcc6823 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** The cadence's value, for the implementation: `Resource.Value`. */ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; @@ -11,13 +11,13 @@ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; rea */ export const cadence = Symbol("cadence"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [cadence]: { type: Cadence }; } } -register(cadence, { +declareResource(cadence, { type: { anyOf: [ { type: "object", properties: { kind: { const: "frame" } } }, diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index c8ef6fc5..7b12e2ab 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the @@ -7,12 +7,12 @@ import { register } from '../resource/register.js'; */ export const clock = Symbol("clock"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [clock]: { type: { readonly now: number; readonly delta: number; readonly tick: number } }; } } -register(clock, { +declareResource(clock, { type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, }); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts index 0e6a3891..7da5ca16 100644 --- a/packages/ecs/src/interface/resources/gpu-device.ts +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world @@ -8,10 +8,10 @@ import { register } from '../resource/register.js'; */ export const gpuDevice = Symbol("gpuDevice"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [gpuDevice]: { type: GPUDevice | null }; } } -register(gpuDevice, {}); +declareResource(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index d6a19f12..9ab07f0c 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity @@ -7,10 +7,10 @@ import { register } from '../resource/register.js'; */ export const wake = Symbol("wake"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [wake]: { type: number; reduce: "min" }; } } -register(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +declareResource(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index cdae93b8..932d277e 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../resource/register.js'; +import { declareResource } from '../registry/declare.js'; /** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ export const workers = Symbol("workers"); -declare module '../resource/types.js' { +declare module '@adobe/ecs' { export interface Resources { [workers]: { type: number | null }; } } -register(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +declareResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); diff --git a/packages/ecs/src/interface/rule/rule.type-test.ts b/packages/ecs/src/interface/rule/rule.type-test.ts index ae368506..8cb3f221 100644 --- a/packages/ecs/src/interface/rule/rule.type-test.ts +++ b/packages/ecs/src/interface/rule/rule.type-test.ts @@ -30,11 +30,6 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(source, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Resource.register(time, { type: { type: "number", default: 0 } }); // Rules derive only derived components. const slots = Rule.create({ name: "ruleTypeTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }); diff --git a/packages/ecs/src/interface/schema/is-data.ts b/packages/ecs/src/interface/schema/is-data.ts deleted file mode 100644 index 3b6dd6e7..00000000 --- a/packages/ecs/src/interface/schema/is-data.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -type Method = (...parameters: never[]) => unknown; - -/** How deep `NotData` looks into members: deeper ones count as data, so recursive types stay finite. */ -type Depth = [never, 0, 1, 2, 3, 4]; - -/** - * True for a value that isn't data: a function, an object with a member that isn't data, or a `Set` or `Map`, which - * changes in place. A `ReadonlySet` or `ReadonlyMap` is data when what it holds is. Members are looked at five levels - * deep. - */ -type NotData = - [D] extends [never] ? false : - T extends string | number | boolean | bigint | symbol | null | undefined ? false : - T extends Method ? true : - T extends readonly (infer E)[] ? NotData : - T extends ReadonlyMap ? (T extends Map ? true : NotData | NotData) : - T extends ReadonlySet ? (T extends Set ? true : NotData) : - T extends object ? (true extends { [K in keyof T]-?: NotData }[keyof T] ? true : false) : - false; - -/** - * Whether a declared type is data a schema describes, so its registration gives that schema. A `Blob`, an - * `HTMLElement` or a `GPUDevice` isn't: its registration has no `type`, and the world stores the value as it is. - */ -export type IsData = true extends NotData ? false : true; diff --git a/packages/ecs/src/interface/schema/schema.type-test.ts b/packages/ecs/src/interface/schema/schema.type-test.ts index 8bcf9dd3..f2b63ef2 100644 --- a/packages/ecs/src/interface/schema/schema.type-test.ts +++ b/packages/ecs/src/interface/schema/schema.type-test.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsData } from "./is-data.js"; import type { IsStruct } from "./is-struct.js"; import type { Schema } from "./schema.js"; @@ -135,21 +134,7 @@ const badSetForm: Schema = { type: 'set', keys: { type: 'string' }, values: { ty const badMapForm: Schema = { type: 'map', items: { type: 'string' } }; void [okSet, okMap, badSet, badMap, badSetForm, badMapForm]; -// A ReadonlySet or ReadonlyMap of data is data; a Set or Map, which changes in place, isn't, nor one holding functions. -check>, true>>(); -check>, true>>(); -check void>>, false>>(); -check>, false>>(); -check>, false>>(); -// An object is data only when every member is: a record of handles isn't, however deep they are. -check, false>>(); -check, false>>(); -check, false>>(); -check, true>>(); -// A recursive data type is still data. -type Tree = { readonly value: number; readonly children: readonly Tree[] }; -check, true>>(); -// Neither is a struct: neither lives in shared memory. +// A set or map isn't a struct: neither lives in shared memory. check>, false>>(); check>, false>>(); diff --git a/packages/ecs/src/interface/system/commands.type-test.ts b/packages/ecs/src/interface/system/commands.type-test.ts index 82a83a58..c8d8e2bc 100644 --- a/packages/ecs/src/interface/system/commands.type-test.ts +++ b/packages/ecs/src/interface/system/commands.type-test.ts @@ -26,12 +26,6 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(player, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Resource.register(time, { type: { type: "number", default: 0 } }); const particle = Archetype.create([position, velocity, label, player]); diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts index e56d94a3..e32bdccc 100644 --- a/packages/ecs/src/interface/system/conditions.type-test.ts +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -25,12 +25,6 @@ declare module '@adobe/ecs' { [position]: { type: number }; } } -Resource.register(handle, { default: new Map() }); -Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }], default: null } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(cache, {}); -Resource.register(count, { type: { type: "number", default: 0 } }); -Component.register(position, { type: { type: "number" } }); // `is`: runs while each is neither null nor false, and reads it narrowed so. System.create({ name: "conditionsTypeTest/loaded", resources: { is: [level, cache] } }, ({ resources }) => { diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 5f2971f9..24f1deed 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -1,18 +1,18 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { Entity } from "../entity/entity.js"; import { definitionOf, firstItemOf, labelOfItem, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; import { identify } from "../registry/ids.test-helper.js"; -import { Resource } from "../resource/resource.js"; import { clock, gpuDevice } from "../resources/index.js"; import { System } from "./system.js"; +import type { F32 } from "../math/index.js"; const position = Symbol("typesSystemTest/position"); const label = Symbol("typesSystemTest/label"); const node = Symbol("typesSystemTest/node"); const document = Symbol("typesSystemTest/document"); const time = Symbol("typesSystemTest/time"); +// Never declared, so never registered. const unregistered = Symbol("typesSystemTest/unregistered"); const parent = Symbol("typesSystemTest/parent"); const total = Symbol("typesSystemTest/total"); @@ -25,29 +25,18 @@ declare module "@adobe/ecs" { [position]: { type: number }; [label]: { type: string }; [node]: { type: HTMLElement; thread: "main" }; - [unregistered]: { type: number }; [parent]: { type: Entity; reference: "weak" }; - [height]: { type: number }; + [height]: { type: F32 }; [tags]: { type: readonly string[] }; } export interface Resources { [document]: { type: Document | null; thread: "main" }; - [time]: { type: number }; - [total]: { type: number; reduce: "+" }; - [paused]: { type: boolean }; + [time]: { type: number; default: 0 }; + [total]: { type: number; reduce: "+"; default: 0 }; + [paused]: { type: boolean; default: false }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(node, { thread: "main" }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Resource.register(document, { thread: "main" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Component.register(parent, { type: Entity.schema, reference: "weak" }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Component.register(height, { type: { type: "number", precision: 1 } }); /** Creates as an untyped caller would, bypassing the types' checks. */ const create = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 554d86aa..3536c554 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -23,10 +23,6 @@ declare module '@adobe/ecs' { [player]: { type: true }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Component.register(player, { type: { const: true } }); const flagged = Symbol("systemTest/flagged"); const bounds = Symbol("systemTest/bounds"); export const total = Symbol("systemTest/total"); @@ -39,11 +35,6 @@ declare module '@adobe/ecs' { [total]: { type: number; reduce: "+" }; } } -Resource.register(bounds, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [Infinity, Infinity, Infinity] }, reduce: "min" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(score, { type: { type: "number", default: 0 } }); -Resource.register(flagged, { type: { type: "boolean", default: false }, reduce: "|" }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); // Row: read values are read-only, write values writable, others absent. export const integrate = System.create({ @@ -204,10 +195,6 @@ declare module '@adobe/ecs' { [marks]: { type: ReadonlySet }; } } -Component.register(label, { type: { type: "string" } }); -Component.register(marks, { type: { type: "set", items: { type: "number" } } }); -Component.register(path, { type: { type: "array", items: { type: "number" } } }); -Component.register(vector, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); // Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. System.create({ diff --git a/packages/ecs/src/interface/system/threads.type-test.ts b/packages/ecs/src/interface/system/threads.type-test.ts index a01cc092..fbea0375 100644 --- a/packages/ecs/src/interface/system/threads.type-test.ts +++ b/packages/ecs/src/interface/system/threads.type-test.ts @@ -34,14 +34,6 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Resource.register(settings, { type: { anyOf: [{ type: "object", properties: { name: { type: "string" } } }, { type: "null" }], default: null } }); -Component.register(node, { thread: "main" }); -Component.register(label, { type: { type: "string" } }); -Component.register(audio, { thread: "threadsTypeTest" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Resource.register(document, { thread: "main" }); -Resource.register(time, { type: { type: "number", default: 0 } }); // A system runs on the thread of the object components and resources it uses; strings and structs go with any. System.create({ name: "threadsTypeTest/render", components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts index 885adaa5..0f915dd3 100644 --- a/packages/ecs/src/interface/system/up.type-test.ts +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -19,10 +19,6 @@ declare module '@adobe/ecs' { [name]: { type: string }; } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(name, { type: { type: "string" } }); // `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the // system writes. diff --git a/packages/ecs/src/interface/thread/thread.type-test.ts b/packages/ecs/src/interface/thread/thread.type-test.ts index 103e7441..25bc8d97 100644 --- a/packages/ecs/src/interface/thread/thread.type-test.ts +++ b/packages/ecs/src/interface/thread/thread.type-test.ts @@ -77,49 +77,7 @@ declare module '@adobe/ecs' { } } -Component.register(label, { type: { type: "string" } }); -Component.register(path, { type: { type: "array", items: { type: "number" } }, thread: "threadTypeTest" }); -Component.register(handle, { thread: "world" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); -Component.register(element, { thread: "main" }); -Component.register(maybeElement, { thread: "main" }); -Component.register(bitmap, { thread: "threadTypeTest", transfer: true }); -Component.register(buffer, { transfer: true }); -Resource.register(canvas, { thread: "threadTypeTest", transfer: true }); -Resource.register(cache, { thread: "main" }); -Resource.register(port, { transfer: true }); -// @ts-expect-error a thread is a declared one -Component.register(unknownThread, { type: { type: "array", items: { type: "number" } }, thread: "elsewhere" }); -// @ts-expect-error a string lives in shared memory, so it has no thread -Component.register(placedLabel, { type: { type: "string" }, thread: "main" }); -// @ts-expect-error a union of string literals is a string too -Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" }, thread: "main" }); -// @ts-expect-error and so is a string or null -Resource.register(caption, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, thread: "main" }); -// @ts-expect-error a struct lives in shared memory, so it has no thread -Component.register(placedPosition, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, thread: "main" }); -// @ts-expect-error a tag is a struct too -Component.register(placedTag, { type: { const: true }, thread: "world" }); -// @ts-expect-error a struct resource has no thread either -Resource.register(count, { type: { type: "number", default: 0 }, thread: "main" }); -// @ts-expect-error a DOM Node declares thread: "main" -Component.register(unplacedElement, {}); -// @ts-expect-error a DOM Node lives on "main", not another thread -Component.register(workerElement, { thread: "threadTypeTest" }); -// @ts-expect-error only a Transferable value is moved -Component.register(notTransferable, { transfer: true }); -// @ts-expect-error a string is data, copied, never moved -Resource.register(text, { type: { type: "string", default: "" }, transfer: true }); -// @ts-expect-error a value that's data is registered with its schema -Component.register(label, {}); -// @ts-expect-error a component's object has no default: its value comes from commands -Component.register(handle, { default: new Map(), thread: "world" }); -// @ts-expect-error a resource without null starts at a default -Resource.register(names, { thread: "main" }); -// @ts-expect-error a default is a value of the type -Resource.register(names, { default: "map", thread: "main" }); -Resource.register(names, { default: new Map(), thread: "main" }); check, readonly number[]>>(); check, never>>(); diff --git a/packages/ecs/src/interface/trait/index.ts b/packages/ecs/src/interface/trait/index.ts deleted file mode 100644 index 5b991244..00000000 --- a/packages/ecs/src/interface/trait/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { register } from './register.js'; diff --git a/packages/ecs/src/interface/trait/register.ts b/packages/ecs/src/interface/trait/register.ts deleted file mode 100644 index ea883b4e..00000000 --- a/packages/ecs/src/interface/trait/register.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { addTrait } from '../registry/traits.js'; -import type { BuiltIn, Key } from './key.js'; - -/** - * Registers a trait under `key`, a plain `Symbol(...)`: string trait names are the core's built-in traits. The same - * trait again is accepted, and so is a new symbol with a trait's id, as a module evaluated again makes it. Throws for a key that isn't a plain symbol, or - * one registered as a component or resource. - */ -export const register = (key: Exclude & symbol): void => addTrait(key); diff --git a/packages/ecs/src/interface/trait/required.ts b/packages/ecs/src/interface/trait/required.ts deleted file mode 100644 index 785ba3cb..00000000 --- a/packages/ecs/src/interface/trait/required.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsNode } from '../platform/platform.js'; - -/** - * The traits a declaration must have beyond those it lists: a DOM `Node` lives on `"main"`, so a declaration of one - * without `thread` can't be registered. Where the DOM library isn't loaded, nothing is required. - */ -export type Required = IsNode extends true ? (Entry extends { readonly thread: unknown } ? {} : { readonly thread: "main" }) : {}; diff --git a/packages/ecs/src/interface/trait/symbol-keys.ts b/packages/ecs/src/interface/trait/symbol-keys.ts deleted file mode 100644 index ade3778c..00000000 --- a/packages/ecs/src/interface/trait/symbol-keys.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Components } from '../component/types.js'; -import type { Resources } from '../resource/types.js'; -import type { BuiltIn } from './key.js'; -import type { Traits } from './types.js'; - -/** A required property naming `problem` and the keys that cause it, or nothing without such keys. */ -type Problem = [Keys] extends [never] ? {} : { readonly [P in Text]: Keys }; - -/** - * What every declaration also requires while `C`, `R` or `T` has a key that isn't a symbol, besides the built-in - * traits: a property named for the problem, holding the keys, so registration fails to compile and says why. - */ -export type KeysMustBeSymbols = - Problem, "component keys must be symbols"> - & Problem, "resource keys must be symbols"> - & Problem, "trait keys must be symbols">; - -/** `KeysMustBeSymbols` of the program's components, resources and traits, computed once. */ -export type SymbolKeys = KeysMustBeSymbols>; diff --git a/packages/ecs/src/interface/trait/symbol-keys.type-test.ts b/packages/ecs/src/interface/trait/symbol-keys.type-test.ts deleted file mode 100644 index 70ae8c11..00000000 --- a/packages/ecs/src/interface/trait/symbol-keys.type-test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { KeysMustBeSymbols } from './symbol-keys.js'; - -type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; -const check = <_T extends true>() => { }; - -declare const key: unique symbol; -type BuiltInTraits = { readonly type: unknown; readonly derived: true; readonly within: never; readonly reference: never; readonly reduce: never; readonly thread: never; readonly transfer: never }; - -// Symbol keys, and the built-in string traits, need nothing. -check, {}>>(); -// Each kind with a string or number key requires a property naming the problem and the keys. -check, { readonly "component keys must be symbols": "position" }>>(); -check, { readonly "resource keys must be symbols": 1 }>>(); -check, { readonly "trait keys must be symbols": "gpuOnly" }>>(); diff --git a/packages/ecs/src/interface/trait/trait.ts b/packages/ecs/src/interface/trait/trait.ts deleted file mode 100644 index 5534fc57..00000000 --- a/packages/ecs/src/interface/trait/trait.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export type Trait = never; - -export * as Trait from './index.js'; diff --git a/packages/ecs/src/interface/trait/trait.type-test.ts b/packages/ecs/src/interface/trait/trait.type-test.ts index b0c3f05e..d95442e4 100644 --- a/packages/ecs/src/interface/trait/trait.type-test.ts +++ b/packages/ecs/src/interface/trait/trait.type-test.ts @@ -5,7 +5,6 @@ import { Resource } from '../resource/resource.js'; import type { Commands } from '../system/commands.js'; import type { IsStruct } from '../schema/is-struct.js'; import type { Components } from '../component/types.js'; -import { Trait } from './trait.js'; import type { Derived, Essential } from '../component/value.js'; import type { Reduced } from '../resource/reduction.js'; @@ -21,10 +20,6 @@ declare module '@adobe/ecs' { readonly [nonPersistent]: N extends Component.Key ? (Components[N] extends { readonly derived: true } ? never : true) : true; } } -Trait.register(gpuOnly); -Trait.register(nonPersistent); -// @ts-expect-error string trait names are the core's built-in traits -Trait.register("derived"); const parent = Symbol("traitTest/parent"); const follows = Symbol("traitTest/follows"); @@ -34,90 +29,27 @@ const score = Symbol("traitTest/score"); const link = Symbol("traitTest/link"); const pair = Symbol("traitTest/pair"); const cache = Symbol("traitTest/cache"); -const badReference = Symbol("traitTest/badReference"); -const selfWithin = Symbol("traitTest/selfWithin"); -const derivedWithin = Symbol("traitTest/derivedWithin"); -const derivedReference = Symbol("traitTest/derivedReference"); -const gpuString = Symbol("traitTest/gpuString"); -const derivedNonPersistent = Symbol("traitTest/derivedNonPersistent"); const time = Symbol("traitTest/time"); -const derivedResource = Symbol("traitTest/derivedResource"); const sum = Symbol("traitTest/sum"); -const anyFlag = Symbol("traitTest/anyFlag"); -const named = Symbol("traitTest/named"); -const reducedComponent = Symbol("traitTest/reducedComponent"); declare module '@adobe/ecs' { export interface Components { [parent]: { type: Entity; reference: "owner" }; [follows]: { type: Entity; reference: "weak" }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; [ready]: { type: true; within: typeof slot }; [score]: { type: number }; [link]: { type: { readonly to: Entity; readonly weight: number } }; [pair]: { type: readonly [number, number]; [gpuOnly]: true }; [cache]: { type: number; [nonPersistent]: true }; - [badReference]: { type: number; reference: "owner" }; - [selfWithin]: { type: true; within: typeof selfWithin }; - [derivedWithin]: { type: number; derived: true; within: typeof score }; - [derivedReference]: { type: Entity; derived: true; reference: "owner" }; - [gpuString]: { type: string; [gpuOnly]: true }; - [derivedNonPersistent]: { type: number; derived: true; [nonPersistent]: true }; - [reducedComponent]: { type: number; reduce: "+" }; } export interface Resources { - [time]: { type: number }; - [derivedResource]: { type: number; derived: true }; - [sum]: { type: number; reduce: "+" }; - [anyFlag]: { type: boolean; reduce: "+" }; - [named]: { type: string; reduce: "max" }; + [time]: { type: number; default: 0 }; + [sum]: { type: number; default: 0; reduce: "+" }; } } -// Registration takes exactly the declared traits, `type` as a schema for the declared type. -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(ready, { type: { const: true }, within: slot }); -Component.register(score, { type: { type: "number" } }); -Component.register(link, { type: { type: "object", properties: { to: Entity.schema, weight: { type: "number" } } } }); -Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, [gpuOnly]: true }); -Component.register(cache, { type: { type: "number" }, [nonPersistent]: true }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); - -// @ts-expect-error a derived component's type has a default, which it starts at -Component.register(slot, { type: { type: "number" }, derived: true }); -// @ts-expect-error a reference's value is an entity -Component.register(badReference, { type: { type: "number" }, reference: "owner" }); -// @ts-expect-error a component can't live within itself -Component.register(selfWithin, { type: { const: true }, within: selfWithin }); -// @ts-expect-error a derived component's rule covers within -Component.register(derivedWithin, { type: { type: "number", default: 0 }, derived: true, within: score }); -// @ts-expect-error a rule can't set a derived component's target -Component.register(derivedReference, { type: { ...Entity.schema, default: Entity.none }, derived: true, reference: "owner" }); -// @ts-expect-error gpuOnly takes only linear components -Component.register(gpuString, { type: { type: "string" }, [gpuOnly]: true }); -// @ts-expect-error nonPersistent is redundant on a derived component -Component.register(derivedNonPersistent, { type: { type: "number", default: 0 }, derived: true, [nonPersistent]: true }); -// @ts-expect-error a declared trait is missing -Component.register(parent, { type: Entity.schema }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, derived: true }); -// @ts-expect-error a trait's value differs from the declared one -Component.register(follows, { type: Entity.schema, reference: "owner" }); -// @ts-expect-error an unknown trait -Component.register(score, { type: { type: "number" }, unknown: true }); -// @ts-expect-error an entity's schema is Entity.schema -Component.register(parent, { type: { type: "integer" }, reference: "owner" }); -// @ts-expect-error a number's schema isn't Entity.schema -Component.register(score, { type: Entity.schema }); -// @ts-expect-error an entity field's schema is Entity.schema too -Component.register(link, { type: { type: "object", properties: { to: { type: "integer" }, weight: { type: "number" } } } }); -// @ts-expect-error a resource's schema has a default -Resource.register(time, { type: { type: "number" } }); -// @ts-expect-error derived is a component trait -Resource.register(derivedResource, { type: { type: "number", default: 0 }, derived: true }); +// The build registers each as declared; the registry checks the built-in traits when it does. // Types filter components by their traits and value types. type Ours = typeof parent | typeof follows | typeof slot | typeof ready | typeof score | typeof link | typeof pair | typeof cache; @@ -149,11 +81,5 @@ const raw: number = Entity.none; const forged: Entity = 3; void raw; void forged; -// @ts-expect-error booleans reduce bitwise -Resource.register(anyFlag, { type: { type: "boolean", default: false }, reduce: "+" }); -// @ts-expect-error only numbers and booleans, and tuples and structs of them, are reduced -Resource.register(named, { type: { type: "string", default: "" }, reduce: "max" }); -// @ts-expect-error reduce is a resource trait -Component.register(reducedComponent, { type: { type: "number" }, reduce: "+" }); check>(); check>(); diff --git a/packages/ecs/src/interface/world/world.type-test.ts b/packages/ecs/src/interface/world/world.type-test.ts index cbb1b88d..7d58f7b1 100644 --- a/packages/ecs/src/interface/world/world.type-test.ts +++ b/packages/ecs/src/interface/world/world.type-test.ts @@ -23,9 +23,6 @@ declare module '@adobe/ecs' { [shared]: { type: true }; } } -Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); -Component.register(persistent, { type: { const: true } }); -Component.register(shared, { type: { const: true } }); // Reading goes through observer systems: a world has no observers, and no synchronous reads. // @ts-expect-error no observe: observer systems read a world void world.observe; diff --git a/packages/ecs/src/observe/observe.type-test.ts b/packages/ecs/src/observe/observe.type-test.ts index 521a0c11..615f948e 100644 --- a/packages/ecs/src/observe/observe.type-test.ts +++ b/packages/ecs/src/observe/observe.type-test.ts @@ -22,10 +22,6 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); -Component.register(persistent, { type: { const: true } }); -Component.register(shared, { type: { const: true } }); -Resource.register(time, { type: { type: "number", default: 0 } }); // The module's `Observe` has the shape of the package root's. check, (notify: (value: number) => void) => () => void>>(); diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts index c5821ab8..0f82594a 100644 --- a/packages/ecs/src/perftest/editor.ts +++ b/packages/ecs/src/perftest/editor.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { Index } from "../interface/indexes/index-type.js"; import { workers } from "../interface/resources/workers.js"; @@ -37,17 +36,6 @@ declare module "@adobe/ecs" { [image]: { type: true }; } } -Component.register(localX, { type: { type: "number" } }); -Component.register(localY, { type: { type: "number" } }); -Component.register(worldX, { type: { type: "number" } }); -Component.register(worldY, { type: { type: "number" } }); -Component.register(opacity, { type: { type: "number" } }); -Component.register(worldOpacity, { type: { type: "number" } }); -Component.register(paintDepth, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(group, { type: { const: true } }); -Component.register(shape, { type: { const: true } }); -Component.register(image, { type: { const: true } }); const node = [localX, localY, worldX, worldY, opacity, worldOpacity, paintDepth] as const; const Document = Archetype.create([...node, group]); diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts index 4ace0afd..2fe97cff 100644 --- a/packages/ecs/src/perftest/filters.ts +++ b/packages/ecs/src/perftest/filters.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import type { I32 } from "../interface/math/i32/index.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; @@ -8,6 +7,7 @@ import type { World } from "../interface/world/world.js"; import { createWorld } from "../implementation/index.js"; import { retainedRuns } from "../implementation/world/reports.js"; import type { Benchmark } from "./benchmark.js"; +import type { U32 } from "../interface/math/index.js"; // Change filters over one archetype of 100K rows after a writer changed one row, a few, or every one: what finding the // rows that pass costs each system that filters, apart from running them. @@ -23,20 +23,13 @@ declare module "@adobe/ecs" { export interface Components { [value]: { type: number }; [other]: { type: number }; - [at]: { type: number }; + [at]: { type: U32 }; [out0]: { type: number }; [out1]: { type: number }; [out2]: { type: number }; [out3]: { type: number }; } } -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Component.register(at, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(out0, { type: { type: "number" } }); -Component.register(out1, { type: { type: "number" } }); -Component.register(out2, { type: { type: "number" } }); -Component.register(out3, { type: { type: "number" } }); const rows = 100_000; const filtered = Archetype.create([value, other, at, out0, out1, out2, out3]); diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index 503a83b5..86c66be7 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -1,13 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld, type CreatedWorld } from "../implementation/index.js"; import type { Benchmark } from "./benchmark.js"; import { runReported } from "../implementation/world/reports.test-helper.js"; +import type { Vec3 } from "../interface/math/index.js"; // Despawning entities whose objects a joined thread fills, with a finalizer on that thread: the world's thread writes // each stop's declared struct reads into the thread's stop list and doesn't wait for it. The joined thread is @@ -26,25 +25,18 @@ declare module "@adobe/ecs" { finalizedBenchmark: true; } export interface Components { - [spot]: { type: readonly [number, number, number] }; + [spot]: { type: Vec3 }; [handle]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; [noted]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; [measured]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; } export interface Resources { - [note]: { type: string }; - [scale]: { type: number }; - [offset]: { type: readonly [number, number, number] }; + [note]: { type: string; default: "released" }; + [scale]: { type: number; default: 1 }; + [offset]: { type: Vec3; default: [0, 0, 0] }; } } -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); /** An object of the joined thread, as a DOM node or a GPU handle is. */ -Component.register(handle, { thread: "finalizedBenchmark" }); -Component.register(noted, { thread: "finalizedBenchmark" }); -Resource.register(note, { type: { type: "string", default: "released" } }); -Component.register(measured, { thread: "finalizedBenchmark" }); -Resource.register(scale, { type: { type: "number", default: 1 } }); -Resource.register(offset, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3, default: [0, 0, 0] } }); /** Entities each timed sync point despawns. */ export const despawnedObjects = 10_000; diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts index d9e09768..905feeb6 100644 --- a/packages/ecs/src/perftest/flat.ts +++ b/packages/ecs/src/perftest/flat.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { System } from "../interface/system/system.js"; import { Archetype } from '../interface/archetype/archetype.js'; +import type { F32, U32 } from '../interface/math/index.js'; // Mirrors adobe/data's ecs2 `create` perftest (packages/data/src/perftest/ecs2-perf.ts): 1M particles of eight scalar // components, interleaved over four archetypes by visible/enabled, each with its own values. @@ -19,29 +19,19 @@ export const enabled = Symbol("flat/enabled"); declare module "@adobe/ecs" { export interface Components { - [color]: { type: number }; - [mass]: { type: number }; - [positionX]: { type: number }; - [positionY]: { type: number }; - [positionZ]: { type: number }; - [velocityX]: { type: number }; - [velocityY]: { type: number }; - [velocityZ]: { type: number }; + [color]: { type: U32 }; + [mass]: { type: F32 }; + [positionX]: { type: F32 }; + [positionY]: { type: F32 }; + [positionZ]: { type: F32 }; + [velocityX]: { type: F32 }; + [velocityY]: { type: F32 }; + [velocityZ]: { type: F32 }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(positionX, { type: { type: "number", precision: 1 } }); -Component.register(positionY, { type: { type: "number", precision: 1 } }); -Component.register(positionZ, { type: { type: "number", precision: 1 } }); -Component.register(velocityX, { type: { type: "number", precision: 1 } }); -Component.register(velocityY, { type: { type: "number", precision: 1 } }); -Component.register(velocityZ, { type: { type: "number", precision: 1 } }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); const scalars = [color, mass, positionX, positionY, positionZ, velocityX, velocityY, velocityZ] as const; /** Entity i goes to archetype i % 4, like ecs2's seedSingle. */ diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 2db886f4..30928fa2 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { System } from "../interface/system/system.js"; +import type { F32 } from "../interface/math/index.js"; // A hundred archetypes, each grown at once: what a sync point costs where every archetype it touches grows. @@ -16,7 +16,7 @@ const g6 = Symbol("growth/g6"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [g0]: { type: true }; [g1]: { type: true }; [g2]: { type: true }; @@ -27,14 +27,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(g0, { type: { const: true } }); -Component.register(g1, { type: { const: true } }); -Component.register(g2, { type: { const: true } }); -Component.register(g3, { type: { const: true } }); -Component.register(g4, { type: { const: true } }); -Component.register(g5, { type: { const: true } }); -Component.register(g6, { type: { const: true } }); const tags = [g0, g1, g2, g3, g4, g5, g6] as const; /** `value` with each of the first 100 sets of the seven tags, by their bits. */ diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts index 13022cbf..5e45e28e 100644 --- a/packages/ecs/src/perftest/handoff.ts +++ b/packages/ecs/src/perftest/handoff.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -23,7 +22,6 @@ declare module "@adobe/ecs" { } } /** An object of the joined thread, which places the systems reading it there. */ -Resource.register(token, { thread: "handoffBenchmark" }); const here0 = System.create({ name: "handoff/here0" }, () => {}); const there0 = System.create({ name: "handoff/there0", resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 68dffdda..f203ef7f 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; +import type { F32 } from "../interface/math/index.js"; export const local = Symbol("hierarchy/local"); export const world = Symbol("hierarchy/world"); @@ -18,10 +18,6 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(leaf, { type: { const: true } }); const root = Archetype.create([local, world]); const child = Archetype.create([local, world, parent]); @@ -97,13 +93,11 @@ export const placed = Symbol("hierarchy/placed"); declare module "@adobe/ecs" { export interface Components { - [offset]: { type: number }; - [placed]: { type: number }; + [offset]: { type: F32 }; + [placed]: { type: F32 }; } } -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(placed, { type: { type: "number", precision: 1 } }); const gpuRoot = Archetype.create([offset, placed]); const gpuChild = Archetype.create([offset, placed, parent]); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index a4df81f3..c7b622ce 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { Index } from "../interface/indexes/index-type.js"; import { workers } from "../interface/resources/workers.js"; @@ -8,6 +7,7 @@ import type { World } from "../interface/world/world.js"; import { createWorld } from "../implementation/index.js"; import type { Benchmark } from "./benchmark.js"; import { fastest } from "./suite.js"; +import type { U32, Vec3 } from "../interface/math/index.js"; // Benchmarks of indexes: keeping them current, and looking up through them. @@ -20,22 +20,15 @@ const place = Symbol("indexes/place"); const cell = Symbol("indexes/cell"); declare module "@adobe/ecs" { export interface Components { - [key]: { type: number }; + [key]: { type: U32 }; [amount]: { type: number }; [marked]: { type: true }; - [serial]: { type: number }; + [serial]: { type: U32 }; [parent]: { type: Entity }; - [place]: { type: readonly [number, number, number] }; - [cell]: { type: readonly [number, number, number] }; + [place]: { type: Vec3 }; + [cell]: { type: Vec3.I32 }; } } -Component.register(key, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(parent, { type: Entity.schema }); -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -0x80000000, maximum: 0x7FFFFFFF }, minItems: 3, maxItems: 3 } }); const million = 1_000_000; @@ -92,7 +85,6 @@ declare module "@adobe/ecs" { [idleKey]: { type: number }; } } -Component.register(idleKey, { type: { type: "number" } }); /** Unique serials among `unique lookup 1M`'s entities. */ const bySerial = Index.create({ name: "indexes/bySerial", key: [serial], unique: true, components: { has: [amount] } }); @@ -266,7 +258,6 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -Component.register(tagged, { type: { const: true } }); const untagged = Index.create({ name: "indexes/untagged", key: [], components: { has: [amount], not: [tagged] } }); const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands }) => { for (let i = 0; i < million; i++) { @@ -311,7 +302,6 @@ declare module "@adobe/ecs" { [flag]: { type: boolean }; } } -Component.register(flag, { type: { type: "boolean" } }); const byFlag = Index.create({ name: "indexes/byFlag", key: [flag] }); const seedFlags = System.create({ name: "indexes/seedFlags" }, ({ commands }) => { for (let i = 0; i < flagged; i++) commands.spawn([flag])({ [flag]: i % 2 === 0 }); diff --git a/packages/ecs/src/perftest/log.ts b/packages/ecs/src/perftest/log.ts index 71751752..e8fe0e28 100644 --- a/packages/ecs/src/perftest/log.ts +++ b/packages/ecs/src/perftest/log.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { createBuffer } from "../implementation/world/buffer.js"; import { createCommands } from "../implementation/world/commands.js"; import { asEntity } from "../implementation/world/entities.js"; @@ -22,14 +21,11 @@ declare module "@adobe/ecs" { } export interface Components { [serial]: { type: number }; - [label]: { type: { readonly text: string } | null; thread: "logBenchmark" }; + [label]: { type: { readonly text: string } | null; thread: "logBenchmark"; default: typeof none }; [moved]: { type: true }; } } -Component.register(serial, { type: { type: "number" } }); const none = { text: "none" }; -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "logBenchmark" }); -Component.register(moved, { type: { const: true } }); /** Entities moved, and swapped out of the rows they leave, per sync point of `log replay`. */ export const replayMoves = 10_000; diff --git a/packages/ecs/src/perftest/observed-gpu.ts b/packages/ecs/src/perftest/observed-gpu.ts index 62574114..f2050074 100644 --- a/packages/ecs/src/perftest/observed-gpu.ts +++ b/packages/ecs/src/perftest/observed-gpu.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; import { gpuDevice } from "../interface/resources/gpu-device.js"; import { workers } from "../interface/resources/workers.js"; @@ -7,6 +6,7 @@ import { System } from "../interface/system/system.js"; import { createWorld } from "../implementation/world/create.js"; import { Observe } from "../observe/index.js"; import type { Benchmark } from "./benchmark.js"; +import type { F32 } from "../interface/math/index.js"; // An entity observer factory of values a GPU kernel writes: each run reads back what the observer reads. @@ -14,12 +14,10 @@ const heat = Symbol("observedGpu/heat"); const rate = Symbol("observedGpu/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); const heated = 100_000; let spawned: Entity[] = []; diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 84d48620..7dc803e1 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -25,14 +23,9 @@ declare module "@adobe/ecs" { [unmatched]: { type: true }; } export interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 3 }; } } -Resource.register(level, { type: { type: "number", default: 3 } }); -Component.register(value, { type: { type: "number" } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); -Component.register(unmatched, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ const seed = System.create({ name: "observed/seed" }, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 7c15f501..f4b04244 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; +import type { F32, U32 } from "../interface/math/index.js"; // Mirrors adobe/data's ecs1/ecs2 perftests (packages/data/src/perftest): 1M particles split evenly across // 4 archetypes by visible/enabled; move adds velocity to position for the visible and enabled quarter. @@ -19,21 +19,15 @@ export const enabled = Symbol("perf/enabled"); declare module "@adobe/ecs" { export interface Components { - [color]: { type: number }; - [mass]: { type: number }; - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; + [color]: { type: U32 }; + [mass]: { type: F32 }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); /** Interleaved like ecs2's seedSingle: entity i goes to archetype i % 4. */ const archetypes = [ diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index feb05839..2ca732a8 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -23,13 +21,9 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } export interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 3 }; } } -Resource.register(level, { type: { type: "number", default: 3 } }); -Component.register(value, { type: { type: "number" } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ const seed = System.create({ name: "reads/seed" }, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index 004837a2..83e01a6f 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; import { Index } from "../interface/indexes/index-type.js"; -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -28,16 +26,10 @@ declare module "@adobe/ecs" { [amount]: { type: number }; } export interface Resources { - [counter]: { type: number }; - [sign]: { type: number }; + [counter]: { type: number; default: 0 }; + [sign]: { type: number; default: 1 }; } } -Resource.register(counter, { type: { type: "number", default: 0 } }); -Resource.register(sign, { type: { type: "number", default: 1 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(caption, { type: { type: "string" } }); -Component.register(amount, { type: { type: "number" } }); /** An empty transaction. */ export const empty = System.create({ name: "remote/empty" }, () => {}); diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts index 8e311f7f..b082f64b 100644 --- a/packages/ecs/src/perftest/render-order.ts +++ b/packages/ecs/src/perftest/render-order.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { Index } from "../interface/indexes/index-type.js"; import { gpuDevice } from "../interface/resources/gpu-device.js"; @@ -7,6 +6,7 @@ import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld } from "../implementation/world/create.js"; import type { Benchmark } from "./benchmark.js"; +import type { F32 } from "../interface/math/index.js"; // A 2D editor's paint order: shapes and images, two archetypes, interleaved in depth, drawn as one table in a sorted // index's order. @@ -16,14 +16,11 @@ const shape = Symbol("renderOrder/shape"); const image = Symbol("renderOrder/image"); declare module "@adobe/ecs" { export interface Components { - [paintDepth]: { type: number }; + [paintDepth]: { type: F32 }; [shape]: { type: true }; [image]: { type: true }; } } -Component.register(paintDepth, { type: { type: "number", precision: 1 } }); -Component.register(shape, { type: { const: true } }); -Component.register(image, { type: { const: true } }); const byDepth = Index.create({ name: "renderOrder/byDepth", key: [paintDepth], sorted: true }); diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts index 46f73686..c834cdf1 100644 --- a/packages/ecs/src/perftest/strings.ts +++ b/packages/ecs/src/perftest/strings.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -18,8 +17,6 @@ declare module "@adobe/ecs" { [boxed]: { type: { readonly text: string } }; } } -Component.register(text, { type: { type: "string" } }); -Component.register(boxed, { type: { type: "object", properties: { text: { type: "string" } } } }); const rows = 10_000; diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index 1c2d423e..d5927d1f 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; +import type { F32, U32 } from "../interface/math/index.js"; // Structural changes one kind at a time: spawns, despawns, adds and removes, through main-thread systems and kernels, // each applied by the sync point that ends its run. @@ -22,11 +22,11 @@ const t3 = Symbol("structural/t3"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; - [value]: { type: number }; + [index]: { type: U32 }; + [value]: { type: F32 }; [flag]: { type: true }; - [amount]: { type: number }; - [extra]: { type: number }; + [amount]: { type: F32 }; + [extra]: { type: F32 }; [spawned]: { type: true }; [source1]: { type: true }; [source2]: { type: true }; @@ -37,18 +37,6 @@ declare module "@adobe/ecs" { } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(amount, { type: { type: "number", precision: 1 } }); -Component.register(extra, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); -Component.register(spawned, { type: { const: true } }); -Component.register(source1, { type: { const: true } }); -Component.register(source2, { type: { const: true } }); -Component.register(t0, { type: { const: true } }); -Component.register(t1, { type: { const: true } }); -Component.register(t2, { type: { const: true } }); -Component.register(t3, { type: { const: true } }); const seeded = [ Archetype.create([index, value]), diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 631588b9..6f4e8ad1 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -1,12 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; -import { Resource } from "../interface/resource/resource.js"; import { Entity } from "../interface/entity/entity.js"; import type { I32 } from "../interface/math/i32/index.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; +import type { F32, U32 } from "../interface/math/index.js"; // One fixture per optimization area: structural changes, change filters, lookups, commands, dispatch and main-thread // paths. @@ -24,13 +23,13 @@ export const hits = Symbol("suite/hits"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; [target]: { type: Entity }; - [index]: { type: number }; + [index]: { type: U32 }; [tag]: { type: true }; [unused]: { type: true }; - [hits]: { type: number }; + [hits]: { type: F32 }; } } @@ -38,20 +37,11 @@ export const indexSum = Symbol("suite/indexSum"); export const highest = Symbol("suite/highest"); declare module "@adobe/ecs" { export interface Resources { - [indexSum]: { type: number; reduce: "+" }; - [highest]: { type: number; reduce: "max" }; + [indexSum]: { type: number; reduce: "+"; default: 0 }; + [highest]: { type: number; reduce: "max"; default: 0 }; } } -Resource.register(indexSum, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(highest, { type: { type: "number", default: 0 }, reduce: "max" }); - -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(target, { type: Entity.schema }); -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(tag, { type: { const: true } }); -Component.register(unused, { type: { const: true } }); -Component.register(hits, { type: { type: "number", precision: 1 } }); + const moving = Archetype.create([position, velocity, index]); const following = Archetype.create([position, velocity, index, target]); diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts index 4553240f..5c7621f0 100644 --- a/packages/ecs/src/perftest/systems.ts +++ b/packages/ecs/src/perftest/systems.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { always } from "../interface/resources/always.js"; import { eachTick, ticksAfter } from "./observer.js"; import { System } from "../interface/system/system.js"; @@ -16,7 +15,6 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(level, { type: { type: "number" } }); /** Entities the added systems read. */ const entities = 100; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index c673a679..3b38bdac 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick, ticksAfter } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -11,6 +9,8 @@ import { backToBack, createWorld, type CreatedWorld } from "../implementation/in import { tailWriters } from "../implementation/world/tails.js"; import type { Benchmark } from "./benchmark.js"; import { reportOf, runReported } from "../implementation/world/reports.test-helper.js"; +import type { Mat4x4, U32, Vec3 } from "../interface/math/index.js"; +import type { Schema } from "../interface/schema/schema.js"; // Tails: terminal systems on a joined worker, which claims "tailsBenchmark", running after their tick's sync point // while the world goes on. The joined thread is `joined.ts`. @@ -26,24 +26,18 @@ declare module "@adobe/ecs" { tailsBenchmark: true; } export interface Components { - [place]: { type: readonly [number, number, number] }; - [pose]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number] }; + [place]: { type: Vec3 }; + [pose]: { type: Mat4x4 }; } export interface Resources { - [surface]: { type: { readonly name: string }; thread: "tailsBenchmark" }; - [poked]: { type: number }; - [rowWork]: { type: number }; - [worldWork]: { type: number }; + [surface]: { type: { readonly name: string }; thread: "tailsBenchmark"; default: typeof surfaceStart }; + [poked]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [rowWork]: { type: U32; default: 0 }; + [worldWork]: { type: number; default: 0 }; } } -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(pose, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 16, maxItems: 16 } }); /** An object of the joined thread, as a canvas or the DOM is, which places the systems reading it there. */ const surfaceStart = { name: "surface" }; -Resource.register(surface, { type: { type: "object", properties: { name: { type: "string" } }, default: surfaceStart }, thread: "tailsBenchmark" }); -Resource.register(poked, { type: { type: "integer", default: 0 } }); -Resource.register(rowWork, { type: { type: "integer", minimum: 0, maximum: 1_000_000, default: 0 } }); -Resource.register(worldWork, { type: { type: "number", default: 0 } }); /** What the tails read, so their reads aren't dead. */ export const drawn = { sum: 0 }; diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts index 0594df18..5d815b4b 100644 --- a/packages/ecs/src/perftest/transfers.ts +++ b/packages/ecs/src/perftest/transfers.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld, type CreatedWorld } from "../implementation/index.js"; @@ -18,7 +17,6 @@ declare module "@adobe/ecs" { } } /** An object of the claimed thread, which places the system reading it there. */ -Resource.register(sink, { thread: "transfersBenchmark" }); /** Bytes `take` took, on the claimed thread. */ export const taken = { bytes: 0 }; diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 564c3d33..19f564a7 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -1,21 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { gpuDevice } from "../interface/resources/gpu-device.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld } from "../implementation/world/create.js"; import type { Benchmark } from "./benchmark.js"; +import type { Vec3 } from "../interface/math/index.js"; // A drag: the CPU writes one row of a column a render system holds on the GPU, which uploads what changed. const place = Symbol("uploads/place"); declare module "@adobe/ecs" { export interface Components { - [place]: { type: readonly [number, number, number] }; + [place]: { type: Vec3 }; } } -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); const shapes = 100_000; let spawned: Entity[] = []; diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 0ed06a7e..84b6e6c4 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; @@ -8,6 +7,7 @@ import type { World } from "../interface/world/world.js"; import { createWorld } from "../implementation/index.js"; import type { Benchmark, Best } from "./benchmark.js"; import { fastest } from "./suite.js"; +import type { Vec2 } from "../interface/math/index.js"; // Benchmarks of struct values, which a row reads into an object and writes back field by field: a 2D transform's five // numbers, as an editor's nodes hold, beside a vec2 size. @@ -21,16 +21,12 @@ declare module "@adobe/ecs" { export interface Components { [pose]: { type: Pose }; [shown]: { type: Pose }; - [extent]: { type: readonly [number, number] }; + [extent]: { type: Vec2 }; [order]: { type: number }; } } const number = { type: "number" } as const; const poseSchema = { type: "object", properties: { tx: number, ty: number, rotation: number, sx: number, sy: number }, required: ["tx", "ty", "rotation", "sx", "sy"] } as const; -Component.register(pose, { type: poseSchema }); -Component.register(shown, { type: poseSchema }); -Component.register(extent, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); -Component.register(order, { type: number }); const Node = Archetype.create([pose, shown, extent, order]); diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 01d6f315..8b61f139 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -1,34 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Gpu, System, Vec3 } from '../../../index.js'; +import { Archetype, type F32, Gpu, System, type U32, Vec3 } from '../../../index.js'; // The perf particles (src/perftest/particles.ts) on screen: 1M particles over four archetypes by visible and // enabled, with screen-space positions and velocities in pixels per frame. `move` is the perf move kernel, on the GPU. -export const position = Symbol("gpuParticles/position"); -export const velocity = Symbol("gpuParticles/velocity"); -export const color = Symbol("gpuParticles/color"); -export const mass = Symbol("gpuParticles/mass"); -export const visible = Symbol("gpuParticles/visible"); -export const enabled = Symbol("gpuParticles/enabled"); +export const position = Symbol(); +export const velocity = Symbol(); +export const color = Symbol(); +export const mass = Symbol(); +export const visible = Symbol(); +export const enabled = Symbol(); declare module '@adobe/ecs' { interface Components { [position]: { type: Vec3 }; [velocity]: { type: Vec3 }; - [color]: { type: number }; - [mass]: { type: number }; + [color]: { type: U32 }; + [mass]: { type: F32 }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); - /** Entity i goes to archetype i % 4. */ const archetypes = [ Archetype.create([color, mass, position, velocity, visible, enabled]), diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index 0bd4ece2..c5866b4d 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Resource, System, Vec3, type World } from '../../index.js'; +import { type F32, System, type U32, Vec3, type World } from '../../index.js'; -export const position = Symbol("particles/position"); -export const velocity = Symbol("particles/velocity"); -export const delta = Symbol("particles/delta"); -export const count = Symbol("particles/count"); +export const position = Symbol(); +export const velocity = Symbol(); +export const delta = Symbol(); +export const count = Symbol(); declare module '@adobe/ecs' { interface Components { @@ -12,16 +12,11 @@ declare module '@adobe/ecs' { [velocity]: { type: Vec3 }; } interface Resources { - [delta]: { type: number }; - [count]: { type: number }; + [delta]: { type: F32; default: 0 }; + [count]: { type: U32; default: 100 }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Resource.register(delta, { type: { type: "number", precision: 1, default: 0 } }); -Resource.register(count, { type: { type: "integer", minimum: 0, default: 100 } }); - // Main thread: resource-only, runs when `count` changes, including the first run. export const spawn = System.create({ name: "particles/spawn", diff --git a/packages/ecs/src/samples/particles/particles.type-test.ts b/packages/ecs/src/samples/particles/particles.type-test.ts deleted file mode 100644 index 6b5bef1a..00000000 --- a/packages/ecs/src/samples/particles/particles.type-test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../../index.js'; -import { count } from './particles.js'; - -// @ts-expect-error a resource schema requires a default -Resource.register(count, { type: { type: "integer" } }); -// @ts-expect-error the default must match the resource type -Resource.register(count, { type: { type: "integer", default: "none" } }); diff --git a/packages/ecs/src/samples/todo/features/completion/dom.ts b/packages/ecs/src/samples/todo/features/completion/dom.ts index d7cef8b2..5c055363 100644 --- a/packages/ecs/src/samples/todo/features/completion/dom.ts +++ b/packages/ecs/src/samples/todo/features/completion/dom.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource, System } from "../../../../index.js"; +import { System } from "../../../../index.js"; import { byDone } from "./data.js"; import { TodoSummary } from "./elements/todo-summary.js"; import { TodoToggleAll } from "./elements/todo-toggle-all.js"; /** The page's summary and toggle-all checkbox; null until the page provides them. */ -export const summary = Symbol("summary"); -export const toggleAllBox = Symbol("toggleAllBox"); +export const summary = Symbol(); +export const toggleAllBox = Symbol(); declare module "@adobe/ecs" { interface Resources { @@ -15,9 +15,6 @@ declare module "@adobe/ecs" { } } -Resource.register(summary, { thread: "main" }); -Resource.register(toggleAllBox, { thread: "main" }); - /** * Writes the counts, read off the index's lists, and whether all are done, after a pass in which a todo was added, * removed or toggled: the index's entries changing is its only change filter. diff --git a/packages/ecs/src/samples/todo/features/filter/data.ts b/packages/ecs/src/samples/todo/features/filter/data.ts index e03a2063..4ad3f398 100644 --- a/packages/ecs/src/samples/todo/features/filter/data.ts +++ b/packages/ecs/src/samples/todo/features/filter/data.ts @@ -1,17 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../../../index.js"; export type Filter = "all" | "active" | "completed"; -export const filterSchema = { anyOf: [{ const: "all" }, { const: "active" }, { const: "completed" }] } as const; - /** Which todos the list shows: all, those not done, or those done. */ -export const filter = Symbol("filter"); +export const filter = Symbol(); declare module "@adobe/ecs" { interface Resources { - [filter]: { type: Filter }; + [filter]: { type: Filter; default: "all" }; } } -Resource.register(filter, { type: { ...filterSchema, default: "all" } }); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.ts b/packages/ecs/src/samples/todo/features/filter/dom.ts index c4a0817e..6d4992ad 100644 --- a/packages/ecs/src/samples/todo/features/filter/dom.ts +++ b/packages/ecs/src/samples/todo/features/filter/dom.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource, System } from "../../../../index.js"; +import { System } from "../../../../index.js"; import { done } from "../todos/data.js"; import { row } from "../todos/dom.js"; import { TodoRow } from "../todos/elements/todo-row.js"; @@ -7,7 +7,7 @@ import { filter, type Filter } from "./data.js"; import { TodoFilters } from "./elements/todo-filters.js"; /** The page's filter buttons; null until the page provides them. */ -export const filters = Symbol("filters"); +export const filters = Symbol(); declare module "@adobe/ecs" { interface Resources { @@ -15,8 +15,6 @@ declare module "@adobe/ecs" { } } -Resource.register(filters, { thread: "main" }); - /** Whether the filter leaves a todo out. */ const leavesOut = (chosen: Filter, isDone: boolean): boolean => chosen === "completed" ? !isDone : chosen === "active" && isDone; diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts index 8a59ecc6..22d8cb39 100644 --- a/packages/ecs/src/samples/todo/features/todos/data.ts +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Index } from "../../../../index.js"; +import { Archetype, Index } from "../../../../index.js"; /** A todo's text. */ -export const label = Symbol("label"); -export const done = Symbol("done"); +export const label = Symbol(); +export const done = Symbol(); /** Where a todo sits in the list: todos are listed by ascending order. */ -export const order = Symbol("order"); -export const todo = Symbol("todo"); +export const order = Symbol(); +export const todo = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -17,11 +17,6 @@ declare module "@adobe/ecs" { } } -Component.register(label, { type: { type: "string" } }); -Component.register(done, { type: { type: "boolean" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(todo, { type: { const: true } }); - export const Todo = Archetype.create([todo, label, done, order]); /** Every todo, in list order. */ diff --git a/packages/ecs/src/samples/todo/features/todos/dom.ts b/packages/ecs/src/samples/todo/features/todos/dom.ts index f240eb22..57c80765 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Finalizer, Index, Resource, System } from "../../../../index.js"; +import { Finalizer, Index, System } from "../../../../index.js"; import { attach } from "../../shared/attach.js"; import { done, label, order, todo } from "./data.js"; import { TodoList } from "./elements/todo-list.js"; import { TodoRow } from "./elements/todo-row.js"; /** The row showing a todo. */ -export const row = Symbol("row"); +export const row = Symbol(); /** The page's list, which rows are placed in; null until the page provides it. */ -export const list = Symbol("list"); +export const list = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -19,9 +19,6 @@ declare module "@adobe/ecs" { } } -Component.register(row, { thread: "main" }); -Resource.register(list, { thread: "main" }); - /** Todos with rows, in list order. */ const placed = Index.create({ name: "todos/placed", key: [order], sorted: true, components: { has: [todo, row] } }); diff --git a/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts index fcf27d65..2e70a0fd 100644 --- a/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts +++ b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts @@ -113,7 +113,6 @@ describe("todo-row", () => { }); }); - describe("todo-list", () => { type Row = HTMLElementTagNameMap["todo-row"]; @@ -180,7 +179,6 @@ describe("todo-list", () => { }); }); - describe("todo-entry", () => { it("dispatches todo-add on Enter and clears itself, ignoring blank text; Escape clears", () => { const entry = document.createElement("todo-entry"); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index e00513a4..b572785f 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -1,15 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Resource, System, Vec2, clock } from '../../index.js'; +import { clock, System, type U32, Vec2 } from '../../index.js'; // The transfers sample's keys and systems, which every thread imports: the world, on a worker, draws sprites into the // canvas main moved to it, each with a texture a loader worker decoded and moved to it. Values move through runs whose // input holds them. -export const texture = Symbol("transferSprites/texture"); -export const position = Symbol("transferSprites/position"); -export const velocity = Symbol("transferSprites/velocity"); -export const surface = Symbol("transferSprites/surface"); -export const stats = Symbol("transferSprites/stats"); +export const texture = Symbol(); +export const position = Symbol(); +export const velocity = Symbol(); +export const surface = Symbol(); +export const stats = Symbol(); declare module '@adobe/ecs' { interface Components { @@ -19,20 +19,13 @@ declare module '@adobe/ecs' { } interface Resources { [surface]: { type: OffscreenCanvas | null; transfer: true }; - [stats]: { type: { readonly sprites: number; readonly frames: number } }; + [stats]: { type: { readonly sprites: U32; readonly frames: U32 }; default: { sprites: 0; frames: 0 } }; } } /** A decoded texture, moved to the world's thread, where sprites are drawn. */ -Component.register(texture, { transfer: true }); /** In pixels, and pixels per second. */ -Component.register(position, { type: Vec2.schema }); -Component.register(velocity, { type: Vec2.schema }); /** The canvas main moved to the world's thread. */ -Resource.register(surface, { transfer: true }); -Resource.register(stats, { - type: { type: "object", properties: { sprites: { type: "integer" }, frames: { type: "integer" } }, default: { sprites: 0, frames: 0 } }, -}); /** From main: the canvas the world draws into. */ export const attachSurface = System.create({ name: "transferSprites/attachSurface", resources: { write: [surface] } }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index ddece40f..f9615806 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Resource, System, World, workers } from "./index.js"; +import { System, World, workers } from "./index.js"; const count = Symbol("worldTest/count"); const total = Symbol("worldTest/total"); @@ -9,11 +9,9 @@ declare module "@adobe/ecs" { [count]: { type: number }; } export interface Resources { - [total]: { type: number }; + [total]: { type: number; default: 0 }; } } -Component.register(count, { type: { type: "number" } }); -Resource.register(total, { type: { type: "number", default: 0 } }); const spawn = System.create({ name: "worldTest/spawn" }, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); const raise = System.create({ name: "worldTest/raise", resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); From 7ea2e17b8c4ee72e4bf03a0a66d67ecbb74cf318 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 16:22:23 -0700 Subject: [PATCH 052/127] ecs: docs describe keys declared once and versioned ids Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 3 + packages/ecs/design.md | 20 +++--- packages/ecs/followups.md | 18 ++--- packages/ecs/src/guide/CLAUDE.md | 5 +- packages/ecs/src/guide/guide.md | 111 +++++++++++++------------------ packages/ecs/threads.md | 21 +++--- 6 files changed, 81 insertions(+), 97 deletions(-) diff --git a/packages/ecs/README.md b/packages/ecs/README.md index b6dda888..40d2cac1 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -13,6 +13,9 @@ A high-performance, multithreaded Entity Component System for web-based applicat - Synced back to shared memory only when the CPU reads it - Strings in shared memory, readable on every thread - Objects and other variable-length values on the thread that owns them +- **Declarations** + - Components and resources declared once, as TypeScript types; the build registers them + - Named types such as `F32`, `U32` and your own `Schema.Type`s give storage precision, width and bounds - **Systems** - Plain TypeScript bodies with declared reads and writes - Kernels compiled and checked at build time to optimized JavaScript, WASM and WGSL diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 930f8fa7..563afaf8 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -44,7 +44,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - It finds systems by resolving the import binding of the system factory. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `kernelPlugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name of the nearest `package.json` above the module that has one and the module's path in that package, without a query, so a workspace-linked package names no directory outside it; without a named package, a dependency's package name and path inside `node_modules`, or the path from the root; never an absolute path; `#default` names a default export. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. + - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. @@ -70,19 +70,19 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`Changed` is conservative:** it never misses a change but may report a component whose value is unchanged. Consumers must treat it as "may have changed", which keeps early cutoff a pure optimization. - **System bodies can't carry state from one row to the next.** Cross-row results (sums, counts) need an explicit reduction construct, so work can later be split without changing semantics. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. -- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource`, `Trait` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. +- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol(...)`, which TypeScript types as a `unique symbol`. Its traits are declared by hand, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. Registering it, a side effect of loading its module, gives the same traits, `type` as a schema: `Component.register(parent, { type: Entity.schema, reference: "owner" })`. Its types check registration against the declaration both ways: the schema describes exactly the declared type (`Schema.ToType`, under which `Entity.schema` is `Entity`, a fixed-length array a tuple and `{ const: true }` `true`) and a default in it is of that type, every declared trait is given with its declared value, and nothing undeclared is given. A failed check names the problem where it can (`"the schema describes another type than the declared one"`). `type` is inferred from the schema alone and the checks are separate members of the parameter's type, so a registration's contextual typing never resolves them. - - **Checks of a generic key read lookups:** while a registration's key is still being inferred, contextual typing resolves its checks for a generic key, and a recursive check of a generic value, such as `IsStruct`, recurses without end. Traits read whether a key's value is a struct from a lookup computed once for every key (`Structs` in `trait/types.ts`), its keys remapped (`as K`) so a generic key's lookup stays a lookup; the checks read `Components[K]["type"]` rather than `Component.Value`, whose deferred conditional resolves the same way. - - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), and its key, a symbol too, is registered with `Trait.register`, in the registry components and resources share. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) registers each record after its block, a side effect of loading its module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module or one it imports, which a declaration can only name anyway, so every declared key has an id; anything else fails the build. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports the registry from the importer's copy of the package: its source modules from the copy they're in, any other module, a library's shipped build among them, from `@adobe/ecs/declare`, which the importer resolves. + - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. + - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Registration checks the built-in traits on what values tell; what a trait of your own allows on a key is documentation the declaration isn't checked against. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - - **A resource always has a value:** its schema's `default` or the declaration's, beside `type` (`Resource.register(names, { type: { type: "map", keys, values }, default: new Map() })`), either of the declared type and never both, left out only when the type allows `null`, which it then starts at. - - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them, looked at five levels deep) are declared by their type alone: registration has no `type`, the types require one exactly when the declared type is data, and a resource gives its `default` on the declaration unless its type allows `null`. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. + - **A resource always has a value:** its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [0, 0, 0]`) or `typeof` a value (`default: typeof Entity.none`, `default: typeof empty`), left out only when the type allows `null`, which it then starts at; the build rejects a resource without one, and a literal default that its schema doesn't hold. A component's default, which a derived component starts at, is declared the same way. The registration puts a data value's default in its schema and gives another's beside it. + - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - **Import and use:** code that references a component or resource always imports its key. That guarantees the module is loaded, so the component is registered. - - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; types reject strings too: `register` takes only symbol keys, and while `Components`, `Resources` or `Traits` has a string key that isn't a built-in trait, every component and resource declaration requires a property named for the problem (`"component keys must be symbols"`), so nothing registers until it's fixed. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). + - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - - **Labels:** errors and reports name a key by its label: its description while no other registered key has it, else its id, and without one its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. + - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, while no other registered key has it, else its id, and without either its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. - **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts @@ -211,7 +211,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. - **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. - - **The operator is a resource trait,** `Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" })`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor. + - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor. - **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers. - **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`. - **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 1028b718..a264125c 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -83,20 +83,22 @@ Open items awaiting a decision or work. Remove an item once it's decided or done The plugin is required, so it can derive what consumers now restate. Each is a contract change, reviewed one at a time; inferred values are emitted as the same literals the runtime takes today, so none costs anything at runtime. -1. **Declarations from the augmentation.** The pre-strip pass reads `interface Components`, `Resources` and `Traits` - and emits the `register` calls: the schema from the type (`F32`, `I32`, `U32` for precision and width), the traits - as declared, and defaults as a trait. About 200 calls in consumer code restate their augmentation. -2. **Names from bindings.** `name` on systems, composites, indexes and finalizers defaults to the plugin's id - (`#`), which 218 of 325 names restate; `Symbol` descriptions likewise. -3. **Reductions declared once.** A system's `reduce` drops the operator, which is the resource's `reduce` trait, and +1. **Names from bindings.** `name` on systems, composites, indexes and finalizers defaults to the plugin's id + (`#`), which 218 of 325 names restate. +2. **Reductions declared once.** A system's `reduce` drops the operator, which is the resource's `reduce` trait, and its keys come from the body's `reduce[key](…)` calls. -4. **Kernels by default.** A body in the kernel subset compiles as a `"cpu"` kernel unflagged; `kernel: "cpu"` +3. **Kernels by default.** A body in the kernel subset compiles as a `"cpu"` kernel unflagged; `kernel: "cpu"` asserts it, `"gpu"` chooses the backend, `false` runs it as written. Needs a measured rule for small runs, and accepts results that aren't bit-exact with plain JS. -5. **Access inferred from bodies.** `components`, `resources`, `lookup` and `up` read and write lists come from the +4. **Access inferred from bodies.** `components`, `resources`, `lookup` and `up` read and write lists come from the body where `usedBy` follows every use, explicit otherwise. Bodies' rows are then typed over every component, so it needs the planned ESLint rule to keep errors in the editor. +- **What a trait of your own allows isn't checked.** `Traits` gives the value each trait allows on a key, which + registration's types checked. The build registers declarations without a type checker, and registration checks only + the built-in traits. A check type over every declaration, surfaced where every program reaches, as `World.create`, + would restore it at the cost of evaluating every declaration once. + ## Findings from the todo sample (`src/samples/todo/README.md`) 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md index 66eb1607..fa240a9d 100644 --- a/packages/ecs/src/guide/CLAUDE.md +++ b/packages/ecs/src/guide/CLAUDE.md @@ -18,7 +18,7 @@ code it shows: every excerpt comes from a file here that runs as a test, so the ## Structure -- **Setup:** its own short section before the basics: the build plugin and how keys are registered and declared. +- **Setup:** its own short section before the basics: the build plugin and how keys are declared. - **Basics:** resources, then entities, components and archetypes, which together are the whole data model, then systems, and ticks and cadence: everything needed to build and run a world and its loop. Topics that belong together share a section; entities are taught with components, and archetypes, named lists of components, with them. @@ -28,7 +28,6 @@ code it shows: every excerpt comes from a file here that runs as a test, so the ## Code conventions -- Keys are short: `Symbol("position")`, never a path like `"guide/components/position"`. A resource's description says - nothing about components. +- Keys are `Symbol()` with no description: the build's id names each by its binding. - Archetypes are types, so their names are PascalCase: `const Particle = Archetype.create([position, velocity])`. - Use the public API only, as a consumer imports it, not internal modules. Read a world through observer systems. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index e0f39521..cfe81a0f 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -42,10 +42,9 @@ export default defineConfig({ plugins: [kernelPlugin()] }); ``` Each component and resource is declared once, by augmenting `Components` or `Resources` in `@adobe/ecs` with its -traits, `type` among them, and registered once with the same traits. Registration's types check the two match. -`type` is registered as a JSON Schema for the value, in which `{ type: "set", items }` and -`{ type: "map", keys, values }` describe a `ReadonlySet` and a `ReadonlyMap`. Traits of your own augment `Traits`. -The examples create worlds with `World.create()`. +traits, `type` among them. The plugin registers each declaration: it reads the schema the value is stored by from +`type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its precision or width. Traits of your own +augment `Traits`. The examples create worlds with `World.create()`. ## Basics @@ -55,30 +54,26 @@ Systems change that data, once per run or on every tick. ### 1. Resources ```ts -export const gravity = Symbol("gravity"); -export const names = Symbol("names"); -export const audio = Symbol("audio"); -export const focused = Symbol("focused"); - -// Its traits are declared; `type` is the value's type. +export const gravity = Symbol(); +export const names = Symbol(); +export const audio = Symbol(); +export const focused = Symbol(); +const noNames: ReadonlyMap = new Map(); + +// It's declared once, with its traits: `type` is the value's type, and `default` the value a world starts it at, which +// a type allowing null may leave out. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { - [gravity]: { type: number }; - [names]: { type: ReadonlyMap }; + [gravity]: { type: number; default: -9.8 }; + // A default that isn't a literal names a value with typeof. + [names]: { type: ReadonlyMap; default: typeof noNames }; + // A value that isn't data, such as a class, is stored as it is. [audio]: { type: AudioContext | null }; - [focused]: { type: Entity }; + // No entity is Entity.none rather than null, so an entity stays data every thread reads. + [focused]: { type: Entity; default: typeof Entity.none }; } } -// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a -// value: its schema's default, or one beside it. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); -// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. -Resource.register(audio, {}); -// No entity is Entity.none rather than null, so an entity stays data every thread reads. -Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); - // Systems read a world: an observer only reads, after the world's changes apply. let values: readonly unknown[] = []; const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { @@ -91,14 +86,14 @@ await moon.run(read); expect(values[0]).toBe(-1.6); ``` -[`01-resources.test.ts`](01-resources.test.ts#L6-L46) +[`01-resources.test.ts`](01-resources.test.ts#L6-L40) ### 2. Entities, components and archetypes ```ts -export const position = Symbol("position"); -export const velocity = Symbol("velocity"); -export const enemy = Symbol("enemy"); +export const position = Symbol(); +export const velocity = Symbol(); +export const enemy = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -109,10 +104,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(enemy, { type: { const: true } }); - // An archetype names a list of components, once at module scope; it goes wherever a list of components does. export const Particle = Archetype.create([position, velocity]); @@ -144,7 +135,7 @@ expect(foes).toEqual([[0, 10, 0]]); expect(particles).toBe(1); ``` -[`02-entities.test.ts`](02-entities.test.ts#L6-L59) +[`02-entities.test.ts`](02-entities.test.ts#L6-L49) ### 3. Systems @@ -180,7 +171,7 @@ await world.run(read); expect(moved).toEqual([[1, 0, 0]]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L35-L69) +[`03-systems.test.ts`](03-systems.test.ts#L28-L60) ```ts const fail = System.create({ name: "fail" }, () => { @@ -201,7 +192,7 @@ expect(run).toBe(1); expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L71-L87) +[`03-systems.test.ts`](03-systems.test.ts#L64-L81) ### 4. Ticks and cadence @@ -229,7 +220,7 @@ expect(await ticked).toBeGreaterThanOrEqual(30); world.remove(advance); ``` -[`04-ticks.test.ts`](04-ticks.test.ts#L18-L52) +[`04-ticks.test.ts`](04-ticks.test.ts#L15-L43) ```ts // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's @@ -245,7 +236,7 @@ world.add(step); await world.run(System.create(() => {})); ``` -[`04-ticks.test.ts`](04-ticks.test.ts#L54-L75) +[`04-ticks.test.ts`](04-ticks.test.ts#L51-L69) ## Intermediate @@ -264,7 +255,7 @@ const heal = System.create({ components: { write: [health] }, after: [hit] }, ro const frame = System.create([heal, hit]); ``` -[`05-scheduling.test.ts`](05-scheduling.test.ts#L19-L43) +[`05-scheduling.test.ts`](05-scheduling.test.ts#L16-L25) ```ts // Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. @@ -279,7 +270,7 @@ const report = System.create({ components: { read: [health] }, during: [render] const phased = System.create([report, poison, update, render]); ``` -[`05-scheduling.test.ts`](05-scheduling.test.ts#L45-L62) +[`05-scheduling.test.ts`](05-scheduling.test.ts#L41-L51) ### 6. Sync points @@ -299,7 +290,7 @@ const count = System.create({ components: { has: [health] }, resources: { write: const frame = System.create([spawn, count]); ``` -[`06-sync-points.test.ts`](06-sync-points.test.ts#L21-L53) +[`06-sync-points.test.ts`](06-sync-points.test.ts#L17-L29) ```ts // An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. @@ -308,7 +299,7 @@ const watch = System.create({ observer: true, components: { read: [health] } }, }); ``` -[`06-sync-points.test.ts`](06-sync-points.test.ts#L36-L39) +[`06-sync-points.test.ts`](06-sync-points.test.ts#L32-L35) ### 7. Change filters and conditions @@ -324,7 +315,7 @@ const show = System.create({ components: { read: [score], write: [shown], change }); ``` -[`07-change-filters.test.ts`](07-change-filters.test.ts#L36-L66) +[`07-change-filters.test.ts`](07-change-filters.test.ts#L27-L35) ```ts // Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. @@ -333,7 +324,7 @@ const step = System.create({ resources: { is: [map], not: [paused], write: [step }); ``` -[`07-change-filters.test.ts`](07-change-filters.test.ts#L68-L89) +[`07-change-filters.test.ts`](07-change-filters.test.ts#L59-L62) ### 8. Input @@ -358,22 +349,19 @@ await world.run(hit, { amount: 3 }); await world.run(heal, { entity: player, amount: 1 }); ``` -[`08-input.test.ts`](08-input.test.ts#L18-L43) +[`08-input.test.ts`](08-input.test.ts#L16-L41) ### 9. Rules ```ts declare module "@adobe/ecs" { interface Components { - // Only rules add derived components, starting at their schema's default. + // Only rules add derived components, starting at their default. [drawn]: { type: true; derived: true }; - [layer]: { type: number; derived: true }; + [layer]: { type: number; derived: true; default: 1 }; } } -Component.register(drawn, { type: { const: true }, derived: true }); -Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); - // Entities matching a rule have what it derives, only while they match. Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. @@ -382,7 +370,7 @@ Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); Rule.create({ components: { has: [drawn] }, derive: [layer] }); ``` -[`09-rules.test.ts`](09-rules.test.ts#L11-L59) +[`09-rules.test.ts`](09-rules.test.ts#L11-L27) ### 10. Finalizers and change sets @@ -396,7 +384,7 @@ Finalizer.create({ components: { read: [handle] } }, row => { await world.dispose(); ``` -[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L48) +[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L43) ```ts // Sees each sync point's structural changes to the components it watches, grouped by how entities changed, with @@ -408,12 +396,12 @@ Finalizer.changes({ watch: [handle] }, changes => { }); ``` -[`10-finalizers.test.ts`](10-finalizers.test.ts#L50-L66) +[`10-finalizers.test.ts`](10-finalizers.test.ts#L49-L55) ### 11. Traits, within and references ```ts -// Traits are the fields of a key's declaration; type is one. Registration repeats them. +// Traits are the fields of a key's declaration; type is one. declare module "@adobe/ecs" { interface Components { [ship]: { type: true }; @@ -427,12 +415,6 @@ declare module "@adobe/ecs" { } } -Component.register(ship, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(target, { type: Entity.schema, reference: "weak" }); -Component.register(selected, { type: { const: true } }); -Component.register(outline, { type: { type: "number" }, within: selected }); - const spawn = System.create(({ commands }) => { const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); commands.spawn([parent])({ [parent]: mothership }); @@ -440,23 +422,22 @@ const spawn = System.create(({ commands }) => { }); ``` -[`11-traits.test.ts`](11-traits.test.ts#L12-L60) +[`11-traits.test.ts`](11-traits.test.ts#L11-L29) ```ts // A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. -export const instanced = Symbol("instanced"); +export const instanced = Symbol(); declare module "@adobe/ecs" { interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; } } -Trait.register(instanced); // Types select components by trait. check, typeof transform | typeof name>, typeof transform>>(); ``` -[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L28) +[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L25) ## Advanced @@ -477,7 +458,7 @@ const count = System.create({ components: { read: [cell], write: [crowded] }, lo }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L28-L35) +[`13-indexes.test.ts`](13-indexes.test.ts#L21-L28) ```ts // A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. @@ -496,7 +477,7 @@ const list = System.create({ observer: true, components: { read: [player] }, ord }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L37-L50) +[`13-indexes.test.ts`](13-indexes.test.ts#L30-L43) ```ts // A unique index gives one row, or null. @@ -507,7 +488,7 @@ const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ looku }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L52-L57) +[`13-indexes.test.ts`](13-indexes.test.ts#L45-L50) ```ts // An index without a key holds every entity with its components, in entity order. With changed: true, this observer @@ -519,7 +500,7 @@ const countPlayers = System.create({ observer: true, lookup: { players: { index: }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L59-L65) +[`13-indexes.test.ts`](13-indexes.test.ts#L52-L58) A list stays valid until its call site runs again. Lists and rows read only what the index holds and its lookup's `read` names, and `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index e24f224e..0cb86991 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -32,13 +32,15 @@ Referenced from `design.md`. never wait on another thread. - **Objects default to `world`.** A declaration without `thread` is on `world`; one with it names a logical thread. Libraries declare threads on components; the application decides which joined thread fills each. -- **The DOM rule:** a DOM `Node` lives on `main`, the window's thread, and its declaration says so: the types require - `thread: "main"` where the DOM library is loaded, and registration checks it on values it can see. +- **The DOM rule:** a DOM `Node` lives on `main`, the window's thread, and its declaration says so: the build requires + `thread: "main"` for a type that's one of the DOM's classes or extends one, and registration checks it on values it + can see. - **Keys are symbols,** so components, resources and traits never collide, but a symbol is a different value on each thread. Threads agree on keys through the ids the plugin gives top-level `Symbol(...)` declarations, by which the registry already takes the symbols of one id as one key; a key without one can't cross, so a joined thread's observer - system naming one fails to register. Ids name packages, not directories, so threads bundled apart agree on them, and - the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. + system naming one fails to register. Ids name packages and their versions, not directories, so threads bundled apart + agree on them, and the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. Every declared + key has one, since a declaration names only a top-level `Symbol()` constant. - **Systems, rules and finalizers are their values,** and names are optional labels, so a value is different on each thread too. Threads agree on them through the ids the plugin gives top-level `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and `Finalizer.changes` bindings, composites included, by which the registry @@ -66,20 +68,17 @@ export interface Threads { main: true; // the window's thread, where the DOM is } -// `thread` only on objects: a struct with `thread` fails in types and at registration. +// `thread` only on objects: a struct with `thread` fails at registration. The build registers each declaration, a +// value that isn't data without a schema. declare module "@adobe/ecs" { interface Components { - // A DOM Node lives on "main", and says so; the check resolves to no requirement where the DOM library isn't - // loaded. + // A DOM Node lives on "main", and says so; the build requires it. [domNode]: { type: HTMLElement; thread: "main" }; // `transfer: true` requires a Transferable type: ArrayBuffer, ImageBitmap, VideoFrame, OffscreenCanvas, // MessagePort. It lives on `world`, where the GPU uploads it; a loader thread sets it by moving the value there. [textureSource]: { type: ImageBitmap; transfer: true }; } } -// A value that isn't data is registered without a schema. -Component.register(domNode, { thread: "main" }); -Component.register(textureSource, { transfer: true }); // On the thread that holds the world: const world = World.create(resources?); @@ -701,7 +700,7 @@ then green type tests, then runtime with red then green runtime tests. Each phas ### Built: threads in declarations, the loop, and the pool -- `Threads`, `thread`, `transfer` and the DOM rule in types, with registration's checks; a system's and finalizer's +- `Threads`, `thread`, `transfer` and the DOM rule, in the build and registration's checks; a system's and finalizer's thread derived, and two threads rejected, when it's created. With every thread filled by `world`, nothing changes at runtime. - Ids: the plugin gives top-level keys and `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and From ba3f6d69dc21deabb339c2d516a22dfd81a577b9 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 16:27:03 -0700 Subject: [PATCH 053/127] ecs: drop a compiler helper nothing uses since types follow into the package Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/compiler/paths.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 014ca32c..8675ca5e 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -166,11 +166,6 @@ export const namespaceCopyOf = (id: string, namespace: string): PackageCopy | nu return found !== null && modules.has(found.path) ? copyIn(found.root, found.path, extname(file)) : null; }; -/** Whether a resolved module id is one of the package's public modules, in any copy: its root or its interface. */ -export const isInterfaceModule = (id: string): boolean => { - const found = pathInCopy(fileOf(id)); - return found !== null && (found.path.startsWith("src/interface/") || found.path.startsWith("dist/interface/") || rootModules.includes(found.path)); -}; /** A file's path in a copy of the package, without `src/` or `dist/` and its extension; null outside every copy. */ export const packagePathOf = (file: string): string | null => { From a3ae0c759ebbf9af012b080823c151894ebfe520 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 16:59:56 -0700 Subject: [PATCH 054/127] ecs: keys register right after themselves, in the module declaring them; library mode; trait checks A key and its augmentation are in one module, which registers the key right after it and after the bindings its declaration names, so importing a key registers it. The build checks the built-in traits' literals and World.create every declaration's traits against Traits. kernelPlugin({ library: true }) runs the pass a library's build ships. Enums fail the build, declare global types are followed, and @adobe/ecs/declare's hooks are named for generated code. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 8 +- packages/ecs/followups.md | 5 - packages/ecs/src/exports.test.ts | 23 +++ packages/ecs/src/guide/guide.md | 10 +- .../compiler/augmentations.test.ts | 87 ++++++++- .../implementation/compiler/augmentations.ts | 180 ++++++++++++++---- .../implementation/compiler/identify.test.ts | 9 + .../src/implementation/compiler/identify.ts | 4 +- .../compiler/input-schemas.test.ts | 11 +- .../implementation/compiler/input-schemas.ts | 16 +- .../implementation/compiler/plugin.test.ts | 65 +++++-- .../ecs/src/implementation/compiler/plugin.ts | 15 +- .../implementation/compiler/type-schemas.ts | 59 ++++-- .../world/ids-again.test-helper.ts | 27 --- .../world/ids-changes.test-helper.ts | 4 + .../world/ids-keys.test-helper.ts | 21 ++ .../ecs/src/implementation/world/ids.test.ts | 43 ++--- .../implementation/world/reductions.test.ts | 4 +- .../src/implementation/world/rules.test.ts | 7 +- .../ecs/src/implementation/world/state.ts | 2 +- .../implementation/world/thread-systems.ts | 4 +- .../src/interface/component/register.test.ts | 10 +- packages/ecs/src/interface/component/types.ts | 4 +- .../ecs/src/interface/registry/declare.ts | 17 +- .../ecs/src/interface/registry/ids.test.ts | 75 ++++---- .../ecs/src/interface/registry/threads.ts | 2 +- .../ecs/src/interface/report/components.ts | 6 +- .../src/interface/resource/register.test.ts | 6 +- packages/ecs/src/interface/resource/types.ts | 5 +- .../ecs/src/interface/resources/always.ts | 4 +- .../ecs/src/interface/resources/cadence.ts | 4 +- packages/ecs/src/interface/resources/clock.ts | 4 +- .../ecs/src/interface/resources/gpu-device.ts | 4 +- packages/ecs/src/interface/resources/wake.ts | 4 +- .../ecs/src/interface/resources/workers.ts | 4 +- .../src/interface/thread/thread.type-test.ts | 28 +-- packages/ecs/src/interface/trait/types.ts | 3 +- packages/ecs/src/interface/world/create.ts | 8 +- .../ecs/src/interface/world/declarations.ts | 27 +++ 39 files changed, 541 insertions(+), 278 deletions(-) delete mode 100644 packages/ecs/src/implementation/world/ids-again.test-helper.ts create mode 100644 packages/ecs/src/implementation/world/ids-changes.test-helper.ts create mode 100644 packages/ecs/src/implementation/world/ids-keys.test-helper.ts create mode 100644 packages/ecs/src/interface/world/declarations.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 563afaf8..20043dd8 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -45,7 +45,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `kernelPlugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. + - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `kernelPlugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. - On registration, workers `import()` each kernel and cache it by the world's index for its module's url, so new values of one kernel, as a module evaluated again makes, share one entry and workers' caches are bounded by code. @@ -72,14 +72,14 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. - **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) registers each record after its block, a side effect of loading its module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module or one it imports, which a declaration can only name anyway, so every declared key has an id; anything else fails the build. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports the registry from the importer's copy of the package: its source modules from the copy they're in, any other module, a library's shipped build among them, from `@adobe/ecs/declare`, which the importer resolves. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) registers each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, it returns a type naming each such declaration's key and members rather than a `World`, so using it fails to compile. That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports the registry from the importer's copy of the package: its source modules from the copy they're in, any other module, a library's shipped build among them, from `@adobe/ecs/declare`, which the importer resolves. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Registration checks the built-in traits on what values tell; what a trait of your own allows on a key is documentation the declaration isn't checked against. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. + - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - **A resource always has a value:** its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [0, 0, 0]`) or `typeof` a value (`default: typeof Entity.none`, `default: typeof empty`), left out only when the type allows `null`, which it then starts at; the build rejects a resource without one, and a literal default that its schema doesn't hold. A component's default, which a derived component starts at, is declared the same way. The registration puts a data value's default in its schema and gives another's beside it. - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - - **Import and use:** code that references a component or resource always imports its key. That guarantees the module is loaded, so the component is registered. + - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, while no other registered key has it, else its id, and without either its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a264125c..f85ec230 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -94,11 +94,6 @@ time; inferred values are emitted as the same literals the runtime takes today, body where `usedBy` follows every use, explicit otherwise. Bodies' rows are then typed over every component, so it needs the planned ESLint rule to keep errors in the editor. -- **What a trait of your own allows isn't checked.** `Traits` gives the value each trait allows on a key, which - registration's types checked. The build registers declarations without a type checker, and registration checks only - the built-in traits. A check type over every declaration, surfaced where every program reaches, as `World.create`, - would restore it at the cost of evaluating every declaration once. - ## Findings from the todo sample (`src/samples/todo/README.md`) 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index 52443fcf..accf3ed6 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -106,6 +106,20 @@ export const listed = System.create([near, far]); export const declared = System.create({ systems: [near, far], after: [both] }); `; +/** A consumer whose declarations give traits values their types don't allow, and a default not of its type. */ +const misdeclaredTraits = ` +import { World } from "@adobe/ecs"; +export const count = Symbol(); +export const mark = Symbol(); +export const label = Symbol(); +declare module "@adobe/ecs" { + interface Traits { readonly [mark]: N extends Component.Key ? (Component.Value extends string ? true : never) : never } + interface Components { [count]: { type: number; thread: "main"; [mark]: true } } + interface Resources { [label]: { type: string; default: typeof count; transfer: true } } +} +export const world: World = World.create(); +`; + describe("package exports", () => { beforeAll(() => { install(); @@ -129,6 +143,15 @@ describe("package exports", () => { }>;`); expect([...emitted.matchAll(/import\("([^"]+)"\)/g)].map(match => match[1]).filter(path => path !== "@adobe/ecs")).toEqual([]); }, 60_000); + it("fail World.create to compile while a declaration gives a trait a value its type doesn't allow, naming it", () => { + const reported = compile(misdeclaredTraits.replace('import { World } from "@adobe/ecs";', 'import { World, type Component } from "@adobe/ecs";')).messages.join("\n"); + expect(reported).toContain("declarations give traits values their types don't allow"); + // Each declaration by its key, with the members that don't hold: a trait of your own's among them. + expect(reported).toContain(`[count]: unique symbol | "thread"`); + expect(reported).toContain("[label]:"); + expect(reported).toContain(`"default"`); + expect(reported).toContain(`"transfer"`); + }, 60_000); it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => { const reported = compile(misdeclared).messages; expect(reported.some(message => message.includes("No overload matches"))).toBe(false); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index cfe81a0f..af10c913 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -41,10 +41,12 @@ import { kernelPlugin } from "@adobe/ecs/compiler"; export default defineConfig({ plugins: [kernelPlugin()] }); ``` -Each component and resource is declared once, by augmenting `Components` or `Resources` in `@adobe/ecs` with its -traits, `type` among them. The plugin registers each declaration: it reads the schema the value is stored by from -`type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its precision or width. Traits of your own -augment `Traits`. The examples create worlds with `World.create()`. +Each component and resource is declared once, in the module that makes its key, by augmenting `Components` or +`Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the +schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its +precision or width. Traits of your own augment `Traits`. A library builds with Vite and +`kernelPlugin({ library: true })`, so its build ships its registrations. The examples create worlds with +`World.create()`. ## Basics diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 7c90ff42..6d5a6425 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -41,7 +41,14 @@ describe("augmentationInsertions", () => { }); test("imports the registry's hooks it calls", async () => { const code = await passed(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); - expect(code?.split("\n")[0]).toBe(`import { declareComponent as declareComponent$ } from "declare.js";`); + expect(code?.split("\n")[0]).toBe(`import { declareComponent$ } from "declare.js";`); + }); + test("names the hooks it imports apart from the module's own names", async () => { + const code = await passed(`import { declareComponent$ } from "elsewhere";\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n").filter(line => line.includes("declareComponent$1"))).toEqual([ + `import { declareComponent$ as declareComponent$1 } from "declare.js";`, + `declareComponent$1(tag, { type: {"const":true} });`, + ]); }); test("gives a default as a literal, or as the value `typeof` names, in the schema", async () => { expect(await registrations(`${keys("gravity", "mode", "selected", "origin")}declare module "@adobe/ecs" { @@ -70,9 +77,10 @@ declare module "@adobe/ecs" { } }`, files)).toEqual([ `declareComponent$(row, { thread: "main" });`, - `declareResource$(pool, { default: empty });`, `declareResource$(canvas, { transfer: true });`, `declareResource$(callback, {});`, + // After the value its default names. + `declareResource$(pool, { default: empty });`, ]); }); test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => { @@ -100,15 +108,75 @@ declare module "@adobe/ecs" { interface Traits { readonly [mark]: true } }`)).toEqual([`declareTrait$(mark);`, `declareComponent$(marked, { type: {"const":true}, [mark]: true });`]); }); - test("follows a type named by Schema.Type and imported keys", async () => { - const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;\nexport const level = Symbol();` }; - expect(await registrations(`import { level, type Level } from "./units.js";\ndeclare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ + test("follows a type named by Schema.Type that another module declares", async () => { + const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;` }; + expect(await registrations(`import type { Level } from "./units.js";\n${keys("level")}declare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ `declareComponent$(level, { type: {"type":"integer","minimum":0,"maximum":9} });`, ]); }); - test("registers after the block, its key and the value its default names, whichever comes last", async () => { - const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Vec3; default: typeof start } } }\nconst later = Symbol();\nconst start = [1, 2, 3] as const;\nconst after = 1;`); + test("registers right after its key, and after the bindings its declaration names, wherever the block is", async () => { + const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Vec3; default: typeof start } } } +const later = Symbol(); +const start = [1, 2, 3] as const; +const after = 1;`); expect(code?.split("\n").slice(-3)).toEqual([`const start = [1, 2, 3] as const;`, `declareResource$(later, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":start} });`, `const after = 1;`]); + // Code between the key and a block below uses the key registered. + const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 0 } } }`); + expect(above?.split("\n").slice(2, 4)).toEqual([`const speed = Symbol();`, `declareResource$(speed, { type: {"type":"number","precision":1,"default":0} });`]); + }); + test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => { + const code = await passed(`declare module "@adobe/ecs" { + interface Components { [marked]: { type: true; [mark]: true } } +} +declare module "@adobe/ecs" { + interface Traits { readonly [mark]: true } +} +const marked = Symbol(); +const mark = Symbol();`); + expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declareTrait$(mark);`, `declareComponent$(marked, { type: {"const":true}, [mark]: true });`]); + }); + test("registers after a let, var or class its default names, which have no value before their statements", async () => { + const code = await passed(`${keys("a", "b", "c")}declare module "@adobe/ecs" { + interface Resources { [a]: { type: Pool; default: typeof first }; [b]: { type: Pool; default: typeof second }; [c]: { type: new () => object; default: typeof Pool } } +} +let first = new (class {})(); +var second = new (class {})(); +class Pool {} +const after = 1;`); + expect(code?.split("\n").slice(-7)).toEqual([ + `let first = new (class {})();`, `declareResource$(a, { default: first });`, + `var second = new (class {})();`, `declareResource$(b, { default: second });`, + `class Pool {}`, `declareResource$(c, { default: Pool });`, + `const after = 1;`, + ]); + }); + test("takes a type that allows null through its aliases", async () => { + const files = { "/src/maybe.ts": `export type Maybe = Pool | null;\nexport class Pool {}` }; + expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declareResource$(pool, {});`]); + }); + test("follows types a declare global block declares, here or in a module imported, before taking a name as a global class", async () => { + const files = { "/src/globals.ts": `declare global {\n interface Point { readonly x: number; readonly y: number }\n}\nexport {};` }; + expect(await registrations(`import "./globals.js";\ndeclare global { type Weight = number }\n${keys("at", "weight")}declare module "@adobe/ecs" { interface Components { [at]: { type: Point }; [weight]: { type: Weight } } }`, files)).toEqual([ + `declareComponent$(at, { type: {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}}} });`, + `declareComponent$(weight, { type: {"type":"number"} });`, + ]); + }); + test("checks the built-in traits' values, as far as their literals and types tell", async () => { + const block = (body: string, more = "") => `${keys("key", "other")}const notKey = 1;\n${more}declare module "@adobe/ecs" { ${body} }`; + const components = (traits: string) => block(`interface Components { [key]: { ${traits} }; [other]: { type: true } }`); + expect(await passed(components(`type: true; derived: false`))).toContain("derived is `true`"); + expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; derived: true } }`))).toContain("only a component is derived"); + expect(await passed(components(`type: Entity; reference: "strong"`))).toContain(`reference is "owner" or "weak"`); + expect(await passed(components(`type: number; reference: "owner"`))).toContain("a reference's type is Entity"); + expect(await passed(components(`type: true; within: "other"`))).toContain("within is `typeof` a component's key"); + expect(await passed(components(`type: true; within: typeof notKey`))).toContain("within is `typeof` a component's key"); + expect(await passed(components(`type: Map; transfer: true`))).toContain("transfer takes a Transferable class"); + expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; reduce: "-" } }`))).toContain("reduce is one of"); + expect(await passed(components(`type: number; reduce: "+"`))).toContain("only a resource is reduced"); + expect(await passed(components(`type: string; thread: "main"`))).toContain("a string lives in shared memory"); + expect(await passed(components(`type: Vec3; thread: "main"`))).toContain("a struct lives in shared memory"); + // A Transferable class, through an alias and with null. + expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declareComponent$(key, { transfer: true });`); }); test("passes over modules without a declaration of the package", async () => { expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null); @@ -116,6 +184,9 @@ declare module "@adobe/ecs" { test("fails, saying why, for what a declaration can't be", async () => { const block = (body: string) => `declare module "@adobe/ecs" { ${body} }`; expect(await passed(`const key = Symbol.for("key");\n${block(`interface Components { [key]: { type: number } }`)}`)).toContain("top-level `Symbol()` constant"); + const files = { "/src/keys.ts": `export const elsewhere = Symbol();\nexport const layer = Symbol();` }; + expect(await passed(`import { elsewhere } from "./keys.js";\n${block(`interface Components { [elsewhere]: { type: number } }`)}`, files)).toContain(`elsewhere is imported from "./keys.js": declare it in the module that declares the key`); + expect(await passed(`import type { layer } from "./keys.js";\n${keys("key")}${block(`interface Components { [key]: { type: true; [layer]: true } }`)}`, files)).toContain("layer, imported as a type only"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: number } }`)}`)).toContain("default"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); expect(await passed(`import type { Entity as E } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Entity; default: typeof E.none } }`)}`)).toContain("type only"); @@ -123,6 +194,8 @@ declare module "@adobe/ecs" { expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type"); expect(await passed(block(`interface Traits { readonly layer: true }`))).toContain("keyed by a `Symbol()` constant"); + expect(await passed(`enum Mode { On, Off }\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`)).toContain("an enum"); + expect(await passed(`import { Mode } from "./mode.js";\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`, { "/src/mode.ts": `export enum Mode { On, Off }` })).toContain("an enum"); expect(await passed(block(`interface Components { "named": { type: number } }`))).toContain("a component's key is a `Symbol()` constant"); expect(await passed(block(`interface Resources { "time": { type: number; default: 0 } }`))).toContain("a resource's key is a `Symbol()` constant"); }); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index 5ba33edd..b16d4b3f 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -1,8 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { reductionOperators } from '../../interface/registry/reduction.js'; +import { isEntity, isString, isStruct } from '../../interface/registry/schemas.js'; import type { Schema } from '../../interface/schema/schema.js'; import type { PassResult } from './input-schemas.js'; import type { Insertion } from './source-map.js'; -import { createConverter, isNotData, isThrown, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; +import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; /** The module emitted code imports its registry hooks from, for the module being transformed. */ export type DeclareModuleOf = (file: string) => string; @@ -32,6 +34,12 @@ const augmentationsOf = (module: Module): readonly { readonly block: Block; read type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>; +type TypeName = Extract["typeName"]; + +/** A type reference's name and the namespaces it's read through. */ +const typeNameParts = (name: TypeName | Extract["left"]): readonly string[] => + name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...typeNameParts(name.left), name.right.name] : []; + /** A `typeof` query's name and the properties read from it; null for `this`. */ const qualifiedParts = (name: QueryName | Extract["left"]): readonly string[] | null => { if (name.type === "Identifier") return [name.name]; @@ -40,6 +48,12 @@ const qualifiedParts = (name: QueryName | Extract = new Set([ + "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", + "ReadableStream", "WritableStream", "TransformStream", "RTCDataChannel", +]); + /** Tests and their helpers in the package, which declare keys as consumers do. */ const testModule = /\.(test|type-test|fixture|test-helper|cases)\.tsx?$/; @@ -47,9 +61,12 @@ const testModule = /\.(test|type-test|fixture|test-helper|cases)\.tsx?$/; const isLibraryModule = (path: string | null, file: string): boolean => path !== null && (path.startsWith("interface/") || path.startsWith("implementation/")) && !testModule.test(file); -/** The top-level statement holding a node of the module, by position. */ -const statementAt = (module: Module, node: Node): Statement | undefined => - module.program.body.find(statement => statement.start <= node.start && node.end <= statement.end); +/** `base`, or it numbered, so the name appears nowhere in the code. */ +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; new RegExp(`${name.replace("$", "\\$")}(?![\\w$])`).test(code); n++) name = `${base}${n}`; + return name; +}; /** Whether a value matches a schema, as far as a literal default can be checked. */ const matches = (schema: Schema, value: Value): boolean => { @@ -73,19 +90,18 @@ const matches = (schema: Schema, value: Value): boolean => { } }; -/** Whether a type, as written, allows `null`. */ -const allowsNullType = (type: TypeNode): boolean => - type.type === "TSNullKeyword" || (type.type === "TSUnionType" && type.types.some(allowsNullType)) || (type.type === "TSParenthesizedType" && allowsNullType(type.typeAnnotation)); const allowsNullSchema = (schema: Schema): boolean => ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); /** - * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: after the - * block, each component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and - * its other traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant, - * of the module or one it imports. A literal default is checked against the schema; `default: typeof value` names a - * value, which the registration passes. Returns null when the module declares nothing. + * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: each + * component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and its other + * traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant of this + * module, so importing a key registers it: its registration comes right after it, after any binding its declaration + * names, and after a trait of this module it declares, wherever the block is. A literal default and the built-in + * traits' values are checked as far as literals and types show; `default: typeof value` names a value, which the + * registration passes. Returns null when the module declares nothing. */ export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => { const { code, file } = module; @@ -95,24 +111,112 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const converter = createConverter(host, file); const { fail } = converter; const insertions: Insertion[] = []; - const names = { component: "declareComponent$", resource: "declareResource$", trait: "declareTrait$" }; + const hooks = { component: "declareComponent$", resource: "declareResource$", trait: "declareTrait$" } as const; + // Local names the module doesn't use, so a module importing a hook itself keeps its own. + const names = { component: uniqueName(code, hooks.component), resource: uniqueName(code, hooks.resource), trait: uniqueName(code, hooks.trait) }; const used = new Set<"component" | "resource" | "trait">(); - /** The end of the top-level statement declaring a local binding, which the registration comes after; 0 for an import. */ + /** + * Where a local binding has its value, which a registration naming it comes after: the end of the top-level + * statement declaring it, and 0 for a function, hoisted with its value, or anything imported. + */ const endOfLocal = (name: string): number => { - const local = module.constants.get(name); - return local === undefined ? 0 : statementAt(module, local)?.end ?? 0; + for (const statement of module.program.body) { + const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement; + if (declared?.type === "VariableDeclaration" && declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name)) return statement.end; + if ((declared?.type === "ClassDeclaration" || declared?.type === "TSEnumDeclaration") && declared.id?.name === name) return statement.end; + } + return 0; }; - /** Fails unless `name` is a top-level `Symbol()` constant, here or where it's imported from. */ - const requireSymbol = async (name: string, at: Node, what: string): Promise => { + /** Whether an initializer is a call of the global `Symbol`. */ + const isSymbolCall = (init: Declarator["init"]): boolean => { + let expression = init; + while (expression !== null && (expression.type === "TSAsExpression" || expression.type === "TSSatisfiesExpression" || expression.type === "ParenthesizedExpression")) expression = expression.expression; + return expression?.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "Symbol"; + }; + + /** + * Fails unless a key a block declares is a top-level `Symbol()` constant of this module, so importing the key + * registers it. + */ + const requireOwnKey = (name: string, at: Node, what: string): void => { + const imported = module.imports.get(name); + if (imported !== undefined) fail(module, at, `${what} ${name} is imported from "${imported.source}": declare it in the module that declares the key`); + if (!isSymbolCall(module.constants.get(name)?.init ?? null)) fail(module, at, `${what} ${name} is a top-level \`Symbol()\` constant of this module`); + }; + + /** Fails unless a trait key a declaration names is a `Symbol()` constant, here or imported as a value. */ + const requireTraitKey = async (name: string, at: Node): Promise => { + const imported = module.imports.get(name); + if (imported !== undefined && !imported.value) fail(module, at, `a trait's key names ${name}, imported as a type only: import it as a value, which the registration passes`); const found = await converter.constantNamed([name], module); - let init = found?.declaration.init ?? null; - while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) init = init.expression; - const isSymbol = init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol"; - if (!isSymbol) fail(module, at, `${what} ${name} is a top-level \`Symbol()\` constant, of this module or one it imports`); + if (!isSymbolCall(found?.declaration.init ?? null)) fail(module, at, `a trait's key ${name} is a top-level \`Symbol()\` constant, of this module or one it imports`); + }; + + /** Whether a type, as written, allows `null`, through the aliases it names. */ + const allowsNullType = async (type: TypeNode, at: Module, seen: Set): Promise => { + if (type.type === "TSNullKeyword") return true; + if (type.type === "TSParenthesizedType") return await allowsNullType(type.typeAnnotation, at, seen); + if (type.type === "TSUnionType") { + for (const member of type.types) if (await allowsNullType(member, at, seen)) return true; + return false; + } + if (type.type !== "TSTypeReference" || type.typeArguments !== null) return false; + const found = await converter.typeNamed(typeNameParts(type.typeName), at); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return false; + seen.add(found.declaration); + return await allowsNullType(found.declaration.typeAnnotation, found.module, seen); + }; + + /** Whether a type, as written, is a Transferable class, or one or null, through the aliases it names. */ + const isTransferableType = async (type: TypeNode, at: Module, seen: Set): Promise => { + if (type.type === "TSParenthesizedType") return await isTransferableType(type.typeAnnotation, at, seen); + if (type.type === "TSUnionType") { + const members = type.types.filter(member => member.type !== "TSNullKeyword"); + if (members.length === 0) return false; + for (const member of members) if (!await isTransferableType(member, at, seen)) return false; + return true; + } + if (type.type !== "TSTypeReference" || type.typeArguments !== null) return false; + const parts = typeNameParts(type.typeName); + const found = await converter.typeNamed(parts, at); + if (found === "global") return parts.length === 1 && transferables.has(parts[0] ?? ""); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return false; + seen.add(found.declaration); + return await isTransferableType(found.declaration.typeAnnotation, found.module, seen); }; + /** Fails for a built-in trait whose value, or whose key's type, its literal and type show can't hold. */ + const checkBuiltIns = async (name: string, kind: "component" | "resource", type: TypeNode, schema: Schema | undefined, builtIns: ReadonlyMap): Promise => { + const derived = builtIns.get("derived"); + if (derived !== undefined && derived.text !== "true") fail(module, derived.node, `${name}'s derived is \`true\``); + if (derived !== undefined && kind !== "component") fail(module, derived.node, `${name} is a resource: only a component is derived`); + const reference = builtIns.get("reference"); + if (reference !== undefined && reference.text !== `"owner"` && reference.text !== `"weak"`) fail(module, reference.node, `${name}'s reference is "owner" or "weak"`); + if (reference !== undefined && (schema === undefined || !isEntity(schema))) fail(module, reference.node, `${name} is a reference, so a reference's type is Entity`); + const within = builtIns.get("within"); + if (within !== undefined) { + const host = within.node.type === "TSTypeQuery" && within.node.exprName.type === "Identifier" ? within.node.exprName.name : null; + const found = host === null ? null : await converter.constantNamed([host], module); + if (found === null || !isSymbolCall(found.declaration.init)) fail(module, within.node, `${name}'s within is \`typeof\` a component's key, a \`Symbol()\` constant`); + } + const transfer = builtIns.get("transfer"); + if (transfer !== undefined && (transfer.text !== "true" || schema !== undefined || !await isTransferableType(type, module, new Set()))) { + fail(module, transfer.node, `${name}'s transfer takes a Transferable class: ${[...transferables].join(", ")}`); + } + const reduce = builtIns.get("reduce"); + if (reduce !== undefined && kind !== "resource") fail(module, reduce.node, `${name} is a component: only a resource is reduced`); + if (reduce !== undefined && !reductionOperators.some(operator => JSON.stringify(operator) === reduce.text)) fail(module, reduce.node, `${name}'s reduce is one of ${reductionOperators.map(operator => `"${operator}"`).join(", ")}`); + const placed = builtIns.get("thread"); + if (placed !== undefined && !/^"[^"]+"$/.test(placed.text)) fail(module, placed.node, `${name}'s thread is a thread's name`); + if (placed !== undefined && schema !== undefined && isString(schema)) fail(module, placed.node, `${name} is a string: a string lives in shared memory, so it has no thread`); + if (placed !== undefined && schema !== undefined && isStruct(schema)) fail(module, placed.node, `${name} is a struct: a struct lives in shared memory, so it has no thread`); + }; + + /** Where a trait a block of this module declares is registered, which what declares it comes after. */ + const traitPoints = new Map(); + /** A trait's or default's value as code: a literal, or `typeof` a value, which names it; and that value's local binding. */ const valueText = async (type: TypeNode, what: string): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string }> => { if (type.type === "TSTypeQuery") { @@ -120,14 +224,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const [first] = named ?? []; if (named === null || first === undefined || type.typeArguments !== null) return fail(module, type, `${what} is a literal type, or \`typeof\` a value`); const imported = module.imports.get(first); - const declaredHere = module.constants.has(first) || module.program.body.some(statement => { - const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - return (declared?.type === "VariableDeclaration" && declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === first)) - || ((declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") && declared.id?.name === first); - }); - const global = imported === undefined && !declaredHere; if (imported !== undefined && !imported.value) fail(module, type, `${what} names ${first}, imported as a type only: import it as a value, which the registration passes`); - return { text: named.join("."), local: global || imported !== undefined ? undefined : first }; + return { text: named.join("."), local: imported === undefined ? first : undefined }; } const value = await converter.valueOfType(type, module, new Set()); return { text: JSON.stringify(value), value }; @@ -137,7 +235,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH // Traits first, so a component declaring one registers after it. const ordered = augmentations.flatMap(({ block, interfaces }) => interfaces.map(declared => ({ block, declared }))) .sort((a, b) => Number(b.declared.id.name === "Traits") - Number(a.declared.id.name === "Traits")); - for (const { block, declared } of ordered) { + for (const { declared } of ordered) { const interfaceName = declared.id.name; if (!isKind(interfaceName)) continue; const kind = kinds[interfaceName]; @@ -150,21 +248,25 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (kind === "trait") { // String-named traits are the package's own. if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); - await requireSymbol(key.name, member, "a trait's key"); + requireOwnKey(key.name, member, "a trait's key"); used.add("trait"); - insertions.push({ at: Math.max(block.end, endOfLocal(key.name)), text: `\n${names.trait}(${key.name});` }); + const point = endOfLocal(key.name); + traitPoints.set(key.name, point); + insertions.push({ at: point, text: `\n${names.trait}(${key.name});` }); continue; } if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); - await requireSymbol(key.name, member, `a ${kind}'s key`); + requireOwnKey(key.name, member, `a ${kind}'s key`); const declaration = member.typeAnnotation?.typeAnnotation; if (declaration?.type !== "TSTypeLiteral") return fail(module, member, `${key.name}'s declaration is a type literal of its traits: \`{ type: ... }\``); converter.site.at = member.start; - let at = Math.max(block.end, endOfLocal(key.name)); + let at = endOfLocal(key.name); let typeNode: TypeNode | undefined; let defaultNode: TypeNode | undefined; const traits: string[] = []; let thread = false; + /** The built-in traits declared, by name: their values' types and code. */ + const builtIns = new Map(); for (const trait of declaration.members) { if (trait.type !== "TSPropertySignature" || trait.typeAnnotation === null) return fail(module, trait, `${key.name}'s traits are properties with values`); const property: Property = trait; @@ -178,6 +280,11 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const text = await valueText(value, `${key.name}'s trait`); if (text.local !== undefined) at = Math.max(at, endOfLocal(text.local)); if (name === "thread") thread = true; + if (name !== null) builtIns.set(name, { node: value, text: text.text }); + if (name === null && property.key.type === "Identifier") { + await requireTraitKey(property.key.name, trait); + at = Math.max(at, endOfLocal(property.key.name), traitPoints.get(property.key.name) ?? 0); + } traits.push(name === null ? `[${property.key.type === "Identifier" ? property.key.name : ""}]: ${text.text}` : `${name}: ${text.text}`); } } @@ -190,10 +297,11 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (!isNotData(error)) throw error; if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); } + await checkBuiltIns(key.name, kind, typeNode, schema, builtIns); const start = defaultNode === undefined ? undefined : await valueText(defaultNode, `${key.name}'s default`); if (start?.local !== undefined) at = Math.max(at, endOfLocal(start.local)); if (schema !== undefined && start?.value !== undefined && !matches(schema, start.value)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); - const allowsNull = schema === undefined ? allowsNullType(typeNode) : allowsNullSchema(schema) || "default" in schema; + const allowsNull = schema === undefined ? await allowsNullType(typeNode, module, new Set()) : allowsNullSchema(schema) || "default" in schema; if (kind === "resource" && start === undefined && !allowsNull) fail(module, member, `resource ${key.name} declares the value it starts at: \`default: ...\`, which a type allowing null may leave out`); const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); @@ -208,7 +316,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH throw error; } if (insertions.length === 0) return null; - const imports = [...used].map(kind => `declare${kind[0]?.toUpperCase()}${kind.slice(1)} as ${names[kind]}`); + const imports = [...used].map(kind => names[kind] === hooks[kind] ? hooks[kind] : `${hooks[kind]} as ${names[kind]}`); insertions.unshift({ at: 0, text: `import { ${imports.join(", ")} } from ${JSON.stringify(host.declareModule(file))};\n` }); return { insertions, dependencies: [...converter.read] }; }; diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index 06164064..7804c921 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -161,6 +161,15 @@ describe("moduleIdOf", () => { expect(moduleIdOf("/app/src/keys.ts?t=1712", none)).toBe(null); expect(moduleIdOf("/libraries/shared/keys.ts", packages({ "/libraries": null }))).toBe(null); }); + test("stops at a drive's root, never reading the working directory's package", async () => { + const read: string[] = []; + const cwd = (directory: string) => { + read.push(directory); + return directory === "." ? { name: "cwd", version: "1.0.0" } : undefined; + }; + expect(moduleIdOf("C:\\elsewhere\\keys.ts", cwd)).toBe(null); + expect(read).toEqual(["C:/elsewhere", "C:"]); + }); test("uses forward slashes", async () => { expect(moduleIdOf("C:\\app\\src\\keys.ts", packages({ "C:/app": { name: "app", version: "1.0.0" } }))).toBe("app@1.0.0/src/keys.ts"); }); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index c195b8df..5d4c3351 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -30,7 +30,9 @@ export const moduleIdOf = (file: string, packageOf: PackageOf): string | null => for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { const found = packageOf(directory); if (found !== null && found !== undefined) return `${found.name}${found.version === undefined ? "" : `@${found.version}`}/${posix.relative(directory, path)}`; - if (posix.dirname(directory) === directory) break; + // A filesystem's root, `/` or a drive's `C:`, whose parent posix paths give as itself or as `.`. + const parent = posix.dirname(directory); + if (parent === directory || parent === ".") break; } return dependency.exec(path)?.[1] ?? null; }; diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts index e2132ff3..7d4deb7e 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.test.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -1,7 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, test } from 'vitest'; -import { passInputSchemas } from './input-schemas.js'; +import { inputInsertions, type InputHost } from './input-schemas.js'; +import { insertInto } from './source-map.js'; import { hostOf } from './type-host.test-helper.js'; +import { moduleOf } from './type-schemas.js'; + +/** The module as the pass leaves it, with the files whose types it read, or its failure; null when it inserts nothing. */ +const passInputSchemas = async (code: string, file: string, host: InputHost) => { + const result = await inputInsertions(moduleOf(file, code, host.parse(code, file)), host); + if (result === null || "failure" in result) return result; + return { ...insertInto(code, result.insertions, file), dependencies: result.dependencies }; +}; const header = `import { System, type Entity, type Vec2, type F32, type I32 } from "@adobe/ecs";\n`; diff --git a/packages/ecs/src/implementation/compiler/input-schemas.ts b/packages/ecs/src/implementation/compiler/input-schemas.ts index 3dca2f2f..3b2f2f84 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.ts @@ -1,18 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Failure } from './failure.js'; -import { insertInto, type Insertion, type SourceMap } from './source-map.js'; -import { createConverter, isThrown, moduleOf, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; +import type { Insertion } from './source-map.js'; +import { createConverter, isThrown, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; /** Whether an import specifier, resolved from `importer`, is a module of the package exporting `System`. */ export type IsSystem = (specifier: string, importer: string) => Promise; export type InputHost = TypeHost & { readonly isSystem: IsSystem }; -/** Code with each system's input schema passed to its `create`, the files whose types it read, or why it failed. */ -export type InputResult = - | { readonly code: string; readonly map: SourceMap; readonly dependencies: readonly string[] } - | { readonly failure: Failure; readonly dependencies: readonly string[] }; - /** What a pass inserts into a module, and the files whose types it read; or why it failed. */ export type PassResult = | { readonly insertions: readonly Insertion[]; readonly dependencies: readonly string[] } @@ -56,13 +51,6 @@ const declaresComponents = (declaration: Call["arguments"][number]): boolean | n * and interfaces of the module and those it imports, so it needs no type checker. Returns null when no system here * takes input. */ -export const passInputSchemas = async (code: string, file: string, host: InputHost): Promise => { - const result = await inputInsertions(moduleOf(file, code, host.parse(code, file)), host); - if (result === null || "failure" in result) return result; - return { ...insertInto(code, result.insertions, file), dependencies: result.dependencies }; -}; - -/** What `passInputSchemas` inserts into a module, as insertions into its code. */ export const inputInsertions = async (module: Module, host: InputHost): Promise => { const { code, file, program } = module; if (!code.includes("System") || !code.includes(".create(")) return null; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 2461cc3d..9366ad98 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -15,10 +15,10 @@ const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "fixture-app", type: "module" })); const fixture = join(directory, "fixture.js"); writeFileSync(fixture, `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -declareComponent(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); const kernel = System.create({ name: "fixture", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); @@ -31,9 +31,9 @@ mkdirSync(join(directory, "node_modules/fake-math/dist"), { recursive: true }); writeFileSync(join(directory, "node_modules/fake-math/package.json"), JSON.stringify({ name: "fake-math", type: "module", exports: "./dist/index.js" })); writeFileSync(join(directory, "node_modules/fake-math/dist/index.js"), "export const increment = (x) => x + 1;\n"); /** The linked kernel's keys, in a module of their own that the edit doesn't evaluate again. */ -writeFileSync(join(directory, "linked-keys.js"), `import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +writeFileSync(join(directory, "linked-keys.js"), `import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; export const score = Symbol("score"); -declareComponent(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); `); writeFileSync(linked, `import { System } from ${source("../../interface/system/system.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; @@ -58,10 +58,10 @@ export const schemas = [push, label].map(system => Reflect.get(system, "input")) /** A kernel spawning through a list another module declares, which the test edits. */ const listing = join(directory, "listing.js"); const listed = join(directory, "listed.js"); -const writeListed = (keys: string) => writeFileSync(listed, `import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +const writeListed = (keys: string) => writeFileSync(listed, `import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { score } from "./linked-keys.js"; export const extra = Symbol("extra"); -declareComponent(extra, { type: { const: true } }); +declareComponent$(extra, { type: { const: true } }); export const Listed = [${keys}]; `); writeListed("score"); @@ -75,24 +75,24 @@ export const description = kernelOf(kernel); /** Two kernels in one module, writing different functions of the entity. */ const pair = join(directory, "pair.js"); writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -declareComponent(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); const doubled = System.create({ name: "doubled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); const tripled = System.create({ name: "tripled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ const keys = (declaration: string, released = "score") => `import { System as Systems } from ${source("../../interface/system/system.ts")}; -import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { Finalizer } from ${source("../../interface/finalizer/finalizer.ts")}; import { Rule } from ${source("../../interface/rule/rule.ts")}; import { labelOf } from ${source("../../interface/registry/names.ts")}; export const score = Symbol("score"); const shown = Symbol("shown"); -declareComponent(score, ${declaration}); -declareComponent(shown, { type: { const: true }, derived: true }); +declareComponent$(score, ${declaration}); +declareComponent$(shown, { type: { const: true }, derived: true }); export const rule = Rule.create({ name: "keys/shown", components: { has: [score] }, derive: [shown] }); export const release = Finalizer.create({ name: "keys/release", components: { read: [${released}] } }, () => {}); export const system = Systems.create({ name: "keys/write", components: { write: [score] } }, (row) => { row[score] = 1; }); @@ -112,13 +112,13 @@ writeFileSync(join(directory, "node_modules/key-library/package.json"), JSON.str writeFileSync(join(directory, "node_modules/key-library/dist/index.js"), `export const marker = Symbol("marker");\n`); writeFileSync(join(directory, "worker-keys.js"), `export const message = Symbol("message");\nself.postMessage(String(message.description));\n`); writeFileSync(identified, `import { labelOf } from ${source("../../interface/registry/names.ts")}; -import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { marker } from "key-library"; export const score = Symbol("score"); const twin = Symbol("score"); export default Symbol(); -declareComponent(score, { type: { type: "number" } }); -declareComponent(twin, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); +declareComponent$(twin, { type: { type: "number" } }); export const labels = [labelOf(score), labelOf(twin), labelOf(marker)]; export const worker = () => new Worker(new URL("./worker-keys.js", import.meta.url), { type: "module" }); `); @@ -127,11 +127,11 @@ mkdirSync(join(directory, "node_modules/external-keys"), { recursive: true }); writeFileSync(join(directory, "node_modules/external-keys/package.json"), JSON.stringify({ name: "external-keys", type: "module", exports: "./index.js" })); writeFileSync(join(directory, "node_modules/external-keys/index.js"), `export const heat = Symbol("heat");\nexport const Pair = [Symbol("left"), Symbol("right")];\n`); const externalKernel = (name: string, added: string) => `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent } from ${source("../../interface/registry/declare.ts")}; +import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); -declareComponent(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); const kernel = System.create({ name: "${name}", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; @@ -327,7 +327,7 @@ test("an application's build compiles the kernels a library ships uncompiled", a writeFileSync(join(project, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); // As the library's own build with the plugin left it, registering its keys through the copy it resolves. writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { System } from "@adobe/ecs"; -import { declareComponent as declareComponent$ } from "@adobe/ecs/declare"; +import { declareComponent$ } from "@adobe/ecs/declare"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); @@ -380,6 +380,35 @@ declare module "@adobe/ecs" { expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1, default: 2 } }); }); +test("a library's build in library mode ships its registrations and input schemas, its kernels as source and no ids", async () => { + const project = join(directory, "library"); + await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "a-library", version: "2.0.0", type: "module" })); + const entry = join(project, "library.ts"); + writeFileSync(entry, `import { System, type F32 } from "@adobe/ecs"; +export const speed = Symbol(); +declare module "@adobe/ecs" { + interface Components { [speed]: { type: F32 } } +} +export const push = System.create({ name: "push" }, (_context, input: { readonly by: number }) => { void input; }); +export const slow = System.create({ name: "slow", kernel: "cpu", components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin({ library: true })], + build: { outDir, minify: false, lib: { entry, formats: ["es"], fileName: "index" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, + }); + const code = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .flatMap(item => item.type === "chunk" ? [item.code] : []).join("\n"); + expect(code).toContain(`from "@adobe/ecs/declare"`); + expect(code).toMatch(/declareComponent\$\(speed, \{ type: \{\s*"type": "number",\s*"precision": 1\s*\} \}\)/); + // The input's schema, passed to its create. + expect(code).toMatch(/"by": \{\s*"type": "number"\s*\}/); + expect(code).toContain(`row[speed] = row[speed] / 2`); + expect(code).not.toContain("attachKernel"); + expect(code).not.toContain("identify$"); +}); + test("serve accepts a module of keys evaluated again, which makes the same keys, systems, rules and finalizers by their ids", async () => { await serve(async server => { const first = await server.ssrLoadModule(evaluated); @@ -470,7 +499,7 @@ test("compiled code imports the runtime from the copy of the package its module const module = join(project, "copied.js"); writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); -declareComponent(score, { type: { type: "number" } }); +declareComponent$(score, { type: { type: "number" } }); export const kernel = System.create({ name: "copied", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const spawner = System.create({ name: "copiedSpawner", components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 52d6d5df..d4ee5718 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -119,7 +119,7 @@ const inputPlugin = (): Plugin => { dependents.set(file, parents); } }; - /** Each file's syntax as last parsed, which transforms of other modules reading its types share. */ + /** The package's own files' syntax as last parsed, which every module reading the package's types shares. */ const parsed = new Map(); const plugin: Plugin = { name: `${packageName}/input`, @@ -139,6 +139,8 @@ const inputPlugin = (): Plugin => { async transform(code, id) { const file = id.split("?")[0] ?? id; if (repeated || id.includes("\0") || !typed.test(file)) return null; + // Only a module declaring keys, or creating systems, has anything for either pass. + if (!(code.includes("declare module") && code.includes("@adobe/ecs")) && !(code.includes("System") && code.includes(".create("))) return null; const { parseAst } = await import("vite"); const resolved = async (specifier: string, importer: string) => await this.resolve(specifier, importer, { skipSelf: true }); const host: InputHost & AugmentationHost = { @@ -146,7 +148,7 @@ const inputPlugin = (): Plugin => { const known = parsed.get(path); if (known !== undefined && known.code === source) return known.program; const program = parseAst(source, { lang: path.endsWith("x") ? "tsx" : "ts" }, path); - parsed.set(path, { code: source, program }); + if (packagePathOf(path) !== null) parsed.set(path, { code: source, program }); return program; }, resolve: async (specifier, importer) => { @@ -179,8 +181,13 @@ const inputPlugin = (): Plugin => { return plugin; }; -/** The kernel plugin's two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. */ -export const kernelPlugin = (): Plugin[] => [inputPlugin(), compilePlugin()]; +/** + * The kernel plugin's two parts: registrations and input schemas, before TypeScript is stripped, and kernels and ids, + * after. A library's build takes `{ library: true }`, the first part alone: its build then ships the registrations and + * schemas its types give, which an application's build, reading only JavaScript there, can't make, and leaves its + * kernels as source and its keys without ids, which the application's plugin compiles and gives. + */ +export const kernelPlugin = (options: { readonly library?: boolean } = {}): Plugin[] => options.library === true ? [inputPlugin()] : [inputPlugin(), compilePlugin()]; const compilePlugin = (): Plugin => { let serve = true; diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts index 0d86be13..3a503474 100644 --- a/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -11,6 +11,7 @@ type Interface = Extract type Class = Extract; type Namespace = Extract; type Import = Extract; +type Enum = Extract; export type Declarator = Extract["declarations"][number]; export type TypeNode = Alias["typeAnnotation"]; type Member = Interface["body"]["body"][number]; @@ -64,7 +65,10 @@ export type Module = { readonly types: ReadonlyMap; readonly namespaces: ReadonlyMap; readonly constants: ReadonlyMap; + readonly enums: ReadonlyMap; readonly imports: ReadonlyMap; + /** The types its `declare global` blocks declare. */ + readonly globals: { readonly types: ReadonlyMap; readonly enums: ReadonlyMap }; readonly exports: readonly Statement[]; }; @@ -97,7 +101,10 @@ const scopeOf = (statements: readonly Statement[]) => { const types = new Map(); const namespaces = new Map(); const constants = new Map(); + const enums = new Map(); for (const statement of statements) { + const inner = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (inner?.type === "TSEnumDeclaration") enums.set(inner.id.name, inner); const declared = declarationOf(statement); if (declared?.type === "TSModuleDeclaration") { if (declared.id.type === "Identifier") namespaces.set(declared.id.name, declared); @@ -105,7 +112,7 @@ const scopeOf = (statements: readonly Statement[]) => { else if (declared !== null && declared.id !== null) types.set(declared.id.name, declared); for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); } - return { types, namespaces, constants }; + return { types, namespaces, constants, enums }; }; export const moduleOf = (file: string, code: string, program: TypedProgram): Module => { @@ -113,8 +120,10 @@ export const moduleOf = (file: string, code: string, program: TypedProgram): Mod for (const statement of program.body) { if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); } + // `declare global` blocks declare types every module names without importing them. + const globals = scopeOf(program.body.flatMap(statement => statement.type === "TSModuleDeclaration" && statement.global && statement.body !== null ? statement.body.body : [])); return { - file, code, program, ...scopeOf(program.body), imports, + file, code, program, ...scopeOf(program.body), imports, globals, exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration"), }; }; @@ -129,8 +138,8 @@ const positionOf = (code: string, offset: number): { readonly line: number; read /** The module being transformed, and where in it the type being converted is. */ type Site = { readonly root: string; at: number }; -/** A type found by name: where it's declared, and the namespaces it's declared in; or a global. */ -type Found = { readonly module: Module; readonly declaration: Declared; readonly within: readonly string[] } | "global"; +/** A type found by name: where it's declared, and the namespaces it's declared in; an enum; or a global. */ +type Found = { readonly module: Module; readonly declaration: Declared; readonly within: readonly string[] } | "enum" | "global"; /** A namespace found by name: a module, or a TypeScript namespace declared in one. */ type FoundNamespace = { readonly module: Module; readonly block: Namespace | null; readonly within: readonly string[] }; @@ -166,7 +175,8 @@ export const createConverter = (host: TypeHost, root: string) => { }; const moduleAt = async (module: Module, source: string): Promise => { - const file = await host.resolve(source, module.file); + // A specifier the build can't resolve names a module this pass can't read. + const file = await host.resolve(source, module.file).catch(() => null); return file === null ? null : await load(file); }; @@ -289,13 +299,35 @@ export const createConverter = (host: TypeHost, root: string) => { if (path.length > 1) return await throughNamespaces(module, path, memberType); const local = module.types.get(first); if (local !== undefined) return { module, declaration: local, within: [] }; + if (module.enums.has(first)) return "enum"; const imported = module.imports.get(first); - if (imported === undefined) return "global"; + if (imported === undefined) return await globalNamed(first, module, new Set()) ?? "global"; if (imported.imported === null) return null; const found = await fromSource(module, imported.source, imported.imported, typesOf, new Set()); - return found === null ? null : { module: found.module, declaration: found.declaration, within: [] }; + if (found !== null) return { module: found.module, declaration: found.declaration, within: [] }; + return await fromSource(module, imported.source, imported.imported, enumsOf, new Set()) === null ? null : "enum"; }; + /** + * A type `declare global` declares, in a module or the modules of the application it imports relatively; null + * where none does. Packages' globals, the platform's among them, are classes. + */ + const globalNamed = async (name: string, module: Module, seen: Set): Promise => { + if (seen.has(module.file)) return null; + seen.add(module.file); + const declaration = module.globals.types.get(name); + if (declaration !== undefined) return { module, declaration, within: [] }; + if (module.globals.enums.has(name)) return "enum"; + for (const statement of module.program.body) { + if (statement.type !== "ImportDeclaration" || !statement.source.value.startsWith(".")) continue; + const target = await moduleAt(module, statement.source.value); + const found = target === null || host.packagePath(target.file) !== null ? null : await globalNamed(name, target, seen); + if (found !== null) return found; + } + return null; + }; + const enumsOf = (module: Module): ReadonlyMap => module.enums; + /** The constant `path` names in `module`, through namespaces it imports. */ const constantNamed = async (path: readonly string[], module: Module): Promise<{ readonly module: Module; readonly declaration: Declarator } | null> => { const [first] = path; @@ -309,7 +341,7 @@ export const createConverter = (host: TypeHost, root: string) => { /** The package's own declaration a found type is, by its path in the package: `Schema.Type` and `Entity`. */ const intrinsic = (found: Found): "schema" | "entity" | null => { - if (found === "global") return null; + if (typeof found === "string") return null; const path = host.packagePath(found.module.file); const { declaration, within } = found; if (declaration.id === null) return null; @@ -341,7 +373,7 @@ export const createConverter = (host: TypeHost, root: string) => { seen.add(declaration); const found = await typeNamed([superClass.name], module); if (found === "global") return domNode.test(superClass.name); - if (found === null || found.declaration.type === "TSTypeAliasDeclaration" || found.declaration.type === "TSInterfaceDeclaration") return false; + if (found === null || found === "enum" || found.declaration.type === "TSTypeAliasDeclaration" || found.declaration.type === "TSInterfaceDeclaration") return false; return await isNode(found.declaration, found.module, seen); }; @@ -432,7 +464,7 @@ export const createConverter = (host: TypeHost, root: string) => { return optional ? { type: "object", properties, required } : { type: "object", properties }; }; - const declared = async (found: Exclude, active: ReadonlySet, at: Node, module: Module, use: Use): Promise => { + const declared = async (found: Exclude, active: ReadonlySet, at: Node, module: Module, use: Use): Promise => { const { declaration } = found; const { a } = words(use); const name = declaration.id?.name ?? "default"; @@ -453,7 +485,8 @@ export const createConverter = (host: TypeHost, root: string) => { const named = async (path: readonly string[], module: Module, active: ReadonlySet, at: Node, use: Use): Promise => { const found = await typeNamed(path, module); if (found === null) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${path.join(".")} isn't a type alias, interface or class its module declares or exports, which this pass can read`); - // Neither declared nor imported, it's a global, which TypeScript checked exists: a class, such as the platform's. + if (found === "enum") return fail(module, at, `${path.join(".")} is an enum, which isn't ${words(use).a}: use a union of literals, or the values of an \`as const\` object`); + // Declared nowhere, it's a global, which TypeScript checked exists: a class, such as the platform's. if (found === "global") return notData(use, path.length === 1 && domNode.test(path[0] ?? "")); if (intrinsic(found) === "entity") return { type: "integer", format: "entity" }; return await declared(found, active, at, module, use); @@ -510,7 +543,7 @@ export const createConverter = (host: TypeHost, root: string) => { case "TSTypeReference": { if (type.typeArguments !== null) return unreadable(); const found = await typeNamed(nameParts(type.typeName), module); - if (found === null || found === "global" || found.declaration.type !== "TSTypeAliasDeclaration" || active.has(found.declaration)) return unreadable(); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || active.has(found.declaration)) return unreadable(); return await valueOfType(found.declaration.typeAnnotation, found.module, new Set(active).add(found.declaration)); } case "TSTypeQuery": { @@ -601,7 +634,7 @@ export const createConverter = (host: TypeHost, root: string) => { return { anyOf: values.map(value => ({ const: value })) }; }; - return { convert, read, site, fail, constantNamed, valueOfType }; + return { convert, read, site, fail, typeNamed, constantNamed, valueOfType }; }; /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ diff --git a/packages/ecs/src/implementation/world/ids-again.test-helper.ts b/packages/ecs/src/implementation/world/ids-again.test-helper.ts deleted file mode 100644 index f98e75c6..00000000 --- a/packages/ecs/src/implementation/world/ids-again.test-helper.ts +++ /dev/null @@ -1,27 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Rule } from "../../interface/rule/rule.js"; - -// `ids.test.ts`'s keys module, evaluated again: new symbols under the ids of its keys, which the build registers once -// a test imports it. -export const scoreAgain = Symbol("score"); -export const shownAgain = Symbol("shown"); - -identify(scoreAgain, "worldIdsTest/keys.ts#score"); -identify(shownAgain, "worldIdsTest/keys.ts#shown"); - -declare module "@adobe/ecs" { - export interface Components { - [scoreAgain]: { type: number }; - [shownAgain]: { type: true; derived: true }; - } -} - -/** The module's rule and finalizer, evaluated again, the finalizer recording into `changes`. */ -export const evaluateAgain = (changes: (readonly symbol[])[]): void => { - Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [scoreAgain] }, derive: [shownAgain] }, "worldIdsTest/systems.ts#shown")); - Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [scoreAgain] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { - for (const group of groups) changes.push(group.added); - }); -}; diff --git a/packages/ecs/src/implementation/world/ids-changes.test-helper.ts b/packages/ecs/src/implementation/world/ids-changes.test-helper.ts new file mode 100644 index 00000000..e77d98f9 --- /dev/null +++ b/packages/ecs/src/implementation/world/ids-changes.test-helper.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** The components each change set added, which every evaluation of `ids-keys.test-helper.ts` records into. */ +export const changes: (readonly symbol[])[] = []; diff --git a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts new file mode 100644 index 00000000..e28240aa --- /dev/null +++ b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { changes } from "./ids-changes.test-helper.js"; + +// A module of keys, a rule and a finalizer, which `ids.test.ts` evaluates again: each evaluation makes new symbols and +// values under the same ids. +export const score = Symbol("score"); +export const shown = Symbol("shown"); + +declare module "@adobe/ecs" { + export interface Components { + [score]: { type: number }; + [shown]: { type: true; derived: true }; + } +} + +export const showing = Rule.create({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }); +export const recording = Finalizer.changes({ name: "worldIdsTest/changes", watch: [score] }, ({ groups }) => { + for (const group of groups) changes.push(group.added); +}); diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts index 09d74a6c..a61eaeaa 100644 --- a/packages/ecs/src/implementation/world/ids.test.ts +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -1,52 +1,31 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import type { Entity } from "../../interface/entity/entity.js"; -import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { identify } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; -import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import { changes } from "./ids-changes.test-helper.js"; +import { score, shown } from "./ids-keys.test-helper.js"; import { read } from "./reads.test-helper.js"; -// A module's keys, which `ids-again.test-helper.ts` makes again under the same ids: one id, two symbols each. -const score = Symbol("score"); -const shown = Symbol("shown"); - -identify(score, "worldIdsTest/keys.ts#score"); -identify(shown, "worldIdsTest/keys.ts#shown"); - -declare module "@adobe/ecs" { - export interface Components { - [score]: { type: number }; - [shown]: { type: true; derived: true }; - } -} - -Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }, "worldIdsTest/systems.ts#shown")); -const changes: (readonly symbol[])[] = []; -Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [score] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { - for (const group of groups) changes.push(group.added); -}); - -const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); const spawned: Entity[] = []; +const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); describe("a world given keys with ids", () => { it("stores the symbols of one id as one component, giving values by the newest", async () => { const world = createWorld({ [workers]: 0 }); await world.run(spawn); - // The module evaluated again, after the world met its first keys. - const again = await import("./ids-again.test-helper.js"); - again.evaluateAgain(changes); - const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([again.scoreAgain])({ [again.scoreAgain]: 2 })); }); - const add = System.create({ name: "worldIdsTest/add", components: { write: [again.scoreAgain] } }, row => { row[again.scoreAgain] += 10; }); + // The keys' module evaluated again, after the world met its first keys: a new module by its query, its ids the same. + const again: typeof import("./ids-keys.test-helper.js") = await import(/* @vite-ignore */ `./ids-keys.test-helper.js?again=${Date.now()}`); + expect(again.score).not.toBe(score); + const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([again.score])({ [again.score]: 2 })); }); + const add = System.create({ name: "worldIdsTest/add", components: { write: [again.score] } }, row => { row[again.score] += 10; }); await world.run(spawnAgain); await world.run(add); const [first, second] = spawned; - expect([await read(world, first, score), await read(world, first, again.scoreAgain), await read(world, second, score)]).toEqual([11, 11, 12]); - expect([await read(world, first, again.shownAgain), await read(world, second, shown)]).toEqual([true, true]); - expect(changes).toEqual([[score], [again.scoreAgain]]); + expect([await read(world, first, score), await read(world, first, again.score), await read(world, second, score)]).toEqual([11, 11, 12]); + expect([await read(world, first, again.shown), await read(world, second, shown)]).toEqual([true, true]); + expect(changes).toEqual([[score], [again.score]]); await world.dispose(); }); }); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index c88bf064..56430e8a 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { type I32, type U32, Vec3 } from "../../interface/math/index.js"; -import { declareResource } from "../../interface/registry/declare.js"; +import { declareResource$ } from "../../interface/registry/declare.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { readResource } from "./reads.test-helper.js"; @@ -234,7 +234,7 @@ describe("reductions", () => { it("reject what can't be reduced", async () => { // Booleans reduce bitwise. - expect(() => declareResource(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); + expect(() => declareResource$(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error a contributor repeats its resource's operator expect(() => System.create({ name: "reductionsTest/other", components: { has: [mass] }, reduce: { [total]: "max" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total" with "max", but it's declared reduce: "+"`); // @ts-expect-error only a resource with a reduce trait is reduced diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index 822baaca..2f71b70d 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -8,7 +8,7 @@ import { createWorld } from "./create.js"; import { count, read } from "./reads.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { declareComponent } from "../../interface/registry/declare.js"; +import { declareComponent$ } from "../../interface/registry/declare.js"; import { labelOfItem, programRules } from "../../interface/registry/index.js"; const source = Symbol("rulesTest/source"); @@ -21,6 +21,7 @@ const glow = Symbol("rulesTest/glow"); const owned = Symbol("rulesTest/owned"); const follows = Symbol("rulesTest/follows"); const mark = Symbol("rulesTest/mark"); +// Within each other, so never declared: the second to register closes a cycle. const left = Symbol("rulesTest/left"); const right = Symbol("rulesTest/right"); const late = Symbol("rulesTest/late"); @@ -39,7 +40,6 @@ declare module "@adobe/ecs" { [owned]: { type: Entity; reference: "owner" }; [follows]: { type: Entity; reference: "weak" }; [mark]: { type: number }; - [left]: { type: true; within: typeof right }; [late]: { type: true; derived: true }; [tagged]: { type: true }; [tag]: { type: true; derived: true }; @@ -145,7 +145,8 @@ describe("rules", () => { // @ts-expect-error a reference changes only through commands expect(() => System.create({ name: "rulesTest/writeReference", components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. - expect(() => declareComponent(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); + declareComponent$(left, { type: { const: true }, within: right }); + expect(() => declareComponent$(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); }); }); diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 5f97809e..2fc22f66 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -367,7 +367,7 @@ export const idOf = (state: State, key: Key): number => { return id; } const declaration = declarationOf(key); - if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); const { structure } = state; // A host gets its id first, so stratifying with the new edge can name it; a cycle throws before anything is // recorded. diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 110857a9..41ab5ad5 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -147,7 +147,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return known; } const declaration = declarationOf(key); - if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); if (declaration.derived === true) throw new Error(`"${labelOf(key)}" is derived, so only rules add and remove it.`); const layout = layoutOf(declaration.type); const own = ownObject(key); @@ -705,7 +705,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const threadIndex = createThreadIndex(host.rows, host.archetypeAt, host.replaced, { layouts: index.declaration.key.map(key => { const layout = host.layoutOf(key); - if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); return layout; }), sorted: keptSorted(index.declaration), diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index 5287f0e5..baf7d825 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; -import { declareComponent, declareTrait } from "../registry/declare.js"; +import { declareComponent$, declareTrait$ } from "../registry/declare.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; const parent = Symbol("typesComponentTest/parent"); @@ -35,7 +35,7 @@ declare module "@adobe/ecs" { } /** Registers a declaration as the build does, which a test may write wrong. */ -const register = (key: unknown, declaration: object): void => declareComponent(key, declaration); +const register = (key: unknown, declaration: object): void => declareComponent$(key, declaration); describe("registering a component", () => { it("records the declaration, and its thread: none for a struct or a string, else its own or \"world\"", () => { @@ -57,7 +57,7 @@ describe("registering a component", () => { expect(declarationOf(count)).toEqual({ type: { type: "number" } }); const claimed = Symbol("typesComponentTest/claimed"); register(claimed, { type: { const: true } }); - expect(() => declareTrait(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); + expect(() => declareTrait$(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); expect(kindOf(count)).toBe("component"); }); it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { @@ -101,10 +101,10 @@ describe("registering a component", () => { it("takes traits other packages register, in the namespace components share", () => { const early = Symbol("typesComponentTest/early"); expect(() => register(early, { type: { const: true }, [late]: true })).toThrow(`typesComponentTest/late isn't a registered trait`); - declareTrait(late); + declareTrait$(late); expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); - expect(() => declareTrait(mark)).not.toThrow(); + expect(() => declareTrait$(mark)).not.toThrow(); expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); }); }); diff --git a/packages/ecs/src/interface/component/types.ts b/packages/ecs/src/interface/component/types.ts index 0fdecb25..80355444 100644 --- a/packages/ecs/src/interface/component/types.ts +++ b/packages/ecs/src/interface/component/types.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * Each component's traits, `type` included, by augmentation: `[position]: { type: Vec3 }`, - * `[slot]: { type: Slot; derived: true }`. Registration must give exactly these. + * Each component's traits, `type` and its `default` included, by augmentation in the module declaring its key: + * `[position]: { type: Vec3 }`, `[slot]: { type: Slot; derived: true; default: 0 }`. The build registers each. */ export interface Components { } diff --git a/packages/ecs/src/interface/registry/declare.ts b/packages/ecs/src/interface/registry/declare.ts index 77847d49..8d58ce9c 100644 --- a/packages/ecs/src/interface/registry/declare.ts +++ b/packages/ecs/src/interface/registry/declare.ts @@ -2,17 +2,20 @@ import { createRegistry, type AnyDeclaration } from './declarations.js'; import { addTrait } from './traits.js'; +/* + * The registry's hooks for code the plugin generates, which `@adobe/ecs/declare` exports: the plugin registers each + * key's declaration in `Components`, `Resources` and `Traits` through them, after the key. They're named with `$`, as + * the plugin's other generated names are, since consumers never call them. + */ + const components = createRegistry("component"); const resources = createRegistry("resource"); -/** - * Registers a component as the build read it from its declaration in `Components`: the plugin emits a call after each - * declaration, so consumers never register keys themselves. - */ -export const declareComponent = (key: unknown, declaration: AnyDeclaration): void => components(key, declaration); +/** Registers a component as the build read it from its declaration in `Components`. */ +export const declareComponent$ = (key: unknown, declaration: AnyDeclaration): void => components(key, declaration); /** Registers a resource as the build read it from its declaration in `Resources`. */ -export const declareResource = (key: unknown, declaration: AnyDeclaration): void => resources(key, declaration); +export const declareResource$ = (key: unknown, declaration: AnyDeclaration): void => resources(key, declaration); /** Registers a trait of a consumer's own, keyed by a symbol, as the build read it from `Traits`. */ -export const declareTrait = (key: unknown): void => addTrait(key); +export const declareTrait$ = (key: unknown): void => addTrait(key); diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index 2019d70f..0a625270 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { Finalizer } from "../finalizer/finalizer.js"; import { Rule } from "../rule/rule.js"; import { System } from "../system/system.js"; -import { declareComponent, declareResource, declareTrait } from "./declare.js"; +import { declareComponent$, declareResource$, declareTrait$ } from "./declare.js"; import { identify } from "./ids.test-helper.js"; import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; @@ -36,82 +36,75 @@ identify(crossedAgain, "idsTest/keys.ts#crossed"); identify(tagged, "idsTest/keys.ts#tagged"); identify(taggedAgain, "idsTest/keys.ts#tagged"); -declare module "@adobe/ecs" { - export interface Components { - [score]: { type: number }; - [scoreAgain]: { type: number }; - [shown]: { type: true; derived: true }; - [shownAgain]: { type: true; derived: true }; - [changed]: { type: number }; - [crossed]: { type: number }; - } - export interface Resources { - [level]: { type: number; default: 0 }; - [levelAgain]: { type: number; default: 0 }; - } - export interface Traits { - readonly [tagged]: true; - readonly [taggedAgain]: true; - } -} +// The keys' ids are given by hand, after the keys are made, so they're registered here rather than declared, which +// registers a key as soon as it's made. +declareComponent$(score, { type: { type: "number" } }); +declareComponent$(shown, { type: { const: true }, derived: true }); +declareResource$(level, { type: { type: "number", default: 0 } }); +declareComponent$(changed, { type: { type: "number" } }); +declareComponent$(crossed, { type: { type: "number" } }); +declareTrait$(tagged); -// Keys registered late, or wrongly, aren't declared, so the build doesn't register them. -/** Creates a system naming keys no declaration types. */ -const untypedSystem = (declaration: object, body: (row: { [key: symbol]: number }) => void): unknown => Reflect.apply(System.create, undefined, [declaration, body]); +/** Calls a constructor as an untyped caller would, naming keys no declaration types. */ +const untyped = unknown>(create: F) => (...parameters: unknown[]): ReturnType => Reflect.apply(create, undefined, parameters); +const system = untyped(System.create); +const rule = untyped(Rule.create); +const finalizer = untyped(Finalizer.create); +type Row = { [key: symbol]: number }; describe("keys with ids", () => { it("labels a key by its description, and by its id once another registered key has its description", () => { expect(labelOf(score)).toBe("score"); - declareComponent(unnamed, { type: { type: "number" } }); + declareComponent$(unnamed, { type: { type: "number" } }); expect([labelOf(score), labelOf(unnamed)]).toEqual(["idsTest/keys.ts#score", "idsTest/other.ts#score"]); expect(labelOf(Symbol("score"))).toBe("score"); }); it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { - declareComponent(scoreAgain, { type: { type: "number" } }); - declareComponent(shownAgain, { type: { const: true }, derived: true }); - declareResource(levelAgain, { type: { type: "number", default: 0 } }); - declareTrait(taggedAgain); + declareComponent$(scoreAgain, { type: { type: "number" } }); + declareComponent$(shownAgain, { type: { const: true }, derived: true }); + declareResource$(levelAgain, { type: { type: "number", default: 0 } }); + declareTrait$(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); expect(labelOf(scoreAgain)).toBe("idsTest/keys.ts#score"); }); it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { - expect(() => declareComponent(changedAgain, { type: { type: "string" } })).toThrow( + expect(() => declareComponent$(changedAgain, { type: { type: "string" } })).toThrow( `Cannot register component "idsTest/keys.ts#changed": its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); - expect(() => declareResource(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( + expect(() => declareResource$(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( `Cannot register resource "idsTest/keys.ts#crossed": its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); it("keeps keys of different ids apart, though their descriptions match", () => { - declareComponent(unnamed, { type: { type: "number" } }); - System.create(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), row => { row[score] = 1; }); - expect(() => untypedSystem(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), row => { row[unnamed] = 1; })).toThrow("reload the page"); + declareComponent$(unnamed, { type: { type: "number" } }); + system(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[score] = 1; }); + expect(() => system(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); }); }); describe("declarations evaluated again", () => { it("accept a system whose keys match by id, as a new system", () => { - const first = System.create(identify({ name: "idsTest/system", components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[score] = resources[level]; }); - const again = System.create(identify({ name: "idsTest/system", components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[scoreAgain] = resources[levelAgain]; }); + const first = system(identify({ name: "idsTest/system", components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); + const again = system(identify({ name: "idsTest/system", components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); expect(again).not.toBe(first); expect(sameItem(again, first)).toBe(true); }); it("accept a rule whose keys match by id, which is already recorded", () => { - const rule = Rule.create(identify({ name: "idsTest/rule", components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); + const created = rule(identify({ name: "idsTest/rule", components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); const rules = programRules().length; - expect(Rule.create(identify({ name: "idsTest/rule", components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(rule); + expect(rule(identify({ name: "idsTest/rule", components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); expect(programRules()).toHaveLength(rules); }); it("accept a finalizer whose keys match by id, keeping its place with the new body", () => { - const first = Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); + const first = finalizer(identify({ name: "idsTest/finalizer", components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); const body = (): void => {}; - expect(Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); + expect(finalizer(identify({ name: "idsTest/finalizer", components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); const found = programFinalizers().find(finalizer => finalizer.name === "idsTest/finalizer"); expect(found?.kind === "finalizer" ? found.body : null).toBe(body); }); it("schedule symbols of one id as one key", () => { - const write = System.create({ name: "idsTest/write", components: { write: [score] } }, row => { row[score] = 1; }); - const read = System.create({ name: "idsTest/read", components: { read: [scoreAgain] } }, row => { void row[scoreAgain]; }); - expect(() => scheduleOf(requireDefinition(System.create({ name: "idsTest/unordered", systems: [write, read] })))).toThrow("both access"); + const write = system({ name: "idsTest/write", components: { write: [score] } }, (row: Row) => { row[score] = 1; }); + const read = system({ name: "idsTest/read", components: { read: [scoreAgain] } }, (row: Row) => { void row[scoreAgain]; }); + expect(() => scheduleOf(requireDefinition(system({ name: "idsTest/unordered", systems: [write, read] })))).toThrow("both access"); }); }); diff --git a/packages/ecs/src/interface/registry/threads.ts b/packages/ecs/src/interface/registry/threads.ts index 1b36d46a..c8bc9c93 100644 --- a/packages/ecs/src/interface/registry/threads.ts +++ b/packages/ecs/src/interface/registry/threads.ts @@ -28,7 +28,7 @@ export type Placed = { export const requireRegistered = (what: "System" | "Finalizer", name: string, kind: "component" | "resource", keys: readonly Key[] | undefined): void => { for (const key of keys ?? []) { if (declarationOf(key) === undefined || kindOf(key) !== kind) { - throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}; import the module that registers it.`); + throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}: declare it beside its key, in a module the plugin builds.`); } } }; diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index 4a433bf8..75ce4b21 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareComponent } from '../registry/declare.js'; +import { declareComponent$ } from '../registry/declare.js'; import type { Failure, Warning } from './failure.js'; /** @@ -38,7 +38,7 @@ declare module '@adobe/ecs' { const text = { type: "string" } as const; const count = { type: "integer", minimum: 0 } as const; -declareComponent(run, { +declareComponent$(run, { type: { type: "object", properties: { @@ -47,7 +47,7 @@ declareComponent(run, { }, }, }); -declareComponent(errors, { +declareComponent$(errors, { type: { type: "object", properties: { diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index b55b438e..d159d36a 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { declareComponent, declareResource } from "../registry/declare.js"; +import { declareComponent$, declareResource$ } from "../registry/declare.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; import { always, cadence, clock, gpuDevice, wake, workers } from "../resources/index.js"; @@ -30,7 +30,7 @@ declare module "@adobe/ecs" { } /** Registers a declaration as the build does, which a test may write wrong. */ -const register = (key: unknown, declaration: object): void => declareResource(key, declaration); +const register = (key: unknown, declaration: object): void => declareResource$(key, declaration); const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; @@ -50,7 +50,7 @@ describe("registering a resource", () => { expect(() => register(time, { type: { type: "number", default: 0 } })).not.toThrow(); expect(() => register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); const shared = Symbol("typesResourceTest/shared"); - declareComponent(shared, { type: { const: true } }); + declareComponent$(shared, { type: { const: true } }); expect(() => register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); }); it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { diff --git a/packages/ecs/src/interface/resource/types.ts b/packages/ecs/src/interface/resource/types.ts index 04cd4fb4..fa425337 100644 --- a/packages/ecs/src/interface/resource/types.ts +++ b/packages/ecs/src/interface/resource/types.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/** Each resource's traits, `type` included, by augmentation: `[time]: { type: number }`. Registration must give exactly these. */ +/** + * Each resource's traits, `type` and its `default` included, by augmentation in the module declaring its key: + * `[time]: { type: number; default: 0 }`. The build registers each. + */ export interface Resources { } diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index f64f395c..52f95457 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ export const always = Symbol("always"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -declareResource(always, { type: { type: "boolean", default: false } }); +declareResource$(always, { type: { type: "boolean", default: false } }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index 0fcc6823..dd83e2b8 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** The cadence's value, for the implementation: `Resource.Value`. */ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; @@ -17,7 +17,7 @@ declare module '@adobe/ecs' { } } -declareResource(cadence, { +declareResource$(cadence, { type: { anyOf: [ { type: "object", properties: { kind: { const: "frame" } } }, diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index 7b12e2ab..ce591a61 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the @@ -13,6 +13,6 @@ declare module '@adobe/ecs' { } } -declareResource(clock, { +declareResource$(clock, { type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, }); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts index 7da5ca16..28c1c524 100644 --- a/packages/ecs/src/interface/resources/gpu-device.ts +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world @@ -14,4 +14,4 @@ declare module '@adobe/ecs' { } } -declareResource(gpuDevice, {}); +declareResource$(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index 9ab07f0c..1cc9bdfa 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity @@ -13,4 +13,4 @@ declare module '@adobe/ecs' { } } -declareResource(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +declareResource$(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index 932d277e..983fcec0 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource } from '../registry/declare.js'; +import { declareResource$ } from '../registry/declare.js'; /** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ export const workers = Symbol("workers"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -declareResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +declareResource$(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); diff --git a/packages/ecs/src/interface/thread/thread.type-test.ts b/packages/ecs/src/interface/thread/thread.type-test.ts index 25bc8d97..89397148 100644 --- a/packages/ecs/src/interface/thread/thread.type-test.ts +++ b/packages/ecs/src/interface/thread/thread.type-test.ts @@ -22,63 +22,39 @@ void undeclared; const label = Symbol("threadTypeTest/label"); const path = Symbol("threadTypeTest/path"); const handle = Symbol("threadTypeTest/handle"); -const placedLabel = Symbol("threadTypeTest/placedLabel"); -const unknownThread = Symbol("threadTypeTest/unknownThread"); const position = Symbol("threadTypeTest/position"); -const placedPosition = Symbol("threadTypeTest/placedPosition"); -const placedTag = Symbol("threadTypeTest/placedTag"); const element = Symbol("threadTypeTest/element"); -const unplacedElement = Symbol("threadTypeTest/unplacedElement"); -const workerElement = Symbol("threadTypeTest/workerElement"); const maybeElement = Symbol("threadTypeTest/maybeElement"); const bitmap = Symbol("threadTypeTest/bitmap"); const buffer = Symbol("threadTypeTest/buffer"); -const notTransferable = Symbol("threadTypeTest/notTransferable"); const canvas = Symbol("threadTypeTest/canvas"); const cache = Symbol("threadTypeTest/cache"); -const count = Symbol("threadTypeTest/count"); const port = Symbol("threadTypeTest/port"); -const text = Symbol("threadTypeTest/text"); const names = Symbol("threadTypeTest/names"); -const mode = Symbol("threadTypeTest/mode"); -const caption = Symbol("threadTypeTest/caption"); declare module '@adobe/ecs' { export interface Components { // Objects: on "world" without `thread`, or on the thread they name. [path]: { type: readonly number[]; thread: "threadTypeTest" }; [handle]: { type: Map; thread: "world" }; - [unknownThread]: { type: readonly number[]; thread: "elsewhere" }; - // Strings and structs live in shared memory, so they have no thread. + // Strings and structs live in shared memory, so they have no thread; World.create rejects one with it. [label]: { type: string }; - [placedLabel]: { type: string; thread: "main" }; [position]: { type: readonly [number, number, number] }; - [placedPosition]: { type: readonly [number, number, number]; thread: "main" }; - [placedTag]: { type: true; thread: "world" }; - // A DOM Node lives on "main", and says so. + // A DOM Node lives on "main", and says so; the build rejects one without it. [element]: { type: HTMLElement; thread: "main" }; - [unplacedElement]: { type: HTMLElement }; - [workerElement]: { type: HTMLElement; thread: "threadTypeTest" }; [maybeElement]: { type: HTMLCanvasElement | null; thread: "main" }; // Transferable values may be moved, never copied, to their thread. [bitmap]: { type: ImageBitmap; thread: "threadTypeTest"; transfer: true }; [buffer]: { type: ArrayBuffer; transfer: true }; - [notTransferable]: { type: Map; transfer: true }; } export interface Resources { [canvas]: { type: OffscreenCanvas | null; thread: "threadTypeTest"; transfer: true }; [cache]: { type: Map | null; thread: "main" }; - [count]: { type: number; thread: "main" }; [port]: { type: MessagePort | null; transfer: true }; - [text]: { type: string; transfer: true }; [names]: { type: Map; thread: "main" }; - [mode]: { type: "edit" | "view"; thread: "main" }; - [caption]: { type: string | null; thread: "main" }; } } - - check, readonly number[]>>(); check, never>>(); check, never>>(); diff --git a/packages/ecs/src/interface/trait/types.ts b/packages/ecs/src/interface/trait/types.ts index f2675bc0..165e0d47 100644 --- a/packages/ecs/src/interface/trait/types.ts +++ b/packages/ecs/src/interface/trait/types.ts @@ -20,7 +20,8 @@ type Structs = { readonly [K in Component.Key | Resource.Key as K]: IsString World; +export type Create = (resources?: InitialResources) => [InvalidDeclarations] extends [never] + ? World + : { readonly "declarations give traits values their types don't allow": { readonly [D in InvalidDeclarations as D["key"]]: D["members"] } }; diff --git a/packages/ecs/src/interface/world/declarations.ts b/packages/ecs/src/interface/world/declarations.ts new file mode 100644 index 00000000..48118074 --- /dev/null +++ b/packages/ecs/src/interface/world/declarations.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component } from '../component/component.js'; +import type { Components } from '../component/types.js'; +import type { Resource } from '../resource/resource.js'; +import type { Resources } from '../resource/types.js'; +import type { Key as TraitKey } from '../trait/key.js'; +import type { Traits } from '../trait/types.js'; + +/** + * The members of key `K`'s declaration `Entry` that don't hold: a trait whose value `Traits` doesn't allow, a name + * that isn't a trait, and a `default` that isn't of the declared type. Checks only `K`'s own traits, so the whole check + * is linear in the declarations. + */ +type Invalid = + | { readonly [T in Exclude]-?: T extends TraitKey ? ([Entry[T]] extends [Traits[T]] ? never : T) : T }[Exclude] + | (Entry extends { readonly default: infer D } ? ([D] extends [Value] ? never : "default") : never); + +// `infer` resolves the members, so errors print them rather than the alias that computes them. +type Reported = [Members] extends [never] ? never : Members extends infer Resolved ? { readonly key: K; readonly members: Resolved } : never; + +/** + * Every declaration in `Components` and `Resources` with members that don't hold, each as its key and those members; + * `never` when all hold. The build registers declarations without a type checker, so `World.create` reports these. + */ +export type InvalidDeclarations = + | { readonly [K in Component.Key]: Reported>> }[Component.Key] + | { readonly [K in Resource.Key]: Reported>> }[Resource.Key]; From b1ea504d0de68a96c4798b36a014d58ac8bc346f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 17:03:03 -0700 Subject: [PATCH 055/127] ecs: the build's plugin is Compiler.plugin, the one export of @adobe/ecs/compiler Co-Authored-By: Claude Opus 5.5 --- packages/ecs/README.md | 10 +++++++ packages/ecs/design.md | 4 +-- packages/ecs/src/guide/guide.md | 6 ++--- .../src/implementation/compiler/compiler.ts | 9 +++++++ .../ecs/src/implementation/compiler/index.ts | 2 +- .../implementation/compiler/plugin.test.ts | 26 +++++++++---------- .../ecs/src/implementation/compiler/plugin.ts | 16 ++++++------ .../ecs/src/implementation/compiler/public.ts | 2 ++ .../ecs/src/implementation/world/runner.ts | 2 +- packages/ecs/vite.perf.config.ts | 4 +-- packages/ecs/vite.sample.config.ts | 4 +-- packages/ecs/vitest.config.ts | 8 +++--- packages/ecs/vitest.fixtures.ts | 4 +-- packages/ecs/vitest.perf.config.ts | 6 ++--- 14 files changed, 62 insertions(+), 41 deletions(-) create mode 100644 packages/ecs/src/implementation/compiler/compiler.ts create mode 100644 packages/ecs/src/implementation/compiler/public.ts diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 40d2cac1..7b29e4dc 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -38,6 +38,16 @@ A high-performance, multithreaded Entity Component System for web-based applicat - Kernels allocate nothing per row; commands, lookups and reports reuse their buffers - Errors reported as data, in each run's report +## Setup + +```ts +// vite.config.ts +import { Compiler } from "@adobe/ecs/compiler"; +export default defineConfig({ plugins: [Compiler.plugin()] }); +``` + +A library builds with `Compiler.plugin({ library: true })`, so its build ships the registrations its declarations give. + ## Guide See the [guide](src/guide/guide.md) for how to use it. diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 20043dd8..55d0fc7f 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -43,9 +43,9 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `kernelPlugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. + - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `kernelPlugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. + - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. - On registration, workers `import()` each kernel and cache it by the world's index for its module's url, so new values of one kernel, as a module evaluated again makes, share one entry and workers' caches are bounded by code. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index af10c913..d1ae4254 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -37,15 +37,15 @@ How to use the ECS. Each excerpt comes from a test in this folder; the link unde ```ts // vite.config.ts: compiles kernels, checks each system's declaration against its body, reads the schema of each // body's input from its type, and gives keys and systems ids. -import { kernelPlugin } from "@adobe/ecs/compiler"; -export default defineConfig({ plugins: [kernelPlugin()] }); +import { Compiler } from "@adobe/ecs/compiler"; +export default defineConfig({ plugins: [Compiler.plugin()] }); ``` Each component and resource is declared once, in the module that makes its key, by augmenting `Components` or `Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its precision or width. Traits of your own augment `Traits`. A library builds with Vite and -`kernelPlugin({ library: true })`, so its build ships its registrations. The examples create worlds with +`Compiler.plugin({ library: true })`, so its build ships its registrations. The examples create worlds with `World.create()`. ## Basics diff --git a/packages/ecs/src/implementation/compiler/compiler.ts b/packages/ecs/src/implementation/compiler/compiler.ts new file mode 100644 index 00000000..9e2f5b06 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/compiler.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Compiler = never; + +/** + * The build's compiler: `Compiler.plugin()` in an application's Vite config, `Compiler.plugin({ library: true })` in a + * library's. + */ +export * as Compiler from './public.js'; diff --git a/packages/ecs/src/implementation/compiler/index.ts b/packages/ecs/src/implementation/compiler/index.ts index ce6096c0..e14f41d6 100644 --- a/packages/ecs/src/implementation/compiler/index.ts +++ b/packages/ecs/src/implementation/compiler/index.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './plugin.js'; +export { Compiler } from './compiler.js'; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 9366ad98..88c8b161 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, expect, test } from 'vitest'; import { build, createServer, parseAst, transformWithOxc, type Rollup, type ViteDevServer } from 'vite'; import type { KernelDescription, KernelModule } from '../world/kernel.js'; -import { kernelPlugin } from './plugin.js'; +import { plugin } from './plugin.js'; const source = (path: string) => JSON.stringify(fileURLToPath(new URL(path, import.meta.url))); const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); @@ -196,7 +196,7 @@ const runs = (module: KernelModule, description: KernelDescription): readonly nu test("a build emits each kernel as its own chunk, referenced by the description's url", async () => { const outDir = join(directory, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -213,7 +213,7 @@ test("a build emits each kernel as its own chunk, referenced by the description' const serve = async (use: (server: ViteDevServer) => Promise): Promise => { const server = await createServer({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, }); try { @@ -226,7 +226,7 @@ const serve = async (use: (server: ViteDevServer) => Promise): Promise test("a build gives each kernel the url of its own chunk", async () => { const outDir = join(directory, "dist-pair"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: pair, formats: ["es"], fileName: "main" } }, }); const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -275,7 +275,7 @@ test("serve rejects a url for a different version of the kernel", async () => { test("a build inlines functions from TypeScript modules and packages", async () => { const outDir = join(directory, "dist-linked"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: linked, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -340,7 +340,7 @@ export const description = kernelOf(kernel); `); const outDir = join(project, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, }); const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -366,7 +366,7 @@ declare module "@adobe/ecs" { writeFileSync(application, `export { speed } from "./keys.ts";\n`); const outDir = join(project, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -395,7 +395,7 @@ export const slow = System.create({ name: "slow", kernel: "cpu", components: { w `); const outDir = join(project, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin({ library: true })], + configFile: false, logLevel: "silent", root: project, plugins: [plugin({ library: true })], build: { outDir, minify: false, lib: { entry, formats: ["es"], fileName: "index" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, }); const code = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -447,7 +447,7 @@ const idsIn = (code: string): readonly string[] => [...code.matchAll(/identify\$ test("a build gives top-level symbols ids naming no absolute path, also in workers", async () => { const outDir = join(directory, "dist-identified"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, rolldownOptions: { input: identified, preserveEntrySignatures: "strict" } }, }); const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); @@ -465,7 +465,7 @@ test("a build gives top-level symbols ids naming no absolute path, also in worke test("a second instance of the plugin in one bundle does nothing, as when an application also lists it for workers", async () => { const outDir = join(directory, "dist-twice"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin(), kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin(), plugin()], build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -480,7 +480,7 @@ test("a second instance of the plugin in one bundle does nothing, as when an app test("a build takes a name from a package external to it as one key, and registration rejects a list there", async () => { const outDir = join(directory, "dist-external"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, rolldownOptions: { input: [externalKey, externalList], external: ["external-keys"], preserveEntrySignatures: "strict" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -504,7 +504,7 @@ export const kernel = System.create({ name: "copied", kernel: "cpu", components: export const spawner = System.create({ name: "copiedSpawner", components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); const server = await createServer({ - configFile: false, logLevel: "silent", root: project, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, }); try { @@ -544,7 +544,7 @@ test("a build fails at a system's input whose type can't be one", async () => { const entry = join(directory, "bad-input.ts"); writeFileSync(entry, `import { System } from ${source("../../interface/system/system.ts")};\nexport const bad = System.create({}, (_context, input: { readonly when: Big["when"] }) => { void input; });\n`); await expect(build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir: join(directory, "dist-bad-input"), minify: false, lib: { entry, formats: ["es"], fileName: "main" } }, })).rejects.toThrow("(typeof X)[keyof typeof X]"); }); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index d4ee5718..e5f863b9 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -121,11 +121,11 @@ const inputPlugin = (): Plugin => { }; /** The package's own files' syntax as last parsed, which every module reading the package's types shares. */ const parsed = new Map(); - const plugin: Plugin = { + const instance: Plugin = { name: `${packageName}/input`, enforce: "pre", configResolved: config => { - repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; + repeated = config.plugins.find(other => other.name === instance.name) !== instance; }, configureServer: devServer => { server = devServer; @@ -178,7 +178,7 @@ const inputPlugin = (): Plugin => { return insertions.length === 0 ? null : insertInto(code, insertions, file); }, }; - return plugin; + return instance; }; /** @@ -187,7 +187,7 @@ const inputPlugin = (): Plugin => { * schemas its types give, which an application's build, reading only JavaScript there, can't make, and leaves its * kernels as source and its keys without ids, which the application's plugin compiles and gives. */ -export const kernelPlugin = (options: { readonly library?: boolean } = {}): Plugin[] => options.library === true ? [inputPlugin()] : [inputPlugin(), compilePlugin()]; +export const plugin = (options: { readonly library?: boolean } = {}): Plugin[] => options.library === true ? [inputPlugin()] : [inputPlugin(), compilePlugin()]; const compilePlugin = (): Plugin => { let serve = true; @@ -235,14 +235,14 @@ const compilePlugin = (): Plugin => { chunks.add(referenceId); return referenceId; }; - const plugin: Plugin = { + const instance: Plugin = { name: `${packageName}/kernel`, enforce: "post", // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. - config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => kernelPlugin() } }), + config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => plugin() } }), configResolved: config => { serve = config.command === "serve"; - repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; + repeated = config.plugins.find(other => other.name === instance.name) !== instance; }, configureServer: devServer => { server = devServer; @@ -317,5 +317,5 @@ const compilePlugin = (): Plugin => { return await identify(result.code) ?? { code: result.code, map: null }; }, }; - return plugin; + return instance; }; diff --git a/packages/ecs/src/implementation/compiler/public.ts b/packages/ecs/src/implementation/compiler/public.ts new file mode 100644 index 00000000..2fa02a6e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/public.ts @@ -0,0 +1,2 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +export { plugin } from './plugin.js'; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 1ed82036..097a33f9 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -205,7 +205,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const description = kernelOf(leaf); if (description === undefined) { indexLookups.release(); - throw new Error(`System "${label}" is a kernel but wasn't compiled; add kernelPlugin() to the Vite config.`); + throw new Error(`System "${label}" is a kernel but wasn't compiled; add Compiler.plugin() to the Vite config.`); } try { kernel = bindKernel(state, leaf, description, indexLookups.byName, lookupSince); diff --git a/packages/ecs/vite.perf.config.ts b/packages/ecs/vite.perf.config.ts index 414ff150..5ea09bdf 100644 --- a/packages/ecs/vite.perf.config.ts +++ b/packages/ecs/vite.perf.config.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { defineConfig } from "vite"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; /** * Bundles the benchmarks for plain node, with kernels compiled into their own chunks as in a production build, and @@ -8,7 +8,7 @@ import { kernelPlugin } from "./src/implementation/compiler/index.ts"; * the thread the joining benchmarks join their worlds from. */ export default defineConfig({ - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], build: { ssr: true, outDir: "dist/perf", diff --git a/packages/ecs/vite.sample.config.ts b/packages/ecs/vite.sample.config.ts index f7c583d1..fc844921 100644 --- a/packages/ecs/vite.sample.config.ts +++ b/packages/ecs/vite.sample.config.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { defineConfig } from "vite"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; /** @@ -9,5 +9,5 @@ import { isolation, sources } from "./vitest.plugins.ts"; */ export default defineConfig({ root: "src/samples", - plugins: [kernelPlugin(), isolation, sources], + plugins: [Compiler.plugin(), isolation, sources], }); diff --git a/packages/ecs/vitest.config.ts b/packages/ecs/vitest.config.ts index 1f974836..3321223b 100644 --- a/packages/ecs/vitest.config.ts +++ b/packages/ecs/vitest.config.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; /** @@ -26,7 +26,7 @@ export default defineConfig({ test: { projects: [ { - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], test: { ...timeouts, execArgv, @@ -37,7 +37,7 @@ export default defineConfig({ }, }, { - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], test: { ...timeouts, execArgv, @@ -49,7 +49,7 @@ export default defineConfig({ }, }, { - plugins: [kernelPlugin(), isolation, sources], + plugins: [Compiler.plugin(), isolation, sources], test: { ...timeouts, name: "browser", diff --git a/packages/ecs/vitest.fixtures.ts b/packages/ecs/vitest.fixtures.ts index ebec940a..bd0d8891 100644 --- a/packages/ecs/vitest.fixtures.ts +++ b/packages/ecs/vitest.fixtures.ts @@ -4,7 +4,7 @@ import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "vite"; import type { TestProject } from "vitest/node"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; const root = dirname(fileURLToPath(import.meta.url)); /** Where each test run builds its fixtures, in a directory of its own: `run--`. */ @@ -50,7 +50,7 @@ export default async (project: TestProject): Promise<() => void> => { configFile: false, root, logLevel: "warn", - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], build: { ssr: true, outDir: fixturesDirectory, diff --git a/packages/ecs/vitest.perf.config.ts b/packages/ecs/vitest.perf.config.ts index 261054f1..cb1f011b 100644 --- a/packages/ecs/vitest.perf.config.ts +++ b/packages/ecs/vitest.perf.config.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; // `*.perf-test.ts` runs in node; `*.perf-browser-test.ts` runs in chromium, with real workers. @@ -10,9 +10,9 @@ export default defineConfig({ // `pnpm perf:browser` reads the results from the console lines, which the default reporter drops for passing tests. reporters: "verbose", projects: [ - { plugins: [kernelPlugin()], test: { name: "perf-node", include: ["src/**/*.perf-test.ts"] } }, + { plugins: [Compiler.plugin()], test: { name: "perf-node", include: ["src/**/*.perf-test.ts"] } }, { - plugins: [kernelPlugin(), isolation, sources], + plugins: [Compiler.plugin(), isolation, sources], test: { name: "perf-browser", include: ["src/**/*.perf-browser-test.ts"], From e13b1b68e7b3d17b387d212856af7cc25ca418d4 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 17:43:24 -0700 Subject: [PATCH 056/127] ecs: declarations take no name: labels are bindings, reports give ids; reductions are resources' reduce lists Systems, composites, render systems, indexes, rules and finalizers are labeled by the binding the plugin's id names, or by the binding or function a call made elsewhere is in, else by their kind numbered; labels never change as other items load. Failures and warnings give the plugin's id beside the label. Keys are labeled by their description, else their binding, likewise. A system reduces into resources: { reduce: [...] }, each folded with its resource's reduce trait, which kernels read from the declaring module at build time. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/exports.test.ts | 9 +- packages/ecs/src/guide/03-systems.test.ts | 4 +- packages/ecs/src/guide/04-ticks.test.ts | 2 +- packages/ecs/src/guide/05-scheduling.test.ts | 2 +- packages/ecs/src/guide/guide.md | 12 +- .../ecs/src/implementation/compiler/access.ts | 2 +- .../implementation/compiler/augmentations.ts | 28 ++- .../implementation/compiler/compile-kernel.ts | 2 +- .../ecs/src/implementation/compiler/exact.ts | 16 +- .../implementation/compiler/identify.test.ts | 63 ++++--- .../src/implementation/compiler/identify.ts | 60 +++++- .../ecs/src/implementation/compiler/link.ts | 11 ++ .../ecs/src/implementation/compiler/lists.ts | 14 +- .../implementation/compiler/plugin.test.ts | 50 ++--- .../ecs/src/implementation/compiler/plugin.ts | 23 ++- .../src/implementation/compiler/signature.ts | 24 +-- .../implementation/compiler/transform.test.ts | 174 +++++++++--------- .../src/implementation/compiler/transform.ts | 3 +- .../ecs/src/implementation/world/activate.ts | 4 +- .../world/async-loading.test.ts | 10 +- .../world/attach-kernel.test.ts | 4 +- .../src/implementation/world/attach-kernel.ts | 4 +- .../src/implementation/world/batches.test.ts | 18 +- .../src/implementation/world/builtins.test.ts | 8 +- .../world/change-blocks.browser.test.ts | 8 +- .../world/change-blocks.fixture.ts | 8 +- .../world/change-blocks.test.ts | 50 ++--- .../world/changes-finalizers.test.ts | 6 +- .../src/implementation/world/check-kernel.ts | 12 +- .../world/command-rings.cases.ts | 2 +- .../world/command-rings.fixture.ts | 10 +- .../src/implementation/world/commands.test.ts | 28 +-- .../world/composite-input.test.ts | 13 +- .../implementation/world/conditions.test.ts | 34 ++-- .../src/implementation/world/create.test.ts | 72 ++++---- .../ecs/src/implementation/world/create.ts | 10 +- .../implementation/world/dispatch.fixture.ts | 22 +-- .../world/entity-lookup-workers.fixture.ts | 9 +- .../world/entity-lookups.browser.test.ts | 2 +- .../world/entity-lookups.test-helper.ts | 12 +- .../world/entity-lookups.test.ts | 8 +- .../ecs/src/implementation/world/execute.ts | 18 +- .../ecs/src/implementation/world/failures.ts | 16 +- .../implementation/world/finalizers.test.ts | 48 ++--- .../implementation/world/generated-code.md | 2 +- .../implementation/world/gpu.browser.test.ts | 82 ++++----- packages/ecs/src/implementation/world/gpu.ts | 10 +- .../world/ids-keys.test-helper.ts | 4 +- .../ecs/src/implementation/world/ids.test.ts | 6 +- .../world/imported-lists.test.ts | 6 +- .../world/index-buckets.test.ts | 15 +- .../world/index-build.fixture.ts | 13 +- .../world/index-changed.test.ts | 37 ++-- .../implementation/world/index-joined.test.ts | 85 ++++----- .../world/index-kernels.test.ts | 24 +-- .../world/index-keyless.test.ts | 17 +- .../implementation/world/index-order.test.ts | 18 +- .../world/index-workers.fixture.ts | 37 ++-- .../src/implementation/world/indexes.test.ts | 50 ++--- .../ecs/src/implementation/world/indexes.ts | 5 +- .../world/intrinsics.browser.test.ts | 38 ++-- .../ecs/src/implementation/world/join.test.ts | 15 +- packages/ecs/src/implementation/world/join.ts | 14 +- .../implementation/world/join.type-test.ts | 2 +- .../world/joined-observer.test.ts | 2 +- .../implementation/world/joined-observer.ts | 12 +- .../world/joining-worker.fixture.ts | 2 +- .../implementation/world/joining.fixture.ts | 2 +- .../world/joining.test-helper.ts | 10 +- .../world/kernel-imports.test.ts | 9 +- .../world/kernel-lookup.test-helper.ts | 1 - .../world/kernel-path.test-helper.ts | 2 +- .../world/kernel-spawns.test.ts | 18 +- .../implementation/world/kernels-csp.test.ts | 4 +- .../src/implementation/world/kernels.test.ts | 12 +- .../ecs/src/implementation/world/kernels.ts | 4 +- .../world/layout-directory.browser.test.ts | 17 +- .../world/layout-directory.fixture.ts | 17 +- .../implementation/world/lifetime.fixture.ts | 5 +- .../src/implementation/world/lifetime.test.ts | 4 +- .../ecs/src/implementation/world/loop.test.ts | 12 +- packages/ecs/src/implementation/world/loop.ts | 7 +- .../src/implementation/world/names.test.ts | 51 ++--- .../world/observe-cases.browser.test.ts | 6 +- .../world/observe-cases.test.ts | 76 ++++---- .../world/observe-factories.test.ts | 18 +- .../world/observe-joined-cases.test.ts | 16 +- .../world/observe-ticks-cases.test.ts | 30 +-- .../world/observe.test-helper.ts | 2 +- .../world/observed-workers.fixture.ts | 6 +- .../observer-contained-worker.fixture.ts | 2 +- .../world/observer-contained.test-helper.ts | 8 +- .../world/observer-contained.test.ts | 12 +- .../world/observer-joined.test-helper.ts | 28 +-- .../world/observer-joined.test.ts | 48 ++--- .../world/observer.browser.test.ts | 10 +- .../src/implementation/world/observer.test.ts | 34 ++-- .../ecs/src/implementation/world/observer.ts | 3 +- .../implementation/world/pool-size.fixture.ts | 6 +- .../src/implementation/world/ports.fixture.ts | 4 +- .../world/reductions.fixture.ts | 4 +- .../implementation/world/reductions.test.ts | 40 ++-- .../src/implementation/world/reductions.ts | 23 +-- .../world/reevaluation.test-helper.ts | 2 +- .../implementation/world/reevaluation.test.ts | 14 +- .../ecs/src/implementation/world/register.ts | 4 +- .../world/remote-finalizers.cases.ts | 2 +- .../world/remote-finalizers.test-helper.ts | 40 ++-- .../world/remote-finalizers.test.ts | 22 +-- .../world/remote-objects.test-helper.ts | 6 +- .../implementation/world/remote-systems.ts | 35 ++-- .../src/implementation/world/remote.test.ts | 36 ++-- .../world/remove.test-helper.ts | 14 +- .../src/implementation/world/remove.test.ts | 26 +-- .../world/render-order.browser.test.ts | 21 +-- .../world/reports.test-helper.ts | 8 +- .../src/implementation/world/reports.test.ts | 30 +-- .../ecs/src/implementation/world/rows.test.ts | 8 +- .../src/implementation/world/rules.test.ts | 35 ++-- .../ecs/src/implementation/world/runner.ts | 8 +- .../src/implementation/world/schedule.test.ts | 124 ++++++------- .../implementation/world/semantics.test.ts | 11 +- .../ecs/src/implementation/world/sharing.ts | 8 +- .../src/implementation/world/strings.test.ts | 30 +-- .../src/implementation/world/tails.cases.ts | 2 +- .../implementation/world/tails.test-helper.ts | 32 ++-- .../src/implementation/world/tails.test.ts | 10 +- .../ecs/src/implementation/world/tails.ts | 10 +- .../src/implementation/world/target.test.ts | 16 +- .../implementation/world/thread-observer.ts | 8 +- .../world/thread-systems.cases.ts | 4 +- .../world/thread-systems.fixture.ts | 2 +- .../world/thread-systems.test-helper.ts | 55 +++--- .../world/thread-systems.test.ts | 12 +- .../implementation/world/thread-systems.ts | 2 +- .../world/ticks.browser.test.ts | 4 +- .../src/implementation/world/ticks.test.ts | 6 +- .../src/implementation/world/traits.test.ts | 6 +- .../world/transfers.test-helper.ts | 18 +- .../src/implementation/world/up-tree.test.ts | 4 +- .../world/up-workers.fixture.ts | 5 +- .../implementation/world/up.browser.test.ts | 3 +- .../ecs/src/implementation/world/up.test.ts | 34 ++-- .../implementation/world/wasm-kernels.test.ts | 23 +-- .../implementation/world/workers.fixture.ts | 20 +- .../archetype/archetype.type-test.ts | 6 +- .../src/interface/component/register.test.ts | 2 +- .../src/interface/finalizer/create.test.ts | 116 ++++++------ .../ecs/src/interface/finalizer/create.ts | 8 +- .../src/interface/finalizer/declaration.ts | 2 - .../ecs/src/interface/finalizer/finalizer.ts | 4 +- packages/ecs/src/interface/finalizer/name.ts | 7 - .../ecs/src/interface/gpu/gpu.type-test.ts | 2 +- packages/ecs/src/interface/gpu/wgsl.ts | 1 - packages/ecs/src/interface/index.type-test.ts | 14 +- .../ecs/src/interface/indexes/create.test.ts | 48 ++--- packages/ecs/src/interface/indexes/create.ts | 2 +- .../ecs/src/interface/indexes/declaration.ts | 2 - .../ecs/src/interface/indexes/index-type.ts | 6 +- .../src/interface/indexes/index.type-test.ts | 4 +- packages/ecs/src/interface/indexes/name.ts | 6 - .../src/interface/registry/finalizer-order.ts | 6 +- .../ecs/src/interface/registry/ids.test.ts | 34 ++-- packages/ecs/src/interface/registry/ids.ts | 18 ++ packages/ecs/src/interface/registry/index.ts | 2 +- .../ecs/src/interface/registry/indexes.ts | 4 +- packages/ecs/src/interface/registry/items.ts | 63 ++----- .../ecs/src/interface/registry/names.test.ts | 30 +-- packages/ecs/src/interface/registry/names.ts | 45 ++--- .../ecs/src/interface/registry/schedule.ts | 13 +- .../ecs/src/interface/report/components.ts | 3 +- packages/ecs/src/interface/report/failure.ts | 9 +- .../src/interface/report/report.type-test.ts | 3 +- packages/ecs/src/interface/rule/create.ts | 4 +- .../ecs/src/interface/rule/declaration.ts | 2 - packages/ecs/src/interface/rule/name.ts | 7 - .../ecs/src/interface/rule/rule.type-test.ts | 18 +- packages/ecs/src/interface/system/access.ts | 2 + packages/ecs/src/interface/system/body.ts | 2 +- packages/ecs/src/interface/system/check.ts | 18 +- .../interface/system/composite-declaration.ts | 2 - .../system/composite-input.type-test.ts | 24 +-- .../interface/system/conditions.type-test.ts | 20 +- .../ecs/src/interface/system/create.test.ts | 165 ++++++++--------- packages/ecs/src/interface/system/create.ts | 24 +-- .../ecs/src/interface/system/declaration.ts | 5 - packages/ecs/src/interface/system/name.ts | 6 +- packages/ecs/src/interface/system/render.ts | 4 +- packages/ecs/src/interface/system/system.ts | 4 +- .../src/interface/system/system.type-test.ts | 72 ++------ .../src/interface/system/threads.type-test.ts | 58 +++--- .../ecs/src/interface/system/up.type-test.ts | 7 +- .../ecs/src/interface/world/loop.type-test.ts | 14 +- packages/ecs/src/observe/entity.ts | 8 +- packages/ecs/src/observe/resource.ts | 6 +- packages/ecs/src/observe/select.ts | 16 +- packages/ecs/src/observe/world-state.ts | 26 ++- packages/ecs/src/perftest/editor.ts | 14 +- packages/ecs/src/perftest/filters.ts | 18 +- packages/ecs/src/perftest/finalized.ts | 18 +- packages/ecs/src/perftest/flat.ts | 4 +- packages/ecs/src/perftest/growth.ts | 6 +- packages/ecs/src/perftest/handoff.ts | 44 ++--- packages/ecs/src/perftest/hierarchy.ts | 11 +- packages/ecs/src/perftest/indexes.ts | 102 +++++----- packages/ecs/src/perftest/loop.ts | 2 +- packages/ecs/src/perftest/observed-gpu.ts | 4 +- packages/ecs/src/perftest/observed.ts | 8 +- packages/ecs/src/perftest/observer.ts | 6 +- packages/ecs/src/perftest/particles.ts | 3 +- packages/ecs/src/perftest/reads.ts | 14 +- packages/ecs/src/perftest/remote.ts | 26 +-- packages/ecs/src/perftest/render-order.ts | 9 +- packages/ecs/src/perftest/strings.ts | 10 +- packages/ecs/src/perftest/structural.ts | 37 ++-- packages/ecs/src/perftest/suite.ts | 43 ++--- packages/ecs/src/perftest/systems.ts | 4 +- packages/ecs/src/perftest/tails.ts | 30 +-- packages/ecs/src/perftest/transfers.ts | 2 +- packages/ecs/src/perftest/uploads.ts | 13 +- packages/ecs/src/perftest/values.ts | 8 +- .../ecs/src/samples/particles/gpu/main.ts | 4 +- .../src/samples/particles/gpu/particles.ts | 3 - .../ecs/src/samples/particles/particles.ts | 7 +- .../src/samples/todo/app/app.browser.test.ts | 6 +- .../src/samples/todo/app/log-failures.test.ts | 6 +- .../ecs/src/samples/todo/app/log-failures.ts | 2 +- .../todo/app/profile.perf-browser-test.ts | 2 +- .../todo/app/todo.perf-browser-test.ts | 7 +- .../samples/todo/features/completion/data.ts | 2 +- .../samples/todo/features/completion/dom.ts | 1 - .../todo/features/completion/model.test.ts | 2 +- .../samples/todo/features/completion/model.ts | 2 - .../src/samples/todo/features/filter/dom.ts | 2 - .../todo/features/filter/model.test.ts | 2 +- .../src/samples/todo/features/filter/model.ts | 1 - .../samples/todo/features/generate/model.ts | 1 - .../src/samples/todo/features/todos/data.ts | 2 +- .../todo/features/todos/dom.browser.test.ts | 2 +- .../src/samples/todo/features/todos/dom.ts | 7 +- .../src/samples/todo/features/todos/model.ts | 5 - .../todo/features/todos/todos.test-helper.ts | 2 +- .../todo/shared/attach.browser.test.ts | 2 +- .../samples/todo/shared/ticks.test-helper.ts | 2 +- packages/ecs/src/samples/transfers/main.ts | 2 +- packages/ecs/src/samples/transfers/sprites.ts | 12 +- packages/ecs/src/world.test.ts | 6 +- 247 files changed, 2059 insertions(+), 2185 deletions(-) delete mode 100644 packages/ecs/src/interface/finalizer/name.ts delete mode 100644 packages/ecs/src/interface/indexes/name.ts delete mode 100644 packages/ecs/src/interface/rule/name.ts diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index accf3ed6..219c2f02 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -38,10 +38,10 @@ declare module "@adobe/ecs" { } export const Placed = Archetype.create([position, element]); export const showing = Rule.create({ components: { has: [element] }, derive: [shown] }); -export const move = System.create({ name: "exportsTest/move", components: { write: [position] } }, row => { row[position] += 1; }); -export const all = System.create({ name: "exportsTest/all", systems: [move] }); -export const draw = System.render({ name: "exportsTest/draw", shader: { accessors: {} } }, () => {}); -export const placed = Index.create({ name: "exportsTest/placed", key: [position], components: { has: [element] } }); +export const move = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); +export const all = System.create({ systems: [move] }); +export const draw = System.render({ shader: { accessors: {} } }, () => {}); +export const placed = Index.create({ key: [position], components: { has: [element] } }); export const ranked = Index.create({ key: [position], sorted: true }); export const after = System.create({ components: { read: [position] }, lookup: { next: { index: ranked, read: [position] } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); export const sortedName = (index: Index<{ readonly sorted: true }>): string => index.name; @@ -131,7 +131,6 @@ describe("package exports", () => { // An index's type reads as it was declared, naming only the package root. const emitted = readFileSync(join(temporary, "out", "index.d.ts"), "utf8"); expect(emitted).toContain(`export declare const placed: Index<{ - readonly name: "exportsTest/placed"; readonly key: readonly [typeof position]; readonly components: { readonly has: readonly [typeof element]; diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index 63c34669..2b209815 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -27,8 +27,6 @@ const spawn = System.create(({ commands }) => { // Runs once per entity with every component in read and write, and none in not. const move = System.create({ - // Optional: labels the system in reports. - name: "physics/move", components: { read: [velocity], write: [position], not: [frozen] }, resources: { read: [delta] }, }, (row, { resources }) => { @@ -61,7 +59,7 @@ it("runs a system once per entity, or once", async () => { expect(counted).toBe(1); }); -const fail = System.create({ name: "fail" }, () => { +const fail = System.create(() => { throw new Error("broken"); }); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index 7f020083..722fd0ab 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -50,7 +50,7 @@ it("runs added systems on every tick", async () => { // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const step = System.create({ resources: { read: [clock], write: [steps], reduce: [wake] } }, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts index 10c1d719..9611a71f 100644 --- a/packages/ecs/src/guide/05-scheduling.test.ts +++ b/packages/ecs/src/guide/05-scheduling.test.ts @@ -21,7 +21,7 @@ const heal = System.create({ components: { write: [health] }, after: [hit] }, ro row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order -// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +// between them may run in parallel. { systems, after, ... } declares one with constraints. const frame = System.create([heal, hit]); const healths: number[] = []; diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index d1ae4254..0a2d20ee 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -144,8 +144,6 @@ expect(particles).toBe(1); ```ts // Runs once per entity with every component in read and write, and none in not. const move = System.create({ - // Optional: labels the system in reports. - name: "physics/move", components: { read: [velocity], write: [position], not: [frozen] }, resources: { read: [delta] }, }, (row, { resources }) => { @@ -173,10 +171,10 @@ await world.run(read); expect(moved).toEqual([[1, 0, 0]]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L28-L60) +[`03-systems.test.ts`](03-systems.test.ts#L28-L58) ```ts -const fail = System.create({ name: "fail" }, () => { +const fail = System.create(() => { throw new Error("broken"); }); @@ -194,7 +192,7 @@ expect(run).toBe(1); expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L64-L81) +[`03-systems.test.ts`](03-systems.test.ts#L62-L79) ### 4. Ticks and cadence @@ -227,7 +225,7 @@ world.remove(advance); ```ts // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const step = System.create({ resources: { read: [clock], write: [steps], reduce: [wake] } }, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); @@ -253,7 +251,7 @@ const heal = System.create({ components: { write: [health] }, after: [hit] }, ro row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order -// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +// between them may run in parallel. { systems, after, ... } declares one with constraints. const frame = System.create([heal, hit]); ``` diff --git a/packages/ecs/src/implementation/compiler/access.ts b/packages/ecs/src/implementation/compiler/access.ts index b2d6b0a7..c4169494 100644 --- a/packages/ecs/src/implementation/compiler/access.ts +++ b/packages/ecs/src/implementation/compiler/access.ts @@ -60,7 +60,7 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona } case "reducers": { const reduced = key.kind === "binding" ? signature.reduced.findIndex(entry => entry.resource === key.name) : -1; - return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's reduce"); + return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's resources.reduce"); } case "lookups": { const lookup = signature.lookups.findIndex(entry => entry.name === name); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index b16d4b3f..692fccc8 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -4,7 +4,7 @@ import { isEntity, isString, isStruct } from '../../interface/registry/schemas.j import type { Schema } from '../../interface/schema/schema.js'; import type { PassResult } from './input-schemas.js'; import type { Insertion } from './source-map.js'; -import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; +import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypedProgram, type TypeHost, type TypeNode } from './type-schemas.js'; /** The module emitted code imports its registry hooks from, for the module being transformed. */ export type DeclareModuleOf = (file: string) => string; @@ -21,7 +21,7 @@ const kinds = { Components: "component", Resources: "resource", Traits: "trait" const isKind = (name: string): name is keyof typeof kinds => name in kinds; /** The interfaces of each top-level `declare module "@adobe/ecs"` block, with the block. */ -const augmentationsOf = (module: Module): readonly { readonly block: Block; readonly interfaces: readonly Interface[] }[] => +const augmentationsOf = (module: Pick): readonly { readonly block: Block; readonly interfaces: readonly Interface[] }[] => module.program.body.flatMap(statement => { if (statement.type !== "TSModuleDeclaration" || statement.global !== false || statement.id.type !== "Literal" || statement.id.value !== "@adobe/ecs") return []; const body = statement.body?.type === "TSModuleBlock" ? statement.body.body : []; @@ -32,6 +32,30 @@ const augmentationsOf = (module: Module): readonly { readonly block: Block; read return [{ block: statement, interfaces }]; }); +/** + * The operator each resource a module's `declare module "@adobe/ecs"` blocks declare with a `reduce` trait reduces + * with, by its key's binding: what a kernel reducing into it folds with. + */ +export const reductionsIn = (program: TypedProgram): ReadonlyMap => { + const found = new Map(); + for (const { interfaces } of augmentationsOf({ program })) { + for (const declared of interfaces) { + if (declared.id.name !== "Resources") continue; + for (const member of declared.body.body) { + if (member.type !== "TSPropertySignature" || !member.computed || member.key.type !== "Identifier") continue; + const declaration = member.typeAnnotation?.typeAnnotation; + if (declaration?.type !== "TSTypeLiteral") continue; + for (const trait of declaration.members) { + if (trait.type !== "TSPropertySignature" || trait.computed || trait.key.type !== "Identifier" || trait.key.name !== "reduce") continue; + const value = trait.typeAnnotation?.typeAnnotation; + if (value?.type === "TSLiteralType" && value.literal.type === "Literal" && typeof value.literal.value === "string") found.set(member.key.name, value.literal.value); + } + } + } + } + return found; +}; + type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>; type TypeName = Extract["typeName"]; diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 605ff984..5d85d24e 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -76,7 +76,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: else if (loops(program.body)) fail(body, "a GPU kernel can't use loops or labeled blocks yet"); } const contributed = new Set(unit.accumulators.flatMap(entry => reduced[entry.reduced]?.resource ?? [])); - requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, module.bindings, lists); + requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, lists); const strided = columns.flatMap((_, index) => unit.chunks.has(index) || unit.blocks.has(index) ? [] : [index]); const locals = [ ...columns.map((_, index) => `const c${index} = columns[${index}];`), diff --git a/packages/ecs/src/implementation/compiler/exact.ts b/packages/ecs/src/implementation/compiler/exact.ts index 8d449146..57fbe856 100644 --- a/packages/ecs/src/implementation/compiler/exact.ts +++ b/packages/ecs/src/implementation/compiler/exact.ts @@ -2,7 +2,6 @@ import type { Expression, ObjectExpression, Pattern } from 'estree'; import { fail } from './failure.js'; import type { Lists } from './lists.js'; -import type { ModuleScope } from './module-scope.js'; type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; @@ -35,20 +34,11 @@ const requireUsed = (node: Expression | Pattern | undefined, used: ReadonlySet, module: ModuleScope): void => { - if (reduce?.type !== "ObjectExpression") return; - const keys = reduce.properties.map(property => - property.type === "Property" && property.computed && property.key.type === "Identifier" && module.has(property.key.name) ? property.key : null); - for (const key of keys) if (key === null) return; - for (const key of keys) if (key !== null && !contributed.has(key.name)) fail(key, `${key.name} is in reduce but never contributed to; remove it`); -}; - /** - * Requires a system's declared `read`, `write` and `reduce` to be exactly what its body uses: a key it never uses + * Requires a system's declared `read`, `write` and `reduce` lists to be exactly what its body uses: a key it never uses * would still narrow which entities match, and hold back systems that could otherwise run alongside it. */ -export const requireExact = (declaration: ObjectExpression, used: Used, module: ModuleScope, lists: Lists): void => { +export const requireExact = (declaration: ObjectExpression, used: Used, lists: Lists): void => { const declared = fields(declaration); const components = fields(declared.get("components")); const resources = fields(declared.get("resources")); @@ -59,7 +49,7 @@ export const requireExact = (declaration: ObjectExpression, used: Used, module: if (used.resources !== null) { requireUsed(resources.get("read"), used.resources.read, lists, name => `${name} is in resources read but never read; remove it`); requireUsed(resources.get("write"), used.resources.write, lists, name => `${name} is in resources write but never written; remove it, or move it to resources read if it's only read`); - requireContributed(declared.get("reduce"), used.resources.reduce, module); + requireUsed(resources.get("reduce"), used.resources.reduce, lists, name => `${name} is in resources reduce but never contributed to; remove it`); } if (used.up !== undefined) { requireUsed(fields(declared.get("up")).get("read"), used.up, lists, diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index 7804c921..c0b7f9dc 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -86,47 +86,62 @@ describe("identify, for systems, rules and finalizers", () => { test("gives the declaration of each top-level binding's constructor call its module's id and binding", async () => { const result = await identify([ `import { System as S, Rule, Finalizer, Index } from "@adobe/ecs";`, - `const move = S.create({ name: "move" }, () => {});`, - `export const grid = Index.create({ name: "grid" });`, - `export const draw = S.render({ name: "draw" }, () => {}), main = S.create({ name: "main", systems: [move] });`, + `const move = S.create({ tag: "move" }, () => {});`, + `export const grid = Index.create({ tag: "grid" });`, + `export const draw = S.render({ tag: "draw" }, () => {}), main = S.create({ tag: "main", systems: [move] });`, `const all = S.create([move]);`, - `let shown = Rule.create({ name: "shown" });`, - `var release = Finalizer.create({ name: "release" }, () => {});`, - `export default Finalizer.changes({ name: "save" }, () => {});`, + `let shown = Rule.create({ tag: "shown" });`, + `var release = Finalizer.create({ tag: "release" }, () => {});`, + `export default Finalizer.changes({ tag: "save" }, () => {});`, ].join("\n")); expect(declared(result?.code)).toEqual([ - [`{ name: "move" }`, "src/keys.ts#move"], [`{ name: "grid" }`, "src/keys.ts#grid"], [`{ name: "draw" }`, "src/keys.ts#draw"], [`{ name: "main", systems: [move] }`, "src/keys.ts#main"], - [`{ name: "shown" }`, "src/keys.ts#shown"], [`{ name: "release" }`, "src/keys.ts#release"], [`{ name: "save" }`, "src/keys.ts#default"], + [`{ tag: "move" }`, "src/keys.ts#move"], [`{ tag: "grid" }`, "src/keys.ts#grid"], [`{ tag: "draw" }`, "src/keys.ts#draw"], [`{ tag: "main", systems: [move] }`, "src/keys.ts#main"], + [`{ tag: "shown" }`, "src/keys.ts#shown"], [`{ tag: "release" }`, "src/keys.ts#release"], [`{ tag: "save" }`, "src/keys.ts#default"], ]); // A composite's list of members takes its id as a declaration does. expect(result?.code).toContain(`identify$([move], "src/keys.ts#all")`); }); test("records each id where the registry takes it, passing the same declaration", async () => { - const result = await identify(`import { System } from "@adobe/ecs";\nconst move = System.create({ name: "move" });`); + const result = await identify(`import { System } from "@adobe/ecs";\nconst move = System.create({ tag: "move" });`); const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); const declaration: unknown = new Function(`${evaluated}\nreturn move;`)(); - expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "name"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); + expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "tag"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); }); test("identifies a kernel's call the kernel transform wrapped", async () => { - const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`); - expect(declared(result?.code)).toEqual([[`{ name: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); + const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ tag: "grow", kernel: "cpu" }, () => {}), {});`); + expect(declared(result?.code)).toEqual([[`{ tag: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); }); test("follows the namespace to the module that exports it, under any local name", async () => { - const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ name: "a" });\nconst b = Rule.create({ name: "b" });`); - expect(declared(result?.code)).toEqual([[`{ name: "a" }`, "src/keys.ts#a"]]); + const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ tag: "a" });\nconst b = Rule.create({});`); + expect(declared(result?.code)).toEqual([[`{ tag: "a" }`, "src/keys.ts#a"]]); }); - test("leaves calls that aren't a top-level binding's, other namespaces and other methods", async () => { - expect(await identify([ + test("labels a call that isn't a top-level binding's by the binding, function, property or method it's in, and leaves other namespaces and methods", async () => { + const result = await identify([ `import { System, Component } from "@adobe/ecs";`, `import { System as Other } from "elsewhere";`, - `function make() { const inner = System.create({ name: "inner" }); return inner; }`, - `System.create({ name: "statement" });`, - `const wrapped = [System.create({ name: "wrapped" })];`, - `const other = Other.create({ name: "other" });`, - `const registered = Component.register({ name: "registered" });`, - `const spread = System.create(...[{ name: "spread" }]);`, - `const computed = System["create"]({ name: "computed" });`, - ].join("\n"))).toBe(null); + `function make() { return System.create({ tag: "make" }); }`, + `const mover = (speed) => { const inner = System.create({ tag: "inner" }); return inner; };`, + `const systems = { move: System.create({ tag: "move" }) };`, + `System.create({ tag: "alone" });`, + `const wrapped = [System.create({ tag: "wrapped" })];`, + `const other = Other.create({ tag: "other" });`, + `const registered = Component.register({ tag: "registered" });`, + `const spread = System.create(...[{ tag: "spread" }]);`, + `const computed = System["create"]({ tag: "computed" });`, + ].join("\n")); + expect([...(result?.code ?? "").matchAll(/label\$\((\{[^}]*\}), "(\w+)"\)/g)].map(match => [match[1], match[2]])).toEqual([ + [`{ tag: "make" }`, "make"], [`{ tag: "inner" }`, "inner"], [`{ tag: "move" }`, "move"], [`{ tag: "wrapped" }`, "wrapped"], + ]); + expect(result?.code).not.toContain("identify$"); + }); + test("keeps a label already given, as an internal factory gives its own", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nfunction make(given) { return System.create(given); }`); + const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); + const labels = new Map([[{}, "given"]]); + const [declaration] = labels.keys(); + Reflect.set(globalThis, Symbol.for("@adobe/ecs/labels@1"), labels); + new Function(`${evaluated}\nreturn make;`)()(declaration); + expect(labels.get(declaration)).toBe("given"); }); test("fails for a module outside a named package, which has nothing to name its ids by", async () => { const result = await identifyModule(`export const position = Symbol();`, null, parse, resolve); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index 5d4c3351..bb6d2b02 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -2,7 +2,7 @@ /// import type { Expression, Node, Program } from 'estree'; import { posix } from 'node:path'; -import { idsName } from '../../interface/registry/ids.js'; +import { idsName, labelsName } from '../../interface/registry/ids.js'; import { children, isFunction } from './estree.js'; import { moduleScope } from './module-scope.js'; import { isAttachKernelModule } from './paths.js'; @@ -145,14 +145,56 @@ export const identify = async (code: string, id: string | null, parse: Parse, re consider(statement.declaration, "default"); } } - if (found.length === 0 || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; - if (id === null) return { failure: "this module's keys and systems need ids, which name its package, but no package.json above it gives a name: give its package one" }; - const identifier = uniqueName(code, "identify$"); + const labeled = namespaces.size > 0 ? labelsOf(program, namespaces, attach, new Set(found.map(({ value }) => value))) : []; + if ((found.length === 0 && labeled.length === 0) || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; const insertions: Insertion[] = []; - for (const { value, binding } of found) { - insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(`${id}#${binding}`)})` }); + if (found.length > 0) { + if (id === null) return { failure: "this module's keys and systems need ids, which name its package, but no package.json above it gives a name: give its package one" }; + const identifier = uniqueName(code, "identify$"); + for (const { value, binding } of found) { + insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(`${id}#${binding}`)})` }); + } + const ids = JSON.stringify(idsName); + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, id) { const ids = Symbol.for(${ids}); (globalThis[ids] || (globalThis[ids] = new Map())).set(value, id); return value; }\n` }); + } + if (labeled.length > 0) { + const identifier = uniqueName(code, "label$"); + for (const { value, label } of labeled) insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(label)})` }); + const labels = JSON.stringify(labelsName); + // A label already given, as an internal factory gives its own, stands. + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, label) { const labels = Symbol.for(${labels}); const map = globalThis[labels] || (globalThis[labels] = new Map()); if (!map.has(value)) map.set(value, label); return value; }\n` }); + } + return insertInto(code, insertions, id ?? ""); +}; + +/** The name a node gives what's declared in it: a variable's, a function's, a property's or a method's. */ +const nameIn = (node: Node): string | null => { + switch (node.type) { + case "VariableDeclarator": return node.id.type === "Identifier" ? node.id.name : null; + case "FunctionDeclaration": case "FunctionExpression": return node.id?.name ?? null; + case "Property": case "MethodDefinition": case "PropertyDefinition": return !node.computed && node.key.type === "Identifier" ? node.key.name : null; + case "AssignmentExpression": return node.left.type === "Identifier" ? node.left.name : null; + default: return null; } - const ids = JSON.stringify(idsName); - insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, id) { const ids = Symbol.for(${ids}); (globalThis[ids] || (globalThis[ids] = new Map())).set(value, id); return value; }\n` }); - return insertInto(code, insertions, id); +}; + +/** + * The declarations of constructor calls that `identified` leaves out, each with a label: the name of the nearest + * variable, function, property or method it's made in, as `mover` for one made in `const mover = (speed) => ...`. + */ +const labelsOf = (program: Program, namespaces: ReadonlyMap, attach: ReadonlySet, identified: ReadonlySet): readonly { readonly value: Expression; readonly label: string }[] => { + const labeled: { readonly value: Expression; readonly label: string }[] = []; + const visit = (node: Node, names: readonly string[]): void => { + const declaration = declarationOf(node, namespaces, attach); + const label = names.at(-1); + if (declaration !== undefined && !identified.has(declaration) && label !== undefined) { + labeled.push({ value: declaration, label }); + return; + } + const name = nameIn(node); + const inner = name === null ? names : [...names, name]; + for (const child of children(node)) visit(child, inner); + }; + visit(program, []); + return labeled; }; diff --git a/packages/ecs/src/implementation/compiler/link.ts b/packages/ecs/src/implementation/compiler/link.ts index f605c275..f6131ae8 100644 --- a/packages/ecs/src/implementation/compiler/link.ts +++ b/packages/ecs/src/implementation/compiler/link.ts @@ -14,6 +14,8 @@ export type LinkHost = { readonly resolve: (specifier: string, importer: string) => Promise; /** A file's code as JavaScript, and how offsets in it map to source lines when it was compiled from another language. */ readonly load: (file: string) => Promise<{ readonly code: string; readonly locate?: Module["locate"] } | { readonly error: string }>; + /** The operator each resource a file declares with a `reduce` trait reduces with, by its key's binding. */ + readonly reductions?: (file: string) => ReadonlyMap; }; /** @@ -23,6 +25,8 @@ export type LinkHost = { export type Link = { readonly module: (file: string) => Module | { readonly error: string }; readonly resolve: (specifier: string, importer: string) => string | Unresolved; + /** The operator the resource `binding` of `file` declares in its `reduce` trait, if the build can read one. */ + readonly reductionOf?: (file: string, binding: string) => string | undefined; }; /** A cache of modules and resolutions shared across transforms. */ @@ -108,12 +112,19 @@ export const createLinker = (host: LinkHost, parse: (code: string) => Program): for (const exported of module.exports.values()) if (exported.kind !== "local") void prefetch(exported.specifier, file); for (const star of module.stars) void prefetch(star, file); }; + const reductions = new Map>(); return { module: file => modules.get(file) ?? pending(startLoad(file)), + reductionOf: (file, binding) => { + let declared = reductions.get(file); + if (declared === undefined && host.reductions !== undefined) reductions.set(file, declared = host.reductions(file)); + return declared?.get(binding); + }, resolve: (specifier, importer) => resolutions.get(`${importer}\0${specifier}`) ?? pending(prefetch(specifier, importer)), add: module => { modules.set(module.file, module); }, forget: (file, all) => { modules.delete(file); + reductions.delete(file); loading.delete(file); versions.set(file, (versions.get(file) ?? 0) + 1); if (all) { diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts index 19e8f173..3fc90fdc 100644 --- a/packages/ecs/src/implementation/compiler/lists.ts +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -23,6 +23,11 @@ export type Lists = { * external to it, is taken as a key, and registration rejects a list there. Anything else fails with the reason. */ readonly component: (node: Node) => Named; + /** + * The operator the resource the kernel's module names `name` reduces with, its declaration's `reduce` trait, read + * through the build from the module declaring it; undefined where the build can't read one. + */ + readonly reduction: (name: string) => string | undefined; }; /** What a command's component argument names, or why the compiler can't tell. */ @@ -199,5 +204,12 @@ export const createLists = (kernel: Module, link: Link | null, nodes: readonly N // its definition where it has one. return { kind: "key", name: node.type === "Identifier" || found.key === null ? reference : nameOf(found.key, reference) }; }; - return { names, component }; + const reduction = (name: string): string | undefined => { + const binding = kernel.bindings.get(name); + const identity = binding === undefined ? null : definition(kernel, name, binding, link); + if (identity === null) return undefined; + const [file = "", defined = ""] = identity.split("\0"); + return link?.reductionOf?.(file, defined); + }; + return { names, component, reduction }; }; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 88c8b161..f4b3eb6d 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -19,7 +19,7 @@ import { declareComponent$ } from ${source("../../interface/registry/declare.ts" import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const kernel = System.create({ name: "fixture", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); /** A kernel inlining a TypeScript helper and a function from a package's `dist`. */ @@ -41,7 +41,7 @@ import { scaled } from "./helper.ts"; import { increment } from "fake-math"; import { score } from "./linked-keys.js"; // Serving evaluates this module again after an edit, declaring the same system again with the same keys. -const kernel = System.create({ name: "linked", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); +const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); export const description = kernelOf(kernel); `); /** Systems taking input, of a type another TypeScript module declares, which the test edits. */ @@ -51,8 +51,8 @@ const writeInputTypes = (field: string) => writeFileSync(inputTypes, `import typ writeInputTypes("by"); writeFileSync(inputs, `import { System } from ${source("../../interface/system/system.ts")}; import type { Push } from "./input-types.ts"; -const push = System.create({ name: "inputs/push" }, (_context, input: Push) => { void input; }); -const label = System.create({ name: "inputs/label" }, (_context, input: { readonly text?: string }) => { void input; }); +const push = System.create({}, (_context, input: Push) => { void input; }); +const label = System.create({}, (_context, input: { readonly text?: string }) => { void input; }); export const schemas = [push, label].map(system => Reflect.get(system, "input")); `); /** A kernel spawning through a list another module declares, which the test edits. */ @@ -69,7 +69,7 @@ writeFileSync(listing, `import { System } from ${source("../../interface/system/ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { score } from "./linked-keys.js"; import { Listed } from "./listed.js"; -const kernel = System.create({ name: "listing", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); +const kernel = System.create({ kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); export const description = kernelOf(kernel); `); /** Two kernels in one module, writing different functions of the entity. */ @@ -79,8 +79,8 @@ import { declareComponent$ } from ${source("../../interface/registry/declare.ts" import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const doubled = System.create({ name: "doubled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -const tripled = System.create({ name: "tripled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); +const doubled = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const tripled = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ @@ -93,10 +93,10 @@ export const score = Symbol("score"); const shown = Symbol("shown"); declareComponent$(score, ${declaration}); declareComponent$(shown, { type: { const: true }, derived: true }); -export const rule = Rule.create({ name: "keys/shown", components: { has: [score] }, derive: [shown] }); -export const release = Finalizer.create({ name: "keys/release", components: { read: [${released}] } }, () => {}); -export const system = Systems.create({ name: "keys/write", components: { write: [score] } }, (row) => { row[score] = 1; }); -export const kernel = Systems.create({ name: "keys/write", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 2; }); +export const rule = Rule.create({ components: { has: [score] }, derive: [shown] }); +export const release = Finalizer.create({ components: { read: [${released}] } }, () => {}); +export const system = Systems.create({ components: { write: [score] } }, (row) => { row[score] = 1; }); +export const kernel = Systems.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 2; }); export const label = labelOf(score); `; const evaluated = join(directory, "evaluated.js"); @@ -132,7 +132,7 @@ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const kernel = System.create({ name: "${name}", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); +const kernel = System.create({ kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; const externalKey = join(directory, "external-key.js"); @@ -330,7 +330,7 @@ test("an application's build compiles the kernels a library ships uncompiled", a import { declareComponent$ } from "@adobe/ecs/declare"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); `); // The test hook comes from the installed copy, which the library's compiled kernel attaches to. const application = join(project, "application.js"); @@ -390,8 +390,8 @@ export const speed = Symbol(); declare module "@adobe/ecs" { interface Components { [speed]: { type: F32 } } } -export const push = System.create({ name: "push" }, (_context, input: { readonly by: number }) => { void input; }); -export const slow = System.create({ name: "slow", kernel: "cpu", components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); +export const push = System.create({}, (_context, input: { readonly by: number }) => { void input; }); +export const slow = System.create({ kernel: "cpu", components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); `); const outDir = join(project, "dist"); const output = await build({ @@ -413,15 +413,15 @@ test("serve accepts a module of keys evaluated again, which makes the same keys, await serve(async server => { const first = await server.ssrLoadModule(evaluated); const again = await server.ssrLoadModule(`${evaluated}?evaluated=again`); - const { labelOfItem, sameItem } = await server.ssrLoadModule(JSON.parse(source("../../interface/registry/items.ts"))); + const { sameItem } = await server.ssrLoadModule(JSON.parse(source("../../interface/registry/items.ts"))); expect(again.score).not.toBe(first.score); expect([first.label, again.label]).toEqual(["score", "score"]); expect(again.system).not.toBe(first.system); expect([sameItem(again.system, first.system), sameItem(again.kernel, first.kernel), sameItem(again.kernel, again.system)]).toEqual([true, true, false]); expect(again.rule).toBe(first.rule); expect(again.release).toBe(first.release); - // The two systems share a name, so each is labeled with its id. - expect([labelOfItem(again.system), labelOfItem(first.kernel)]).toEqual(["keys/write (fixture-app/evaluated.js#system)", "keys/write (fixture-app/evaluated.js#kernel)"]); + // Each is labeled by its binding. + expect([again.system.name, first.kernel.name]).toEqual(["system", "kernel"]); }); }); @@ -429,7 +429,7 @@ test("serve reports a finalizer evaluated again under its id with another declar await serve(async server => { await server.ssrLoadModule(changedItems); writeFileSync(changedItems, keys(`{ type: { type: "number" } }`, "shown")); - await expect(server.ssrLoadModule(`${changedItems}?evaluated=changed`)).rejects.toThrow(`Cannot declare "keys/release" (fixture-app/changed-items.js#release): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + await expect(server.ssrLoadModule(`${changedItems}?evaluated=changed`)).rejects.toThrow(`Cannot declare "release" (fixture-app/changed-items.js#release): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); }); }); @@ -437,7 +437,7 @@ test("serve reports a key evaluated again with another declaration", async () => await serve(async server => { await server.ssrLoadModule(changed); writeFileSync(changed, keys(`{ type: { type: "string" } }`)); - await expect(server.ssrLoadModule(`${changed}?evaluated=changed`)).rejects.toThrow(`Cannot register component "fixture-app/changed.js#score": its id is another key's, with another declaration`); + await expect(server.ssrLoadModule(`${changed}?evaluated=changed`)).rejects.toThrow(`Cannot register component "score" (fixture-app/changed.js#score): its id is another key's, with another declaration`); }); }); @@ -458,8 +458,8 @@ test("a build gives top-level symbols ids naming no absolute path, also in worke const ids = items.flatMap(item => idsIn(item.type === "chunk" ? item.code : String(item.source))); expect(ids.filter(id => id.startsWith("/") || id.includes(directory) || id.includes(tmpdir()))).toEqual([]); const { labels }: { labels: readonly string[] } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); - // Keys sharing a description are labeled by their ids; a key whose description is its own, by its description. - expect(labels).toEqual(["fixture-app/identified.js#score", "fixture-app/identified.js#twin", "marker"]); + // Keys are labeled by their descriptions, however many share one. + expect(labels).toEqual(["score", "score", "marker"]); }); test("a second instance of the plugin in one bundle does nothing, as when an application also lists it for workers", async () => { @@ -488,7 +488,7 @@ test("a build takes a name from a package external to it as one key, and registr const url = (entry: string) => pathToFileURL(join(outDir, chunks.find(chunk => chunk.facadeModuleId === entry)?.fileName ?? "")).href; const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ url(externalKey)); expect(description.keys.map(String)).toEqual(["Symbol(heat)"]); - await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`System "externalList" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); + await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); }); test("compiled code imports the runtime from the copy of the package its module imports, not the plugin's own", async () => { @@ -500,8 +500,8 @@ test("compiled code imports the runtime from the copy of the package its module writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -export const kernel = System.create({ name: "copied", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -export const spawner = System.create({ name: "copiedSpawner", components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); +export const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const spawner = System.create({ components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); const server = await createServer({ configFile: false, logLevel: "silent", root: project, plugins: [plugin()], diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index e5f863b9..855bd479 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,7 +9,7 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { augmentationInsertions, type AugmentationHost } from './augmentations.js'; +import { augmentationInsertions, reductionsIn, type AugmentationHost } from './augmentations.js'; import { inputInsertions, type InputHost } from './input-schemas.js'; import { copyForImporter, isInPackageCopy, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; import { moduleOf, type TypedProgram } from './type-schemas.js'; @@ -54,8 +54,28 @@ const packages = (): PackageOf => { }; }; +/** Vite's parser of TypeScript, loaded by the first kernel transform, which reads `reduce` traits with it. */ +let parseTypes: typeof import("vite").parseAst | null = null; + +const noReductions: ReadonlyMap = new Map(); + +/** The `reduce` traits a TypeScript file's declarations give, read from its source. */ +const reductionsOf = (file: string): ReadonlyMap => { + const lang = loaders[file.slice(file.lastIndexOf(".") + 1)]; + if (parseTypes === null || (lang !== "ts" && lang !== "tsx")) return noReductions; + let code: string; + try { + code = readFileSync(file, "utf8"); + } + catch { + return noReductions; + } + return code.includes("reduce") && code.includes("declare module") ? reductionsIn(parseTypes(code, { lang }, file)) : noReductions; +}; + /** Resolves imports through the build, from whichever transform runs. */ const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ + reductions: reductionsOf, resolve: async (specifier, importer) => { const resolved = await resolver().resolve(specifier, importer, { skipSelf: true }); if (resolved === null) return { error: `${specifier} can't be resolved from ${importer}` }; @@ -291,6 +311,7 @@ const compilePlugin = (): Plugin => { return identified !== null && "failure" in identified ? this.error(identified.failure) : identified; }; const ssr = options?.ssr === true; + parseTypes ??= (await import("vite")).parseAst; const linker = linkerFor(ssr, this); const hashes: string[] = []; const transformed = await transformKernels(code, id, parse, async source => { diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index c2ed0608..24e0bbff 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -61,17 +61,13 @@ const keyLists = (node: Expression | Pattern, what: string, allowed: readonly st ? entry(name, identifiers(property.value, `${what}.${name}`, lists)) : fail(property, `${what}.${name} is not allowed in a kernel declaration`))); -/** `reduce: { [resource]: "operator", ... }`, with resources bound at module scope. */ -const reductions = (node: Expression | Pattern, module: ModuleScope): readonly Reduced[] => { - if (node.type !== "ObjectExpression") return fail(node, "reduce must be an object literal in a kernel declaration"); - return node.properties.map((property): Reduced => { - if (property.type === "SpreadElement" || !property.computed || property.key.type !== "Identifier" || !module.has(property.key.name)) { - return fail(property, "reduce names each resource as a computed key bound at module scope, as in { [total]: \"+\" }"); - } - const { value } = property; - const operator = value.type === "Literal" ? value.value : undefined; - if (!isReduction(operator)) return fail(value, `a resource is reduced with one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`); - return { resource: property.key.name, operator }; +/** `resources: { reduce: [resource, ...] }`, each folded with its declaration's `reduce` trait. */ +const reductions = (node: Expression | Pattern, lists: Lists): readonly Reduced[] => { + const names = identifiers(node, "resources.reduce", lists); + return names.map((resource): Reduced => { + const operator = lists.reduction(resource); + if (!isReduction(operator)) return fail(node, `${resource} is reduced, so the build reads its operator from its declaration's reduce trait, ${operator === undefined ? "which it can't find: declare the resource with reduce in the module that makes its key" : `"${operator}", which isn't one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`}`); + return { resource, operator }; }).map((entry, index, all) => all.findIndex(other => other.resource === entry.resource) === index ? entry : fail(node, `${entry.resource} is reduced twice`)); }; @@ -106,13 +102,11 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li // A condition in `is` is readable too; one in `not` is only checked before the kernel runs. if (list === "read" || list === "is") resources = [...resources, ...identifiers(value.value, `resources.${list}`, lists)]; else if (list === "changed") changedResources = identifiers(value.value, "resources.changed", lists); + else if (list === "reduce") reduced = reductions(value.value, lists); else if (list === "not") continue; else fail(value, `resources.${list} is not allowed in a kernel declaration`); } break; - case "reduce": - reduced = reductions(property.value, module); - break; case "lookup": for (const [lookup, value] of properties(property.value, "lookup")) { // The index is a value the world resolves; only that a lookup goes through one changes how it compiles. @@ -147,7 +141,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li case "order": ordered = true; break; - case "name": case "kernel": case "before": case "after": case "during": + case "kernel": case "before": case "after": case "during": break; default: fail(property, `${name} is not allowed in a kernel declaration`); diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 449b7d82..4fa40198 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -28,8 +28,16 @@ const delta = "delta"; const target = "target"; `; -/** Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package. */ -const host = (files: { readonly [file: string]: string }): LinkHost => ({ +/** The operators the test module's resources declare in their `reduce` traits, by binding. */ +type Reductions = { readonly [binding: string]: string }; +const reductions: Reductions = { total: "+", low: "min", bits: "|" }; + +/** + * Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package; + * the test module's resources reduce with `reduced`. + */ +const host = (files: { readonly [file: string]: string }, reduced: Reductions = reductions): LinkHost => ({ + reductions: file => new Map(file === "/test.js" ? Object.entries(reduced) : []), resolve: async (specifier, importer) => { if (specifier === "ecs") return ownCopy.index; if (specifier === "external") return { error: "external is external to the build", unreadable: true }; @@ -47,8 +55,8 @@ const host = (files: { readonly [file: string]: string }): LinkHost => ({ }, }); -const transform = (source: string, files: { readonly [file: string]: string } = {}) => transformKernels(`${header}${source}`, "/test.js", parse, - async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files), parse)); +const transform = (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions) => transformKernels(`${header}${source}`, "/test.js", parse, + async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files, reduced), parse)); type Compiled = KernelDescription & KernelModule; @@ -56,8 +64,8 @@ type Compiled = KernelDescription & KernelModule; * Compiles `source`, which assigns its kernel system to `kernel`: the attached description plus its kernel module's * `create`. `imported` gives the values of the bindings `source` imports, since its imports aren't evaluated. */ -const compile = async (source: string, files: { readonly [file: string]: string } = {}, imported: { readonly [name: string]: unknown } = {}): Promise => { - const result = await transform(source, files); +const compile = async (source: string, files: { readonly [file: string]: string } = {}, imported: { readonly [name: string]: unknown } = {}, reduced: Reductions = reductions): Promise => { + const result = await transform(source, files, reduced); if (result === null || "failure" in result) throw new Error(result === null ? "no kernel" : result.failure.message); const module = result.code.split("\n").filter(line => !line.startsWith("import ")).join("\n"); const evaluate = new Function("System", "attachKernel$", "Archetype", ...Object.keys(imported), `${module}\nreturn kernel;`); @@ -68,8 +76,8 @@ const compile = async (source: string, files: { readonly [file: string]: string return { ...description, ...kernelModule }; }; -const failure = async (source: string, files: { readonly [file: string]: string } = {}): Promise => { - const result = await transform(source, files); +const failure = async (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions): Promise => { + const result = await transform(source, files, reduced); return result !== null && "failure" in result ? result.failure.message : "compiled"; }; @@ -314,7 +322,7 @@ const vec3 = (x: readonly number[], y: readonly number[], z: readonly number[]) describe("particles", () => { test("gravity and integrate", async () => { const gravity = await compile(`const kernel = System.create({ - name: "particles/gravity", kernel: "cpu", + kernel: "cpu", components: { write: [velocity] }, resources: { read: [delta] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; @@ -327,7 +335,7 @@ describe("particles", () => { expect(gravity.resources).toEqual(["delta"]); const integrate = await compile(`const kernel = System.create({ - name: "particles/integrate", kernel: "cpu", + kernel: "cpu", components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{ name: "particles/gravity" }], }, (row, { resources }) => { const [px, py, pz] = row[position]; @@ -346,7 +354,7 @@ describe("particles", () => { describe("layout", () => { test("interleaved components are read and written at row * stride + offset, leaving padding untouched", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; row[velocity] = [z, row[alive] === true ? y * 10 : y, x]; });`); @@ -359,7 +367,7 @@ describe("layout", () => { }); test("stamps set the changed row and its chunk; each changed column is followed by its chunk column", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { if (row[mass] > 0) row[score] = 1; });`); const rows = 2 * 2 ** chunkShift + 5; @@ -373,12 +381,12 @@ describe("layout", () => { }); test("an unconditional stamp stamps the call's chunks once; a stamp after return stays per row", async () => { - const always = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const always = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { row[score] = row[mass]; });`); expect(String(always.create)).not.toContain(">> 10] = tick"); expect(run(always, 3200, {}, { range: [1000, 2100] }).chunks("score")).toEqual([tick, tick, tick, 0]); - const after = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const after = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { if (row[mass] > 0) return; row[score] = 1; });`); @@ -388,7 +396,7 @@ describe("layout", () => { }); test("indices stay in step when return skips the rest of a row", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; if (x > 1 && x < 5) return; row[velocity] = [z, y, x]; @@ -403,11 +411,11 @@ describe("reductions", () => { const reducing = (reduce: string, body: string, read = "[position, mass]") => `const total = "total"; const low = "low"; const bits = "bits"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: ${read} }, reduce: { ${reduce} } }, (row, { reduce }) => { ${body} });`; +const kernel = System.create({ kernel: "cpu", components: { read: ${read} }, resources: { reduce: [${reduce}] } }, (row, { reduce }) => { ${body} });`; const fields = { position: vec3([1, -4, 2], [5, 0, -1], [3, 9, 6]), mass: { "[]": [2, 3, 12] } }; test("fold each contribution into its resource, leaf by leaf, from the partial", async () => { - const kernel = await compile(reducing(`[total]: "+", [low]: "min", [bits]: "|"`, ` + const kernel = await compile(reducing("total, low, bits", ` const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](1); @@ -422,23 +430,23 @@ const kernel = System.create({ name: "a", kernel: "cpu", components: { read: ${r }); test("fold the values of inlined functions", async () => { - const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing(`[low]: "max"`, "reduce[low](Vec3.scale(row[position], 2));", "[position]")}`); + const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing("low", "reduce[low](Vec3.scale(row[position], 2));", "[position]")}`, {}, {}, { low: "max" }); expect([0, 1, 2].map(k => run(kernel, 3, fields).reduced("low", [k]))).toEqual([4, 10, 18]); }); test("that can't be compiled fail with the reason", async () => { - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); - expect(await failure(reducing(`[total]: "avg"`, "reduce[total](1);"))).toBe(`a resource is reduced with one of "+", "*", "min", "max", "&", "|", "^"`); - expect(await failure(reducing(`total: "+"`, "reduce[total](1);"))).toBe(`reduce names each resource as a computed key bound at module scope, as in { [total]: "+" }`); - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace(`kernel: "cpu"`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace("reduce: {", "resources: { read: [total] }, reduce: {"))).toBe("total is reduced, so it can't also be read or filtered on"); - expect(await failure(reducing(`[total]: "+", [total]: "max"`, "reduce[total](1);"))).toBe("total is reduced twice"); + expect(await failure(reducing("total", "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); + expect(await failure(reducing("total", "reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); + expect(await failure(reducing("total", "reduce[total](1);"), {}, {})).toBe("total is reduced, so the build reads its operator from its declaration's reduce trait, which it can't find: declare the resource with reduce in the module that makes its key"); + expect(await failure(reducing("total", "reduce[total](1);").replace(`kernel: "cpu"`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); + expect(await failure(reducing("total", "reduce[total](1);").replace("resources: { reduce:", "resources: { read: [total], reduce:"))).toBe("total is reduced, so it can't also be read or filtered on"); + expect(await failure(reducing("total, total", "reduce[total](1);"))).toBe("total is reduced twice"); }); }); describe("let locals of component values", () => { test("written back whole after a tuple assignment have the tuple's shape", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [position] } }, (row) => { let v = row[position]; if (row[alive]) v = [7, 8, 9]; row[position] = v; @@ -451,7 +459,7 @@ describe("let locals of component values", () => { describe("binding identity", () => { test("a local shadowing a component key is not that component", async () => { - expect(await failure(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { const velocity = 1; row[position] = row[velocity]; });`)).toBe("row is indexed by a component in components read or write, or .entity"); @@ -459,14 +467,14 @@ describe("binding identity", () => { test("another binding with the same key string is not the declared component", async () => { expect(await failure(`const speed = "velocity"; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { row[position] = row[speed]; });`)).toBe("speed is not in components read or write"); }); test("a helper parameter shadows a module binding only inside the helper", async () => { const kernel = await compile(`const twice = (velocity) => velocity * 2; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { row[position] = twice(row[velocity]) + row[velocity]; });`); expect(run(kernel, 1, { velocity: { "[]": [3] } }).field("position")).toEqual([9]); @@ -475,7 +483,7 @@ describe("binding identity", () => { describe("value semantics", () => { test("tuple locals are copies, and snapshots read before later writes", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { const before = row[position]; let v = [before[0], before[1]]; const w = v; @@ -489,7 +497,7 @@ describe("value semantics", () => { }); test("whole-value writes evaluate every leaf before storing", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { row[position] = [row[position][1], row[position][0]]; let a = 1; let b = 2; @@ -502,7 +510,7 @@ describe("value semantics", () => { }); test("struct values and conditional tuples", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { const p = row[mass] > 1 ? { x: 1, y: [2, 3] } : { x: 4, y: [5, 6] }; row[position] = p; });`); @@ -520,7 +528,7 @@ describe("helpers", () => { return [x * k, y * k]; }; const length = ([x, y]) => Math.sqrt(x * x + y * y); - const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { const k = 100; const x = 1; row[position] = scale(row[position], 0.5); @@ -533,14 +541,14 @@ describe("helpers", () => { test("recursive helpers are rejected", async () => { expect(await failure(`const f = (x) => f(x); - const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = f(1); });`)) + const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = f(1); });`)) .toBe("f is recursive"); }); }); describe("control flow", () => { test("return ends the row, also from inside a loop", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { if (row[mass] < 0) return; let total = 0; for (let i = 0; i < 10; i++) { @@ -555,7 +563,7 @@ describe("control flow", () => { test("a loop test that inlines a helper is re-evaluated each iteration", async () => { const kernel = await compile(`const below = (a, b) => { const limit = b * 2; return a < limit; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { let n = 0; while (below(n, row[mass])) { n++; } let m = 0; @@ -569,7 +577,7 @@ describe("control flow", () => { describe("context", () => { test("lookups map components to their read index, with null guards", async () => { const kernel = await compile(`const half = (value) => { const h = value / 2; return h; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { + const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { row[position] = other[position] - other[mass]; } row[score] = (other !== null && half(other[position])) || -1; @@ -582,7 +590,7 @@ describe("context", () => { }); test("optional lookup access, destructured context groups and input", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; @@ -590,7 +598,7 @@ describe("context", () => { }); test("context groups reached through the context parameter", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { const { resources } = context; row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + input; });`); @@ -599,7 +607,7 @@ describe("context", () => { }); test("boolean columns read as booleans", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [score] } }, (row) => { row[score] = row[alive] === true ? 1 : 2; });`); expect(run(kernel, 2, { alive: { "[]": [1, 0] } }, { booleans: ["alive"] }).field("score")).toEqual([1, 2]); @@ -607,7 +615,7 @@ describe("context", () => { }); test("boolean reads convert only where observable, including through locals", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { const flag = row[alive]; const copy = flag; row[score] = (copy === true ? 10 : 20) + row[mass] * 2 + (flag ? 1 : 0); @@ -626,7 +634,7 @@ describe("context", () => { }); test("a spawn nested in an add's value is issued before the add begins", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { commands.add(row.entity, [target, score], { [target]: commands.spawn([mass])({ [mass]: row[mass] }), [score]: 1 }); commands.despawn(row.entity); });`); @@ -640,7 +648,7 @@ describe("context", () => { }); test("commands name components through keys", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [position] } }, (row, context) => { const { commands } = context; const spawned = commands.spawn([position, alive])({ [position]: [row[position][0], 1] }); commands.add(spawned, score, row.entity); @@ -665,7 +673,7 @@ describe("context", () => { const kernel = await compile(`import { Archetype } from "ecs"; const unit = Archetype.create([position, alive, score]); const pair = [mass, score]; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, { commands }) => { + const kernel = System.create({ kernel: "cpu", components: { read: [position] } }, (row, { commands }) => { const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); commands.add(spawned, pair, { [mass]: 3 }); commands.remove(row.entity, unit); @@ -682,7 +690,7 @@ describe("context", () => { test("kernel declarations list components through module constants", async () => { const kernel = await compile(`import { Archetype } from "ecs"; const moving = Archetype.create([position, velocity]); - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: moving, write: [score] } }, row => { + const kernel = System.create({ kernel: "cpu", components: { read: moving, write: [score] } }, row => { row[score] = row[position] + row[velocity]; });`); expect(run(kernel, 2, { position: { "[]": [1, 2] }, velocity: { "[]": [10, 20] } }).field("score")).toEqual([11, 22]); @@ -697,13 +705,13 @@ describe("context", () => { ["an add with too many arguments", "commands.add(row.entity, mass, 1, 2);", "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"], ["a remove with rest components", "commands.remove(row.entity, mass, score);", "a remove is commands.remove(entity, component) or commands.remove(entity, components)"], ])("fail the build for %s", async (_, body, message) => { - expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); + expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); }); }); describe("program", () => { test("input and resource paths are scalars in the program", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { const [, y] = d; row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); });`); @@ -714,7 +722,7 @@ describe("program", () => { }); test("runtime indexing into a resource has no program", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { + const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { row[score] = d[row[mass]]; });`); expect(run(kernel, 1, { mass: { "[]": [1] } }, { resources: [[5, 6]], program: false }).field("score")).toEqual([6]); @@ -723,7 +731,7 @@ describe("program", () => { describe("composites", () => { test("one argument to System.create that isn't a body is a composite, never a kernel", async () => { - const result = await transform(`const a = System.create({ name: "a" }, () => {}); + const result = await transform(`const a = System.create({}, () => {}); const listed = System.create([a]); const declared = System.create({ kernel: "cpu", systems: [a], components: { write: [score] } });`); expect(result !== null && "failure" in result ? result.failure.message : result?.kernels ?? []).toEqual([]); @@ -732,7 +740,7 @@ const declared = System.create({ kernel: "cpu", systems: [a], components: { writ describe("lookups in the program", () => { const targets = (entity: number) => entity === 1 ? [3, [10, 20, 30]] : entity === 2 ? [4, [40, 50, 60]] : null; - const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ name: "a", kernel: "cpu", + const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ kernel: "cpu", components: { read: ${read}, write: [score] }, lookup: { ${lookup} } }, (row, { lookup: { ${lookup.includes("others") ? "targets, others" : "targets"} } }) => { ${body} });`; @@ -778,7 +786,7 @@ describe("lookups in the program", () => { describe("subset", () => { const kernel = (body: string, components = "{ read: [mass], write: [score] }") => - `const kernel = System.create({ name: "a", kernel: "cpu", components: ${components} }, (row, { commands }) => { ${body} });`; + `const kernel = System.create({ kernel: "cpu", components: ${components} }, (row, { commands }) => { ${body} });`; test.each([ ["row[mass] = 1;", "mass is not in components write"], @@ -802,7 +810,7 @@ describe("subset", () => { test("GPU kernels", async () => { const gpu = (declaration: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; + `const kernel = System.create({ kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; expect(await failure(gpu("components: { read: [mass], write: [score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); expect(await failure(gpu("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass] } }", "row[score] = 1;"))) .toBe("a GPU kernel can't use lookups yet"); @@ -811,20 +819,20 @@ describe("subset", () => { .toBe("a GPU kernel can't issue commands or use values that aren't numbers or booleans"); expect(await failure(gpu("components: { read: [mass], write: [score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) .toBe("a GPU kernel can't use loops or labeled blocks yet"); - expect(await failure(`const kernel = System.create({ name: "a", kernel: true, components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => {});`)) .toBe(`kernel must be the literal "cpu" or "gpu"`); }); test("GPU programs carry their locals' names", async () => { const body = "(row) => { const doubled = row[mass] * 2; row[score] = doubled; }"; - const gpu = await compile(`const kernel = System.create({ name: "a", kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); - const cpu = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, ${body});`); + const gpu = await compile(`const kernel = System.create({ kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); + const cpu = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, ${body});`); expect(gpu.program?.names).toEqual([expect.stringMatching(/^doubled_\d+$/)]); expect(cpu.program?.names).toBeUndefined(); }); test("kernels inside functions", async () => { - expect(await failure(`const make = () => System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const make = () => System.create({ kernel: "cpu", components: { write: [score] } }, (row) => {});`)) .toBe("kernel systems must be declared at module scope"); }); @@ -836,7 +844,7 @@ describe("subset", () => { }); test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { - expect(await transform(`const s = System.create({ name: "a", components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); + expect(await transform(`const s = System.create({ components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); @@ -874,13 +882,13 @@ describe("terminal bodies", () => { expect(await marked(parameters, body)).toBe(false); }); test("leave a function body unmarked that uses arguments", async () => { - const result = await transform(`const system = System.create({ name: "a" }, function () { console.log(arguments); });`); + const result = await transform(`const system = System.create({}, function () { console.log(arguments); });`); expect(result).toBeNull(); }); test("mark a system made from its body alone, and no kernel or finalizer", async () => { const alone = await transform("const system = System.create(() => { console.log(1); });"); expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); - const kernel = await transform(`const kernel = System.create({ name: "k", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); + const kernel = await transform(`const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); expect(finalizer).toBeNull(); @@ -890,7 +898,7 @@ describe("terminal bodies", () => { describe("index lookups in main-thread systems", () => { /** Runs the transformed body once, recording each lookup as the runtime hook receives it. */ const looked = async (body: string, parameters = "({ lookup })", lookup = "near: { index: grid }"): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { - const source = `const grid = "grid";\nconst system = System.create({ name: "a", lookup: { ${lookup} } }, ${parameters} => { ${body} });`; + const source = `const grid = "grid";\nconst system = System.create({ lookup: { ${lookup} } }, ${parameters} => { ${body} });`; const result = await transform(source); if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; const module = (result?.code ?? `${header}${source}`).split("\n").filter(line => !line.startsWith("import ")).join("\n"); @@ -1032,7 +1040,7 @@ describe("helpers that return early", () => { } return [c, 2]; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { const [value, path] = pick(row[mass], 4); row[position] = [value, path, 0]; });`); @@ -1048,7 +1056,7 @@ describe("math builtins", () => { const results = (result: ReturnType) => [0, 1, 2].map(row => [0, 1, 2].map(k => result.field("position", [k])[row])); test("inline as scalar code, nested namespaces and helpers they call included", async () => { - const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position, score] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position, score] } }, (row) => { const p = row[position]; const v = row[velocity]; row[position] = Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.F32.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); @@ -1067,7 +1075,7 @@ describe("math builtins", () => { }); test("across namespaces: quaternions rotating vectors, and matrices", async () => { - const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])); @@ -1081,8 +1089,8 @@ describe("math builtins", () => { test("in GPU kernels, become regions WGSL emits as builtins, and other backends run as the inlined body", async () => { const body = `(row) => { row[position] = Vec3.scale(Vec3.normalize(Vec3.cross(row[position], row[velocity])), Vec3.dot(row[position], row[velocity])); }`; const declaration = `components: { read: [velocity], write: [position] }`; - const gpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, ${body});`); - const cpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, ${body});`); + const gpu = await compile(`${math}const kernel = System.create({ kernel: "gpu", ${declaration} }, ${body});`); + const cpu = await compile(`${math}const kernel = System.create({ kernel: "cpu", ${declaration} }, ${body});`); const regions = (gpu.program?.body ?? []).flatMap(statement => statement.kind === "intrinsic" ? [statement.intrinsic.operation] : []); expect(regions).toEqual(["cross", "normalize", "dot", "*"]); expect((cpu.program?.body ?? []).some(statement => statement.kind === "intrinsic")).toBe(false); @@ -1096,17 +1104,17 @@ describe("math builtins", () => { }); test("that leave the kernel subset fail with the function's reason", async () => { - expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { row[position] = Vec3.random(); });`)).toBe("Math.random is not in the kernel subset"); - expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { row[position] = Vec3.missing([1, 2, 3]); });`)).toMatch(/^missing isn't exported by .*\/math\/vec3\/public\.ts$/); }); }); describe("imported functions and constants", () => { - const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { ${body} });`; + const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { ${body} });`; const scores = async (source: string, files: { readonly [file: string]: string }) => run(await compile(source, files), 2, { mass: { "[]": [2, 3] } }).field("score"); @@ -1191,7 +1199,7 @@ describe("imported lists", () => { const Unit = ["health", "armor", "tagged"]; const Guard = ["shield", "armor"]; const commanding = (imports: string, body: string) => - `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { ${body} });`; + `${imports}\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { ${body} });`; test("name the keys of a list imported from another module, by the kernel's bindings or else through the list", async () => { const source = commanding(`import { health, armor } from "./components.js";\nimport { Unit, Guard } from "./archetypes.js";`, ` @@ -1230,11 +1238,11 @@ describe("imported lists", () => { test("in a declaration, bind keys the list's module defines to the kernel's imports of them, under any name", async () => { const source = `import { shield, Guard } from "./archetypes.js";\nimport { armor as plate } from "./components.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; +const kernel = System.create({ kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; const kernel = await compile(source, files, { shield: "shield", Guard, plate: "armor" }); expect(run(kernel, 2, { shield: { "[]": [1, 2] }, armor: { "[]": [3, 4] } }).field("score")).toEqual([13, 24]); expect(await failure(`import { shield, Guard } from "./archetypes.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) +const kernel = System.create({ kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) .toBe("Guard[1] is in components read but never read; remove it, or move it to components has to keep requiring it"); }); @@ -1286,15 +1294,15 @@ const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Gua test("fail the build for a declaration's list computed when its module runs", async () => { expect(await failure(`import { Computed } from "./archetypes.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) +const kernel = System.create({ kernel: "cpu", components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) .toBe("components.read: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); }); }); describe("declared access", () => { const kernel = (declaration: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; - const system = (declaration: string, body: string) => `const system = System.create({ name: "a", ${declaration} }, ${body});`; + `const kernel = System.create({ kernel: "cpu", ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; + const system = (declaration: string, body: string) => `const system = System.create({ ${declaration} }, ${body});`; test("a kernel uses every component and resource it lists", async () => { expect(await failure(kernel("components: { read: [position, velocity], write: [score] }", "row[score] = row[velocity];"))) @@ -1314,7 +1322,7 @@ describe("declared access", () => { test("a kernel reads every component it lists in up.read, and up holds only those", async () => { const up = (kind: string, read: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "${kind}", components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; + `const kernel = System.create({ kernel: "${kind}", components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; const propagate = "row[score] = (up === null ? 0 : up[score]) + row[mass];"; expect(await failure(up("cpu", "score, position", propagate))).toBe("position is in up.read but never read; remove it"); expect(await failure(up("cpu", "score", "row[score] = up === null ? row[mass] : up[position];"))).toBe("up is indexed by a component in up.read"); @@ -1347,11 +1355,11 @@ describe("declared access", () => { test("a system contributes to every resource it reduces", async () => { const totals = `const total = "total";\nconst low = "low";\n`; - const missing = "low is in reduce but never contributed to; remove it"; - expect(await failure(`${totals}${kernel(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "reduce[total](row[mass]);")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, { reduce }) => { reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, context) => { context.reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+" }`, "(row, { reduce }) => { const add = reduce[total]; add(row[mass]); }")}`)).toBe("compiled"); + const missing = "low is in resources reduce but never contributed to; remove it"; + expect(await failure(`${totals}${kernel("components: { read: [mass] }, resources: { reduce: [total, low] }", "reduce[total](row[mass]);")}`)).toBe(missing); + expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total, low] }", "(row, { reduce }) => { reduce[total](row[mass]); }")}`)).toBe(missing); + expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total, low] }", "(row, context) => { context.reduce[total](row[mass]); }")}`)).toBe(missing); + expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total] }", "(row, { reduce }) => { const add = reduce[total]; add(row[mass]); }")}`)).toBe("compiled"); }); test("a main-thread system reads every component it lists in a lookup, where its body shows every use of the result", async () => { @@ -1392,13 +1400,13 @@ describe("declared access", () => { return result !== null && "failure" in result ? result.failure.message : "compiled"; }; const header = `import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\n`; - expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle, count] } }, (row) => { console.log(row[handle]); });`)) + expect(await failed(`${header}const f = Finalizer.create({ components: { read: [handle, count] } }, (row) => { console.log(row[handle]); });`)) .toBe("count is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes); });`)) + expect(await failed(`${header}const f = Finalizer.changes({ resources: { read: [count] } }, (changes, { resources }) => { console.log(changes); });`)) .toBe("count is in resources read but never read; remove it"); - expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes, resources[count]); });`)) + expect(await failed(`${header}const f = Finalizer.changes({ resources: { read: [count] } }, (changes, { resources }) => { console.log(changes, resources[count]); });`)) .toBe("compiled"); - expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle] } }, (row) => { console.log(row[handle]); });`)).toBe("compiled"); + expect(await failed(`${header}const f = Finalizer.create({ components: { read: [handle] } }, (row) => { console.log(row[handle]); });`)).toBe("compiled"); }); test("a list declared as a module constant is checked as its array literal", async () => { diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 544f4b8c..c73bde2d 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -149,6 +149,7 @@ export const transformKernels = async ( return linker.module(dependency); }, resolve: linker.resolve, + reductionOf: linker.reductionOf, }; const attach = uniqueName(code, "attachKernel$"); const prepare = uniqueName(code, "prepareSpawn$"); @@ -185,7 +186,7 @@ export const transformKernels = async ( } const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : null; if (declaration?.type === "ObjectExpression" && positions !== null && body !== undefined && body.type !== "SpreadElement") { - requireExact(declaration, usedBy(declaration, body, module.bindings, positions), module.bindings, mainThreadLists); + requireExact(declaration, usedBy(declaration, body, module.bindings, positions), mainThreadLists); } // A system whose body never reaches its commands is terminal where its declaration allows (`world/tails.ts`). if (created === "system" && positions !== null && body !== undefined && body.type !== "SpreadElement" && neverReachesCommands(body, positions.context)) { diff --git a/packages/ecs/src/implementation/world/activate.ts b/packages/ecs/src/implementation/world/activate.ts index bc923c83..1a5fbcaa 100644 --- a/packages/ecs/src/implementation/world/activate.ts +++ b/packages/ecs/src/implementation/world/activate.ts @@ -9,7 +9,7 @@ import { triggersOf } from './program.js'; import { ensureResources, findResource } from './resources.js'; import { idOf, metIdOf, type Key, type State } from './state.js'; import { restratify } from './structure.js'; -import { idOfItem, idOfKey, labelOf, labelOfItem, threadOf } from '../../interface/registry/index.js'; +import { idOfItem, idOfKey, labelOf, threadOf } from '../../interface/registry/index.js'; /** * Why a joined thread that imported the items of ids `imported` can't claim `threads`: a program finalizer on one that @@ -20,7 +20,7 @@ export const finalizerCrossing = (threads: readonly string[], imported: Readonly if (finalizer.kind !== "finalizer") continue; const thread = threadOf(finalizer.declaration, "Finalizer", finalizer.name); if (thread === null || !threads.includes(thread)) continue; - const label = labelOfItem(finalizer); + const label = finalizer.name; const id = idOfItem(finalizer); if (id === undefined) return `Finalizer "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level finalizers one.`; if (!imported.has(id)) return `Finalizer "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts index 9bf4bb41..827883c9 100644 --- a/packages/ecs/src/implementation/world/async-loading.test.ts +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -38,11 +38,10 @@ const load = async (path: string): Promise => { }; // 1. A rule derives a slot for every visible entity with a mesh source. -Rule.create({ name: "asyncLoading/slots", components: { has: [meshSource], not: [hidden] }, derive: [meshSlot] }); +Rule.create({ components: { has: [meshSource], not: [hidden] }, derive: [meshSlot] }); // 2. Each new slot gets a fresh token, and its load is requested; the application starts requested loads. const request = System.create({ - name: "asyncLoading/request", components: { read: [meshSource], write: [meshSlot], added: [meshSlot] }, resources: { write: [nextToken, requests] }, }, (row, { resources }) => { @@ -54,7 +53,6 @@ const request = System.create({ // 3. A completed load arrives as a run of its own. A slot whose token still matches gets the mesh; a stale one releases it. const apply = System.create({ - name: "asyncLoading/apply", lookup: { slots: { read: [meshSlot] } }, }, ({ lookup, commands }, input: { readonly entity: Entity; readonly token: number; readonly handle: number }) => { if (lookup.slots(input.entity)?.[meshSlot] === input.token) commands.add(input.entity, meshReady, input.handle); @@ -62,13 +60,13 @@ const apply = System.create({ }); // 4. When the slot goes, its mesh goes with it, and the finalizer releases what was loaded. -Finalizer.create({ name: "asyncLoading/release", components: { read: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); +Finalizer.create({ components: { read: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); -const spawn = System.create({ name: "asyncLoading/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { commands.spawn([meshSource])({ [meshSource]: "ship.glb" }); commands.spawn([meshSource])({ [meshSource]: "rock.glb" }); }); -const hide = System.create({ name: "asyncLoading/hide" }, ({ commands }, input: { readonly entity: Entity }) => { +const hide = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, hidden); }); diff --git a/packages/ecs/src/implementation/world/attach-kernel.test.ts b/packages/ecs/src/implementation/world/attach-kernel.test.ts index 5414fc55..a2d42565 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.test.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.test.ts @@ -2,9 +2,9 @@ import { expect, it } from "vitest"; it("rejects a kernel lookup of a string, which kernels don't read, where the kernel is declared", async () => { - await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`Kernel "kernelLookupTest/labels": a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); + await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); }); it("rejects a compiled kernel that doesn't match its schemas where the kernel is declared", async () => { - await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`Kernel "kernelPathTest/beyond": "kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); + await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`"kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); }); diff --git a/packages/ecs/src/implementation/world/attach-kernel.ts b/packages/ecs/src/implementation/world/attach-kernel.ts index 1cd86615..2c661ebb 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { definitionOf, labelOfItem } from '../../interface/registry/index.js'; +import { definitionOf } from '../../interface/registry/index.js'; import type { System } from '../../interface/system/system.js'; import { checkKernel } from './check-kernel.js'; import type { KernelDescription } from './kernel.js'; @@ -14,7 +14,7 @@ const kernels = new WeakMap, KernelDescription>(); */ export const attachKernel = >(system: S, description: KernelDescription): S => { for (const key of description.keys) { - if (Array.isArray(key)) throw new Error(`System "${labelOfItem(system)}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); + if (Array.isArray(key)) throw new Error(`System "${system.name}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); } const leaf = definitionOf(system); if (leaf !== undefined && leaf.kind !== "composite") checkKernel(leaf, description); diff --git a/packages/ecs/src/implementation/world/batches.test.ts b/packages/ecs/src/implementation/world/batches.test.ts index 920df34d..0881ba33 100644 --- a/packages/ecs/src/implementation/world/batches.test.ts +++ b/packages/ecs/src/implementation/world/batches.test.ts @@ -32,14 +32,14 @@ describe("staged spawns", () => { it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => { const seen = { early: 0, same: 0, late: 0 }; const spawned: Entity[] = []; - const early = System.create({ name: "batchesTest/early", components: { added: [score] } }, () => { seen.early++; }); - const spawn = System.create({ name: "batchesTest/spawn", after: [early] }, ({ commands }) => { + const early = System.create({ components: { added: [score] } }, () => { seen.early++; }); + const spawn = System.create({ after: [early] }, ({ commands }) => { if (spawned.length > 0) return; for (let i = 0; i < 100; i++) spawned.push(commands.spawn([position, score, name, tag])({ [position]: [i, i, i], [score]: i, [name]: `${i}`, [tag]: true })); }); - const same = System.create({ name: "batchesTest/same", components: { added: [score] }, after: [early] }, () => { seen.same++; }); - const late = System.create({ name: "batchesTest/late", components: { added: [score] }, after: [spawn, same] }, () => { seen.late++; }); - const frame = System.create({ name: "batchesTest/edit", systems: [early, spawn, same, late] }); + const same = System.create({ components: { added: [score] }, after: [early] }, () => { seen.same++; }); + const late = System.create({ components: { added: [score] }, after: [spawn, same] }, () => { seen.late++; }); + const frame = System.create({ systems: [early, spawn, same, late] }); const world = createWorld({ [workers]: 0 }); await world.run(frame); // Nothing saw the spawns during their own run. @@ -54,7 +54,7 @@ describe("staged spawns", () => { it("can be changed, removed, re-added or despawned in the run that spawns them", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "batchesTest/edit/2" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { for (let i = 0; i < 4; i++) spawned.push(commands.spawn([score, name])({ [score]: i, [name]: `${i}` })); commands.add(spawned[0], score, 10); commands.remove(spawned[1], name); @@ -72,7 +72,7 @@ describe("staged spawns", () => { it("keep the spawns before a value that can't be written, and release that one's entity", async () => { const spawned: Entity[] = []; let poison = true; - const spawn = System.create({ name: "batchesTest/spawn/2" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 3; i++) spawned.push(commands.spawn([position, score])({ [position]: [i, 0, 0], [score]: i })); // A symbol can't convert to a number, so writing it throws. if (poison) commands.spawn([position, score])({ [position]: [0, 0, 0], [score]: Symbol("x") as unknown as number }); @@ -89,7 +89,7 @@ describe("staged spawns", () => { it("take value objects built before the spawn", async () => { const spawned: Entity[] = []; - const spawn = System.create({ name: "batchesTest/spawn/4" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 20; i++) { const values = i % 2 === 0 ? { [position]: [i, 0, 1] as const, [score]: i, [name]: `${i}` } : { [score]: i, [position]: [i, 0, 1] as const, [name]: `${i}` }; spawned.push(commands.spawn([position, score, name])(values)); @@ -114,7 +114,7 @@ describe("spawns keyed by symbols", () => { }); it("read only their components' values, as adds do", async () => { let spawned = Entity.none; - const edit = System.create({ name: "batchesTest/stringKeys" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { const values = { [score]: 1, "batchesTest/score": 2, [name]: "a" }; spawned = commands.spawn([score])(values); commands.add(spawned, [score], values); diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts index 951c30c8..722795e9 100644 --- a/packages/ecs/src/implementation/world/builtins.test.ts +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -23,7 +23,7 @@ declare module "@adobe/ecs" { } -const spawn = System.create({ name: "builtinsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([position, velocity])({ [position]: [i, i % 5, -i], [velocity]: [1, 0.5 * (i % 3), 0.25] }); }); @@ -31,7 +31,7 @@ const rotation = Quat.fromAxisAngle([0, 1, 0], 0.5); const matrix = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); /** Rotates, transforms and offsets each position, with functions from three math namespaces inlined. */ -const transform = System.create({ name: "builtinsTest/transform", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); @@ -42,7 +42,7 @@ const expected = (p: Vec3, v: Vec3): Vec3 => Vec3.add(Mat4x4.multiplyVec3(matrix const bitsAt = (i: number): Vec3.U32 => [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, i << 26 >>> 0]; const sumsAt = (i: number): Vec3.I32 => [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)]; -const spawnBits = System.create({ name: "builtinsTest/spawnBits" }, ({ commands }) => { +const spawnBits = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([bits, counts, sums])({ [bits]: bitsAt(i), [counts]: [0, 0, 0], [sums]: sumsAt(i) }); }); @@ -52,7 +52,7 @@ const integerResults = (b: Vec3.U32, s: Vec3.I32): { readonly counts: Vec3.U32; sums: Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))), }); -const integers = System.create({ name: "builtinsTest/integers", kernel: "cpu", components: { read: [bits], write: [counts, sums] } }, row => { +const integers = System.create({ kernel: "cpu", components: { read: [bits], write: [counts, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[counts] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))); diff --git a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts index e929d7fa..14fb4ce7 100644 --- a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts @@ -28,18 +28,18 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con const rows = 4500; let entities: Entity[] = []; -const seed = System.create({ name: "changeBlocksGpuTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn([value, at, note])({ [value]: 0, [at]: i })); }); -const raise = System.create({ name: "changeBlocksGpuTest/raise", kernel: "gpu", components: { write: [value] } }, row => { +const raise = System.create({ kernel: "gpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); -const setValue = System.create({ name: "changeBlocksGpuTest/setValue", target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { +const setValue = System.create({ target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { row[value] += 1; }); let found: number[] = []; -const there = System.create({ name: "changeBlocksGpuTest/there", components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const there = System.create({ components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts index 22f82b48..96b86329 100644 --- a/packages/ecs/src/implementation/world/change-blocks.fixture.ts +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -23,23 +23,23 @@ declare module "@adobe/ecs" { const rows = 4500; -const seed = System.create({ name: "changeBlocksWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) commands.spawn([value, at, seen])({ [value]: 0, [at]: i, [seen]: 0 }); }); /** Writes the rows whose `at` is one of three, stamping row by row. */ -const writeSome = System.create({ name: "changeBlocksWorkersTest/writeSome", kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { +const writeSome = System.create({ kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c) row[value] += 1; }); /** Counts each run of the rows whose value changed. */ -const watch = System.create({ name: "changeBlocksWorkersTest/watch", kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { +const watch = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); let found: number[] = []; -const onMain = System.create({ name: "changeBlocksWorkersTest/onMain", components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const onMain = System.create({ components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); /** * The rows a kernel on workers and a system on the world's thread find after a kernel on workers wrote three rows far diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index 85fce5a1..be1be66f 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -51,7 +51,7 @@ const marked = Archetype.create([value, at, seen, group, tagged]); const noted = Archetype.create([value, at, note]); let entities: Entity[] = []; -const seed = System.create({ name: "changeBlocksTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn(plain)({ [value]: 0, [at]: i, [seen]: 0, [group]: 0 })); // Rows an entity tagged later moves after, so it lands far into that archetype. @@ -59,58 +59,58 @@ const seed = System.create({ name: "changeBlocksTest/seed" }, ({ commands }) => }); // Writers. -const setValue = System.create({ name: "changeBlocksTest/setValue", target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { +const setValue = System.create({ target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { row[value] += 1; }); -const replace = System.create({ name: "changeBlocksTest/replace" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { +const replace = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { for (const entity of input.entities) commands.add(entity, value, 7); }); -const despawn = System.create({ name: "changeBlocksTest/despawn" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { +const despawn = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { for (const entity of input.entities) commands.despawn(entity); }); -const tag = System.create({ name: "changeBlocksTest/tag" }, ({ commands }, input: { readonly entities: readonly Entity[] }) => { +const tag = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { for (const entity of input.entities) commands.add(entity, tagged); }); -const spawnOne = System.create({ name: "changeBlocksTest/spawnOne" }, ({ commands }, input: { readonly at: number }) => { +const spawnOne = System.create({}, ({ commands }, input: { readonly at: number }) => { commands.spawn(plain)({ [value]: 0, [at]: input.at, [seen]: 0, [group]: 0 }); }); /** Writes the rows whose `at` is in a list of up to four, which stamps row by row. */ -const writeSome = System.create({ name: "changeBlocksTest/writeSome", kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { +const writeSome = System.create({ kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c || here === input.d) row[value] += 1; }); /** Writes every row, which stamps chunks and blocks once per call. */ -const writeAll = System.create({ name: "changeBlocksTest/writeAll", kernel: "cpu", components: { write: [value] } }, row => { +const writeAll = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] += 1; }); -const setGroup = System.create({ name: "changeBlocksTest/setGroup", target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity }) => { +const setGroup = System.create({ target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity }) => { row[group] = 1; }); // Readers. let found: number[] = []; -const onMain = System.create({ name: "changeBlocksTest/onMain", components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); -const observed = System.create({ name: "changeBlocksTest/observed", observer: true, components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); -const ofTwo = System.create({ name: "changeBlocksTest/ofTwo", components: { read: [at], changed: [group, value] } }, row => { found.push(row[at]); }); -const inKernel = System.create({ name: "changeBlocksTest/inKernel", kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); -const inUp = System.create({ name: "changeBlocksTest/inUp", components: { read: [at], changed: [value] }, up: { through: parent, read: [value] } }, row => { found.push(row[at]); }); -const byGroup = Index.create({ name: "changeBlocksTest/byGroup", key: [group] }); +const onMain = System.create({ components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const observed = System.create({ observer: true, components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const ofTwo = System.create({ components: { read: [at], changed: [group, value] } }, row => { found.push(row[at]); }); +const inKernel = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); +const inUp = System.create({ components: { read: [at], changed: [value] }, up: { through: parent, read: [value] } }, row => { found.push(row[at]); }); +const byGroup = Index.create({ key: [group] }); let grouped: number[] = []; -const inGroup = System.create({ name: "changeBlocksTest/inGroup", lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { +const inGroup = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { const list = lookup.byGroup(1); grouped = Array.from({ length: list.length }, (_, i) => list.at(i).entity); }); // On a joined thread, by the object they read: a writer, and readers of its writes and of the world's thread's. -const seedNoted = System.create({ name: "changeBlocksTest/seedNoted" }, ({ commands }) => { +const seedNoted = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn(noted)({ [value]: 0, [at]: i })); }); -const noteSome = System.create({ name: "changeBlocksTest/noteSome", components: { read: [at], write: [note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { +const noteSome = System.create({ components: { read: [at], write: [note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { if (input.ats.includes(row[at])) row[note] = { text: "noted" }; }); -const notesThere = System.create({ name: "changeBlocksTest/notesThere", components: { read: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); -const valuesThere = System.create({ name: "changeBlocksTest/valuesThere", components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const notesThere = System.create({ components: { read: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const valuesThere = System.create({ components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); /** The `at` of the rows a main-thread reader ran, in order. */ const ranOn = async (world: World, reader: System): Promise => { @@ -256,16 +256,16 @@ describe("chunk and block ticks", () => { }); // Rows that have `extra` from the start, then two that gain it; and a JS `up` kernel writing every row's height. -const seedExtra = System.create({ name: "changeBlocksTest/seedExtra" }, ({ commands }) => { +const seedExtra = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn([at, extra, height])({ [at]: i, [extra]: 0, [height]: 0 })); for (let i = rows; i < rows + 2; i++) entities.push(commands.spawn([at, height])({ [at]: i, [height]: 0 })); }); -const addExtra = System.create({ name: "changeBlocksTest/addExtra" }, ({ commands }, input: { readonly entity: Entity }) => { +const addExtra = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, extra, 1); }); -const addedExtra = System.create({ name: "changeBlocksTest/addedExtra", components: { read: [at], added: [extra] } }, row => { found.push(row[at]); }); -const raiseHeights = System.create({ name: "changeBlocksTest/raiseHeights", kernel: "cpu", components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { +const addedExtra = System.create({ components: { read: [at], added: [extra] } }, row => { found.push(row[at]); }); +const raiseHeights = System.create({ kernel: "cpu", components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { row[height] = up === null ? 1 : up[height] + 1; }); -const heights = System.create({ name: "changeBlocksTest/heights", components: { read: [at], changed: [height] } }, row => { found.push(row[at]); }); +const heights = System.create({ components: { read: [at], changed: [height] } }, row => { found.push(row[at]); }); diff --git a/packages/ecs/src/implementation/world/changes-finalizers.test.ts b/packages/ecs/src/implementation/world/changes-finalizers.test.ts index 7fa51e4a..86cbf676 100644 --- a/packages/ecs/src/implementation/world/changes-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/changes-finalizers.test.ts @@ -21,14 +21,14 @@ declare module "@adobe/ecs" { // A changes finalizer sees each sync point's structural changes to the components it watches, grouped by how // they changed, with values before and after: for persistence, replication and undo. -Finalizer.changes({ name: "changesFinalizers/save", watch: [name, score], resources: { write: [journal] } }, (changes, { resources }) => { +Finalizer.changes({ watch: [name, score], resources: { write: [journal] } }, (changes, { resources }) => { const lines = changes.groups.flatMap(group => group.entities.map(entity => `${changes.previous(entity, name) ?? changes.next(entity, name)}: +${group.added.length} -${group.removed.length} score ${changes.previous(entity, score)} → ${changes.next(entity, score)}`)); resources[journal] = [...resources[journal], ...lines]; }); -const spawn = System.create({ name: "changesFinalizers/spawn" }, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); -const retire = System.create({ name: "changesFinalizers/retire", components: { has: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); +const retire = System.create({ components: { has: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); it("records each sync point's structural changes", async () => { await using world = createWorld(); diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts index da60b0cf..8ab9f24a 100644 --- a/packages/ecs/src/implementation/world/check-kernel.ts +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { declarationOf, labelOf } from '../../interface/registry/index.js'; import { layoutOf, samePath, type Layout, type Path } from '../../interface/registry/layout.js'; import type { Leaf } from '../../interface/system/leaf.js'; import type { KernelDescription } from './kernel.js'; @@ -32,8 +32,9 @@ const exactly = (layout: Layout | null, leaves: readonly Path[]): boolean => { * memory. Throws with the first mismatch. */ export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { - const { lookup, components: query, resources, up, reduce } = leaf.declaration; - const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; + const { lookup, components: query, resources, up } = leaf.declaration; + const fail = (message: string): never => { throw new Error(`Kernel "${leaf.name}": ${message}`); }; + const reduces = new Set(resources?.reduce); const queried = new Set([...query?.read ?? [], ...query?.write ?? []]); const written = new Set(query?.write); // A condition in `is` is readable too, and the compiler lists it with the reads. @@ -65,7 +66,8 @@ export const checkKernel = (leaf: Leaf, description: KernelDescription): void => if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); } for (const reduced of description.reductions) { - if (Reflect.get(reduce ?? {}, reduced.resource) !== reduced.operator) fail(`reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", which its reduce doesn't declare.`); + if (!reduces.has(reduced.resource)) fail(`reduces "${labelOf(reduced.resource)}", which isn't in its resources' reduce.`); + if (declarationOf(reduced.resource)?.reduce !== reduced.operator) fail(`reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", but it's declared reduce: "${labelOf(declarationOf(reduced.resource)?.reduce)}"; rebuild.`); if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) fail(`contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`); } for (const list of description.spawns) { @@ -80,7 +82,7 @@ export const checkKernel = (leaf: Leaf, description: KernelDescription): void => if (layout !== null && layout.kind !== "linear") fail(`gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`); } for (const shape of description.reducedShapes) { - if (Reflect.get(reduce ?? {}, shape.component) === undefined || !exactly(layoutOfKey(shape.component), shape.leaves)) { + if (!reduces.has(shape.component) || !exactly(layoutOfKey(shape.component), shape.leaves)) { fail(`contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`); } } diff --git a/packages/ecs/src/implementation/world/command-rings.cases.ts b/packages/ecs/src/implementation/world/command-rings.cases.ts index 1bc393d4..3e8d440e 100644 --- a/packages/ecs/src/implementation/world/command-rings.cases.ts +++ b/packages/ecs/src/implementation/world/command-rings.cases.ts @@ -19,7 +19,7 @@ export const commandRingsCases = (load: () => Promise) => descri const fixture = await load(); const alone = await fixture.failed(0); const { failures, weights, extents, spawned } = alone; - expect(failures).toEqual(["commandRingsTest/failing"]); + expect(failures).toEqual(["failing"]); // Every row but the first, which has no target, added its weight and spawned before reading its target; only // the last one's target is none. expect(weights).toEqual([null, ...Array.from({ length: 999 }, (_, i) => i + 1)]); diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index ad987853..d6a546c6 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -40,7 +40,7 @@ declare module "@adobe/ecs" { /** Objects: a nullable string starting at "unnamed", a nullable string, and a nullable number. */ -const seed = System.create({ name: "commandRingsTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); @@ -49,7 +49,6 @@ const seed = System.create({ name: "commandRingsTest/seed" }, ({ commands }, inp * of every seventh spawn's row, so a run of a few thousand rows overflows a worker's ring. */ const burst = System.create({ - name: "commandRingsTest/burst", kernel: "cpu", components: { read: [index] }, }, (row, { commands }, input: { readonly shift: number }) => { @@ -96,14 +95,13 @@ export const overflow = (count: number): Promise<{ readonly rows: readonly strin }; /** Rows targeting a live entity, but for the last, whose target never lived. */ -const targets = System.create({ name: "commandRingsTest/targets" }, ({ commands }, input: { readonly count: number }) => { +const targets = System.create({}, ({ commands }, input: { readonly count: number }) => { const first = commands.spawn([index])({ [index]: 0 }); for (let i = 1; i < input.count; i++) commands.spawn([index, target])({ [index]: i, [target]: i === input.count - 1 ? Entity.none : first }); }); /** A spawn and an add for each row, then a read through its target, which throws at the last row, whose target is none. */ const failing = System.create({ - name: "commandRingsTest/failing", kernel: "cpu", components: { read: [index, target] }, lookup: { targets: { read: [index] } }, @@ -133,14 +131,13 @@ export const failed = (count: number): Promise => using(createWorld({ [w /** A spawn for every 64th row: each chunk uses one position of its block of eight and returns the rest. */ const sparse = System.create({ - name: "commandRingsTest/sparse", kernel: "cpu", components: { read: [index], not: [made] }, }, (row, { commands }) => { if (row[index] % 64 === 0) commands.spawn([made])({ [made]: row[index] }); }); -const many = System.create({ name: "commandRingsTest/many" }, ({ commands }) => { +const many = System.create({}, ({ commands }) => { for (let i = 0; i < 5_000; i++) commands.spawn([made])({ [made]: 1_000_000 + i }); }); @@ -158,7 +155,6 @@ export const returned = (count: number): Promise<{ readonly spawned: number; rea /** Object components from a kernel: left out, null, and a number, by add and by spawn. */ const objects = System.create({ - name: "commandRingsTest/objects", kernel: "cpu", components: { read: [index] }, }, (row, { commands }) => { diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index d43d9653..997bfc7a 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -51,7 +51,7 @@ declare module "@adobe/ecs" { [level]: { type: number; default: 0 }; } } -Rule.create({ name: "commandsTest/slots", components: { has: [other, score] }, derive: [slot] }); +Rule.create({ components: { has: [other, score] }, derive: [slot] }); const unit = Archetype.create([position, score, tag]); const made = Archetype.create([score, tag]); @@ -63,20 +63,20 @@ const components = (world: World, entity: Entity) => /** Spawns and adds with literal values, which the plugin passes positionally, from one site over several lists. */ const literals: Entity[] = []; -const spawnLiterals = System.create({ name: "commandsTest/spawnLiterals" }, ({ commands }) => { +const spawnLiterals = System.create({}, ({ commands }) => { for (let i = 0; i < 6; i++) literals.push(commands.spawn(i % 3 === 0 ? defaulted : i % 3 === 1 ? made : [score, label])({ [score]: i })); }); -const addLiterals = System.create({ name: "commandsTest/addLiterals", components: { has: [score], not: [health] } }, (row, { commands }) => { +const addLiterals = System.create({ components: { has: [score], not: [health] } }, (row, { commands }) => { commands.add(row.entity, [health, label], { [label]: "x", [health]: 5 }); }); -const spawnFrom = System.create({ name: "commandsTest/spawnFrom", kernel: "cpu", components: { read: [score], not: [tag, other] } }, (row, { commands }) => { +const spawnFrom = System.create({ kernel: "cpu", components: { read: [score], not: [tag, other] } }, (row, { commands }) => { commands.spawn(made)({ [score]: row[score] + 10 }); }); -const mark = System.create({ name: "commandsTest/mark", kernel: "cpu", components: { has: [tag], not: [other] } }, (row, { commands }) => { +const mark = System.create({ kernel: "cpu", components: { has: [tag], not: [other] } }, (row, { commands }) => { commands.add(row.entity, marks, {}); }); -const unmark = System.create({ name: "commandsTest/unmark", kernel: "cpu", components: { has: [other] } }, (row, { commands }) => { +const unmark = System.create({ kernel: "cpu", components: { has: [other] } }, (row, { commands }) => { commands.remove(row.entity, marks); }); const defaulted = Archetype.create([score, label, tag]); @@ -85,7 +85,7 @@ const defaulted = Archetype.create([score, label, tag]); const withCommands = () => { const world = createWorld({ [workers]: 0 }); let body: (commands: Commands) => void = () => {}; - const issue = System.create({ name: "commandsTest/issue" }, ({ commands }) => body(commands)); + const issue = System.create({}, ({ commands }) => body(commands)); const run = async (next: (commands: Commands) => void): Promise => { body = next; const [failure] = (await runReported(world, issue)).failures; @@ -192,14 +192,14 @@ describe("literal values in a system's body", () => { }); /** Three adds of one component to each row, by systems ordered first to third, the second adding twice. */ -const first = System.create({ name: "commandsTest/firstAdd", kernel: "cpu", components: { has: [position] } }, (row, { commands }) => { +const first = System.create({ kernel: "cpu", components: { has: [position] } }, (row, { commands }) => { commands.add(row.entity, score, 1); }); -const second = System.create({ name: "commandsTest/secondAdd", components: { has: [position] }, after: [first] }, (row, { commands }) => { +const second = System.create({ components: { has: [position] }, after: [first] }, (row, { commands }) => { commands.add(row.entity, score, 2); commands.add(row.entity, score, 3); }); -const third = System.create({ name: "commandsTest/thirdAdd", kernel: "cpu", components: { has: [position] }, after: [second] }, (row, { commands }) => { +const third = System.create({ kernel: "cpu", components: { has: [position] }, after: [second] }, (row, { commands }) => { commands.add(row.entity, score, 4); }); @@ -258,9 +258,9 @@ describe("add and remove", () => { }); const added: Entity[] = []; const changed: Entity[] = []; - const watchAdded = System.create({ name: "commandsTest/watchAdded", components: { has: [score], added: [tag] } }, row => { added.push(row.entity); }); - const watchChanged = System.create({ name: "commandsTest/watchChanged", components: { has: [score], changed: [position] } }, row => { changed.push(row.entity); }); - const watch = System.create({ name: "commandsTest/watch", systems: [watchAdded, watchChanged] }); + const watchAdded = System.create({ components: { has: [score], added: [tag] } }, row => { added.push(row.entity); }); + const watchChanged = System.create({ components: { has: [score], changed: [position] } }, row => { changed.push(row.entity); }); + const watch = System.create({ systems: [watchAdded, watchChanged] }); await world.run(watch); added.length = changed.length = 0; await run(commands => { @@ -298,7 +298,7 @@ describe("spawners where code can't be generated", () => { spawned.push(commands.spawn([score, label])(values)); }); // A spawn with literal values in a system's body, which the plugin passes positionally. - const literal = System.create({ name: "commandsTest/literal" }, ({ commands }) => { + const literal = System.create({}, ({ commands }) => { spawned.push(commands.spawn([score, bit2, label])({ [label]: "b", [score]: 8 })); }); await world.run(literal); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index 2e7372ef..150cd9dc 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -6,35 +6,32 @@ import { workers } from "../../interface/resources/workers.js"; const seen: { readonly system: string; readonly input: unknown }[] = []; const move = System.create({ - name: "compositeInputTest/move", }, (_context, input: { readonly distance: number }) => { seen.push({ system: "move", input: { ...input } }); }); const step = System.create({ - name: "compositeInputTest/step", }, (_context, input: { readonly distance: number; readonly dt: number }) => { seen.push({ system: "step", input: { ...input } }); }); -const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); +const idle = System.create({}, () => {}); const label = System.create({ - name: "compositeInputTest/label", }, (_context, input: { readonly distance: string }) => { void input.distance; }); describe("composite input", () => { it("passes each member the fields of the run's input that it declares", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }), { distance: 1, dt: 0.5 }); + await world.run(System.create({ systems: [move, step, idle] }), { distance: 1, dt: 0.5 }); expect(seen).toEqual([{ system: "move", input: { distance: 1 } }, { system: "step", input: { distance: 1, dt: 0.5 } }]); }); it("rejects a field two members declare with different schemas, naming both", () => { // @ts-expect-error distance is a number in one and a string in the other - expect(() => System.create({ name: "compositeInputTest/conflict", systems: [move, label] })).toThrow('Systems "compositeInputTest/move" and "compositeInputTest/label" declare input field "distance" with different schemas.'); + expect(() => System.create({ systems: [move, label] })).toThrow('Systems "move" and "label" declare input field "distance" with different schemas.'); }); it("rejects a body taking input without the schema the plugin gives from its type, as one not written in place has", () => { const counting = (_context: unknown, input: { readonly count: number }): void => { void input.count; }; - expect(() => System.create({ name: "compositeInputTest/unbuilt" }, counting)).toThrow('System "compositeInputTest/unbuilt" takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with the kernel plugin.'); + expect(() => System.create({}, counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with the kernel plugin.'); }); it("rejects an input schema that isn't an object of named fields", () => { const create: (...parameters: unknown[]) => unknown = System.create; - expect(() => create({ name: "compositeInputTest/scalar" }, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('System "compositeInputTest/scalar": input is an object schema of named fields.'); + expect(() => create({ name: "scalar" }, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); }); }); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index 57506f1d..5a0a1df8 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -30,14 +30,14 @@ declare module "@adobe/ecs" { } -const pause = System.create({ name: "conditionsTest/pause", resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); -const tick = System.create({ name: "conditionsTest/tick", resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); -const afterTick = System.create({ name: "conditionsTest/afterTick", resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); -const frame = System.create({ name: "conditionsTest/frame", systems: [tick, afterTick] }); -const grow = System.create({ name: "conditionsTest/grow", kernel: "cpu", components: { write: [value] }, resources: { not: [paused] } }, row => { +const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); +const tick = System.create({ resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); +const afterTick = System.create({ resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); +const frame = System.create({ systems: [tick, afterTick] }); +const grow = System.create({ kernel: "cpu", components: { write: [value] }, resources: { not: [paused] } }, row => { row[value] = row[value] + 1; }); -const growWhilePaused = System.create({ name: "conditionsTest/growWhilePaused", kernel: "cpu", components: { write: [value] }, resources: { is: [paused] } }, row => { +const growWhilePaused = System.create({ kernel: "cpu", components: { write: [value] }, resources: { is: [paused] } }, row => { row[value] = row[value] + 1; }); @@ -53,10 +53,10 @@ describe("resource conditions", () => { it("read an `is` condition as holding", async () => { await using world = createWorld({ [workers]: 0 }); - const measure = System.create({ name: "conditionsTest/measure", resources: { is: [level], write: [sizes] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); + const measure = System.create({ resources: { is: [level], write: [sizes] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); await world.run(measure); expect(await readResource(world, sizes)).toBe(0); - const load = System.create({ name: "conditionsTest/load", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 3 }; }); + const load = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 3 }; }); await world.run(load); await world.run(measure); expect(await readResource(world, sizes)).toBe(3); @@ -65,12 +65,12 @@ describe("resource conditions", () => { it("keep a skipped system's lastRun, so it sees every change once it runs again", async () => { await using world = createWorld({ [workers]: 0 }); const seen: number[] = []; - const watch = System.create({ name: "conditionsTest/watch", components: { read: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); - const spawn = System.create({ name: "conditionsTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); + const watch = System.create({ components: { read: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); + const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); await world.run(spawn); await world.run(watch); expect(seen).toEqual([]); - await world.run(System.create({ name: "conditionsTest/ready", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 1 }; })); + await world.run(System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 1 }; })); await world.run(watch); expect(seen).toEqual([7]); }); @@ -78,7 +78,7 @@ describe("resource conditions", () => { it("leave a reduced resource as it was when every contributor is skipped", async () => { await using world = createWorld({ [workers]: 0 }); let next = 5; - const add = System.create({ name: "conditionsTest/add", reduce: { [total]: "+" }, resources: { not: [paused] } }, ({ reduce }) => { reduce[total](next++); }); + const add = System.create({ resources: { not: [paused], reduce: [total] } }, ({ reduce }) => { reduce[total](next++); }); await world.run(add); expect(await readResource(world, total)).toBe(5); await world.run(pause); @@ -88,15 +88,15 @@ describe("resource conditions", () => { it("reject conditions that are never null or false, or listed twice, for untyped declarations, when declared", () => { // @ts-expect-error a number is never null or false - expect(() => System.create({ name: "conditionsTest/number", resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); + expect(() => System.create({ resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); // @ts-expect-error both is and not - expect(() => System.create({ name: "conditionsTest/both", resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); + expect(() => System.create({ resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); // @ts-expect-error is already makes it readable - expect(() => System.create({ name: "conditionsTest/read", resources: { is: [level], read: [level] } }, ({ resources }) => { void resources[level]; })).toThrow("is already makes it readable"); + expect(() => System.create({ resources: { is: [level], read: [level] } }, ({ resources }) => { void resources[level]; })).toThrow("is already makes it readable"); }); it("skip a kernel the same way", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); await world.run(grow); await world.run(pause); await world.run(grow); @@ -105,7 +105,7 @@ describe("resource conditions", () => { }); it("run a kernel only while its `is` condition holds", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); await world.run(growWhilePaused); await world.run(pause); await world.run(growWhilePaused); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index 7fe5766e..9f0d7516 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -38,14 +38,12 @@ declare module "@adobe/ecs" { const spawn = System.create({ - name: "createTest/spawn", resources: { read: [spawnCount], changed: [spawnCount] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[spawnCount]; i++) commands.spawn([position, velocity, label])({ [position]: [i, 0], [velocity]: 1, [label]: `e${i}` }); }); const move = System.create({ - name: "createTest/move", components: { read: [velocity], write: [position], not: [frozen] }, after: [spawn], }, row => { @@ -55,7 +53,6 @@ const move = System.create({ /** Counts rows whose position may have changed since it last ran. */ const watch = System.create({ - name: "createTest/watch", components: { changed: [position] }, resources: { write: [seen] }, after: [move], @@ -65,11 +62,11 @@ const watch = System.create({ /** A kernel whose module can't be imported. */ const missing = attachKernel( - System.create({ name: "createTest/missing", kernel: "cpu", components: { has: [velocity] } }, () => {}), + System.create({ kernel: "cpu", components: { has: [velocity] } }, () => {}), { columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "/@missing/kernel.js", program: null, reductions: [], reducedShapes: [] }, ); -const main = System.create({ name: "createTest/main", systems: [spawn, move, watch] }); +const main = System.create({ systems: [spawn, move, watch] }); const positions = async (world: World): Promise => (await readEach(world, await select(world, { required: [position], order: { [position]: true } }), position)).map(value => value ?? [NaN, NaN]); @@ -102,7 +99,7 @@ describe("World.create", () => { it("applies the net structural change of a sync point", async () => { let first = Entity.none; let second = Entity.none; - const edit = System.create({ name: "createTest/edit/net" }, ({ commands }, input: { readonly action: string }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") { first = commands.spawn([velocity])({ [velocity]: 1 }); second = commands.spawn([velocity])({ [velocity]: 2 }); @@ -126,7 +123,7 @@ describe("World.create", () => { it("reuses a despawned index only after a sync point, with a new generation", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/reuse" }, ({ commands }, input: { readonly action: string }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") spawned.push(commands.spawn([velocity])({ [velocity]: spawned.length })); else for (const entity of spawned) commands.despawn(entity); }); @@ -145,7 +142,7 @@ describe("World.create", () => { it("keeps rows consistent across swap-removes and archetype growth", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/rows" }, ({ commands }, input: { readonly action: string }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 100; i++) spawned.push(commands.spawn([velocity, label])({ [velocity]: i, [label]: `${i}` })); else for (let i = 0; i < 100; i += 3) commands.remove(spawned[i], label); }); @@ -161,11 +158,11 @@ describe("World.create", () => { it("reads a component the world had no id for at an earlier read, once it has one", async () => { await using world = createWorld(); - const spawnPlain = System.create({ name: "createTest/spawnPlain" }, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); }); + const spawnPlain = System.create({}, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); }); await world.run(spawnPlain); const [entity] = await select(world, { required: [velocity] }); expect(await read(world, entity, frozen)).toBe(null); - const freeze = System.create({ name: "createTest/freeze" }, ({ commands }, input: { readonly entity: Entity }) => { + const freeze = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, frozen, true); }); await world.run(freeze, { entity }); @@ -173,7 +170,7 @@ describe("World.create", () => { }); it("hides the resource entity from queries and commands", async () => { - const edit = System.create({ name: "createTest/edit/hidden", components: {} }, (row, { commands }) => { + const edit = System.create({ components: {} }, (row, { commands }) => { commands.despawn(row.entity); }); const world = createWorld(); @@ -183,14 +180,14 @@ describe("World.create", () => { }); it("throws at creating a composite with an unordered conflict, naming nothing", () => { - const push = System.create({ name: "createTest/push", components: { write: [position] } }, row => { row[position] = [0, 0]; }); - expect(() => System.create({ name: "createTest/conflicted", systems: [move, push] })).toThrow('"createTest/move" and "createTest/push"'); - expect(() => System.create({ name: "createTest/conflicted", systems: [move] })).not.toThrow(); + const push = System.create({ components: { write: [position] } }, row => { row[position] = [0, 0]; }); + expect(() => System.create({ systems: [move, push] })).toThrow('"move" and "push"'); + expect(() => System.create({ systems: [move] })).not.toThrow(); }); it("gives a body's context only the kinds its declaration declares", async () => { let keys: string[] = []; - const probe = System.create({ name: "createTest/keys", resources: { read: [seen] } }, context => { + const probe = System.create({ resources: { read: [seen] } }, context => { keys = Object.keys(context); void context.resources[seen]; }); @@ -201,24 +198,24 @@ describe("World.create", () => { it("rejects a key listed in both read and write, since write already makes it readable", () => { // @ts-expect-error write already makes it readable - expect(() => System.create({ name: "createTest/both", components: { read: [velocity], write: [velocity] } }, row => { row[velocity] += 1; })) + expect(() => System.create({ components: { read: [velocity], write: [velocity] } }, row => { row[velocity] += 1; })) .toThrow(`lists "createTest/velocity" in both components read and write`); }); it("reports a system that throws, still applying its commands", async () => { - const fail = System.create({ name: "createTest/fail" }, ({ commands }) => { + const fail = System.create({}, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); throw new Error("boom"); }); const world = createWorld(); const [failure] = (await runReported(world, fail)).failures; - expect([failure.kind, failure.name, failure.message]).toEqual(["system", "createTest/fail", "boom"]); + expect([failure.kind, failure.name, failure.message]).toEqual(["system", "fail", "boom"]); expect(failure.stack).toContain("boom"); expect(await count(world, { required: [velocity] })).toBe(1); }); it("gives every run a report, with no errors for runs with nothing to report", async () => { - const quiet = System.create({ name: "createTest/quiet" }, () => {}); + const quiet = System.create({}, () => {}); await using world = createWorld(); const first = await world.run(quiet); expect(await world.run(quiet)).toBe(first + 1); @@ -228,33 +225,32 @@ describe("World.create", () => { }); it("reports what isn't an Error as text, without a stack", async () => { - const fail = System.create({ name: "createTest/failText" }, () => { + const fail = System.create({}, () => { // A value that isn't an Error, which user code may throw. throw { code: 7 }; }); await using world = createWorld(); - expect((await runReported(world, fail)).failures).toEqual([{ kind: "system", name: "createTest/failText", message: "[object Object]", stack: null }]); + expect((await runReported(world, fail)).failures).toEqual([{ kind: "system", name: "fail", id: null, message: "[object Object]", stack: null }]); }); it("reports the systems a failed tier skips, and the commands that did nothing", async () => { - const doomed = System.create({ name: "createTest/doomed" }, ({ commands }) => { + const doomed = System.create({}, ({ commands }) => { const entity = commands.spawn([velocity])({ [velocity]: 1 }); commands.despawn(entity); commands.add(entity, frozen, true); commands.despawn(entity); }); - const fails = System.create({ name: "createTest/failsFirst" }, () => { throw new Error("first"); }); - const later = System.create({ name: "createTest/later", after: [fails] }, () => {}); + const fails = System.create({}, () => { throw new Error("first"); }); + const later = System.create({ after: [fails] }, () => {}); await using world = createWorld(); expect(await runReported(world, doomed)).toEqual({ failures: [], skipped: [], dropped: 2, warnings: [] }); - const report = await runReported(world, System.create({ name: "createTest/failedTier", systems: [fails, later] })); - expect(report.skipped).toEqual(["createTest/later"]); - expect(report.failures.map(failure => failure.name)).toEqual(["createTest/failsFirst"]); + const report = await runReported(world, System.create({ systems: [fails, later] })); + expect(report.skipped).toEqual(["later"]); + expect(report.failures.map(failure => failure.name)).toEqual(["fails"]); }); it("skips every row when a system's only filter is an unchanged resource", async () => { const count = System.create({ - name: "createTest/count", components: { has: [velocity] }, resources: { write: [seen], changed: [spawnCount] }, }, (_, { resources }) => { @@ -270,7 +266,7 @@ describe("World.create", () => { it("doesn't let a lookup match the resource entity", async () => { let found: object | null | undefined; - const probe = System.create({ name: "createTest/probe", lookup: { any: {} } }, ({ lookup: { any } }) => { + const probe = System.create({ lookup: { any: {} } }, ({ lookup: { any } }) => { // The resource entity is entity 1, since index 0 is never an entity and registration reserves the next first. // @ts-expect-error an untyped caller can pass any number found = any(1); @@ -289,15 +285,15 @@ describe("World.create", () => { it("sees a change moved into an older chunk by a swap-remove", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/swap" }, ({ commands }, input: { readonly action: string }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 2100; i++) spawned.push(commands.spawn([velocity])({ [velocity]: i })); else commands.despawn(spawned[5]); }); - const writer = System.create({ name: "createTest/writer", components: { write: [velocity] } }, row => { + const writer = System.create({ components: { write: [velocity] } }, row => { if (row[velocity] === 2099) row[velocity] = -1; }); const changes: number[] = []; - const observer = System.create({ name: "createTest/observer", components: { read: [velocity], changed: [velocity] } }, row => { + const observer = System.create({ components: { read: [velocity], changed: [velocity] } }, row => { changes.push(row[velocity]); }); const world = createWorld(); @@ -312,7 +308,7 @@ describe("World.create", () => { }); it("selects with where and order", async () => { - const edit = System.create({ name: "createTest/edit/select" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([position, velocity])({ [position]: [i % 3, i], [velocity]: i }); }); const world = createWorld(); @@ -329,7 +325,7 @@ describe("World.create", () => { it("spawns components named by symbols", async () => { let spawned = Entity.none; - const edit = System.create({ name: "createTest/edit/symbols" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { spawned = commands.spawn([velocity, marker])({ [velocity]: 2, [marker]: 7 }); }); const world = createWorld(); @@ -354,14 +350,14 @@ describe("World.create", () => { await using world = createWorld(); expect(await readResource(world, gpuDevice)).toBeNull(); let seen: unknown = undefined; - await world.run(System.create({ name: "createTest/readDevice", resources: { read: [gpuDevice] } }, ({ resources }) => { seen = resources[gpuDevice]; })); + await world.run(System.create({ resources: { read: [gpuDevice] } }, ({ resources }) => { seen = resources[gpuDevice]; })); expect(seen).toBeNull(); }); it("accepts a system declared again the same under its id, as a module evaluated again declares it, and rejects a different one", async () => { - const first = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); - const again = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); - expect(() => System.create(identify({ name: "createTest/named" }, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "createTest/named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + const first = System.create(identify({ resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); + const again = System.create(identify({ resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); + expect(() => System.create(identify({}, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); await using world = createWorld(); await world.run(first); await world.run(again); diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index 486bc158..0709448d 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -49,7 +49,7 @@ import { createState, defaultOf, idOf, reside, startTicking, type Key } from './ import { indexOf } from './entities.js'; import { isHeld } from './transfers.js'; import type { ReadonlyValue as ReadonlyResourceValue } from '../../interface/resource/value.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf } from '../../interface/registry/index.js'; type Queue = (task: () => T | Promise) => Promise; @@ -431,10 +431,10 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti if (workersChanged()) await followWorkers(); if (deviceChanged()) await followDevice(); const schedule = registrar.registered(definition) ?? registered; - if (schedule === undefined) throw new Error(`System "${labelOfItem(definition)}" didn't register.`); + if (schedule === undefined) throw new Error(`System "${definition.name}" didn't register.`); // An input that moved to a joined thread runs only there: that thread went since, its claims with it. if (isHeld(value) && (definition.kind === "composite" || registrar.runnerOf(definition).remote?.channel !== value.channel)) { - throw new Error(`System "${labelOfItem(definition)}": the run's input moved to a joined thread that's gone.`); + throw new Error(`System "${definition.name}": the run's input moved to a joined thread that's gone.`); } const run = ++runs; const started = performance.now(); @@ -526,7 +526,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti await complete(registrar.register(system)); } catch (error) { - (observerFailures ??= []).push(failureOf("system", labelOfItem(system), error)); + (observerFailures ??= []).push(failureOf("system", system, error)); } }); }; @@ -604,7 +604,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti } startRan(state.ran); values = valuesOf(run, performance.now(), 0); - report = { failures: [failureOf("system", labelOfItem(composite), error)], skipped: none, dropped: 0, warnings: none }; + report = { failures: [failureOf("system", composite, error)], skipped: none, dropped: 0, warnings: none }; } report = withFailures(report, takeFailures()); const tails = tailing; diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts index 44b0eb27..2b62e4d8 100644 --- a/packages/ecs/src/implementation/world/dispatch.fixture.ts +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -32,27 +32,27 @@ declare module "@adobe/ecs" { } } -const scatter = System.create({ name: "dispatchTest/scatter" }, ({ commands }) => { +const scatter = System.create({}, ({ commands }) => { for (let i = 0; i < 128 * 64; i++) { const components: (typeof position | typeof velocity | typeof offset | (typeof tags)[number])[] = [position, velocity, offset]; tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); } }); -const seed = System.create({ name: "dispatchTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); }); -const fastA = System.create({ name: "dispatchTest/fastA", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const fastA = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; row[position] = [x + vx, y + vy]; }); // Commands make it a JS kernel, dispatched by message. -const slowB = System.create({ name: "dispatchTest/slowB", kernel: "cpu", components: { read: [velocity] } }, (row, { commands }) => { +const slowB = System.create({ kernel: "cpu", components: { read: [velocity] } }, (row, { commands }) => { const [vx] = row[velocity]; if (vx > 100) commands.despawn(row.entity); }); -const fastC = System.create({ name: "dispatchTest/fastC", kernel: "cpu", components: { read: [velocity], write: [offset] } }, row => { +const fastC = System.create({ kernel: "cpu", components: { read: [velocity], write: [offset] } }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -60,15 +60,15 @@ const fastC = System.create({ name: "dispatchTest/fastC", kernel: "cpu", compone const withTimeout = (promise: Promise, ms: number): Promise => Promise.race([promise, new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), ms))]); -const grow = System.create({ name: "dispatchTest/seed/2" }, ({ commands }) => { +const grow = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 1], [velocity]: [2, i % 3], [offset]: 0 }); }); let observedRows = 0; -const bump = System.create({ name: "dispatchTest/fastA/2", kernel: "cpu", components: { write: [offset] } }, row => { +const bump = System.create({ kernel: "cpu", components: { write: [offset] } }, row => { row[offset] = row[offset] + 1; }); -const watch = System.create({ name: "dispatchTest/slowB/2", components: { changed: [offset] } }, () => { observedRows++; }); -const later = System.create({ name: "dispatchTest/fastC/2", kernel: "cpu", components: { read: [velocity], write: [offset] }, after: [watch] }, row => { +const watch = System.create({ components: { changed: [offset] } }, () => { observedRows++; }); +const later = System.create({ kernel: "cpu", components: { read: [velocity], write: [offset] }, after: [watch] }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -102,7 +102,7 @@ export const afterFailedRegistration = (failing = 2): Promise<{ readonly rejecte WebAssembly.compile = (bytes: BufferSource) => (++calls === failing ? Promise.reject(new Error("injected")) : original(bytes)); let rejected = "resolved"; try { - await world.run(System.create({ name: "dispatchTest/tier/2", systems: [watch, later] })); + await world.run(System.create({ systems: [watch, later] })); } catch (error) { rejected = String(error); @@ -149,7 +149,7 @@ let tiers = 0; /** A tier of `systems` run `frames` times on `count` workers after `prepare`: "ok", or "timeout" if a run hung. */ const runTier = (systems: readonly System[], count: number, frames: number, prepare: System): Promise<"ok" | "timeout"> => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { - const tier = System.create({ name: `dispatchTest/tier/${3 + tiers++}`, systems: [...systems] }); + const tier = System.create({ systems: [...systems] }); await world.run(prepare); let result: unknown = "ok"; for (let frame = 0; frame < frames && result !== "timeout"; frame++) result = await withTimeout(world.run(tier), 3000); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 26fd861f..6f4fae50 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -31,7 +31,7 @@ declare module "@adobe/ecs" { * whether its mark changed in the next digit, then whether it has a mark and isn't banned. */ const watchKernel = System.create({ - name: "entityLookupWorkersTest/watchKernel", kernel: "cpu", + kernel: "cpu", components: { read: [follows], write: [kernelSeen] }, lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, }, (row, { lookup }) => { @@ -44,7 +44,6 @@ const watchKernel = System.create({ }); const watchPlain = System.create({ - name: "entityLookupWorkersTest/watchPlain", components: { read: [follows], write: [plainSeen] }, lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, }, (row, { lookup }) => { @@ -56,17 +55,17 @@ const watchPlain = System.create({ row[plainSeen] = ((row[plainSeen] * 4 + seen) % 1000003 * 4 + flagged) * 2 + eligible; }); -const tick = System.create({ name: "entityLookupWorkersTest/tick", systems: [watchKernel, watchPlain] }); +const tick = System.create({ systems: [watchKernel, watchPlain] }); const targets: Entity[] = []; -const seed = System.create({ name: "entityLookupWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { targets.length = 0; for (let k = 0; k < 500; k++) targets.push(k % 3 === 0 ? commands.spawn([weight, mark])({ [weight]: k, [mark]: 1 }) : commands.spawn([weight])({ [weight]: k })); for (let k = 0; k < 4000; k++) commands.spawn([follows, kernelSeen, plainSeen])({ [follows]: targets[(k * 7) % targets.length], [kernelSeen]: 0, [plainSeen]: 0 }); }); /** Writes some weights, marks and unmarks some targets, despawns some and spawns others: a different change each round. */ -const stir = System.create({ name: "entityLookupWorkersTest/stir", components: { write: [weight] } }, (row, { commands }, input: { readonly round: number }) => { +const stir = System.create({ components: { write: [weight] } }, (row, { commands }, input: { readonly round: number }) => { const w = row[weight]; if ((w + input.round) % 5 === 0) row[weight] = w + 1000; if ((w + input.round) % 11 === 0) commands.add(row.entity, mark, 2); diff --git a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts index e380da8f..4f8aacad 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts @@ -7,7 +7,7 @@ import { createWorld, join, type CreatedWorld } from "./create.js"; import { edit, probe, scene, threadProbe, value } from "./entity-lookups.test-helper.js"; /** Writes every `value` on the GPU, which stamps its whole column at once. */ -const bumpOnGpu = System.create({ name: "entityLookupsTest/bumpOnGpu", kernel: "gpu", components: { write: [value] } }, row => { +const bumpOnGpu = System.create({ kernel: "gpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index 309476dd..706a9b82 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -65,7 +65,6 @@ const seenOf = (subject: Entity, lookup: { /** On the world's thread. */ export const probe = System.create({ - name: "entityLookupsTest/probe", components: { read: [target] }, lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, }, (row, { lookup }) => { @@ -74,7 +73,6 @@ export const probe = System.create({ /** The same, registered later, so its first run comes after every change. */ export const lateProbe = System.create({ - name: "entityLookupsTest/lateProbe", components: { read: [target] }, lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, }, (row, { lookup }) => { @@ -83,7 +81,6 @@ export const lateProbe = System.create({ /** On thread "entityLookupsTest", as it reads the probe's label there. */ export const threadProbe = System.create({ - name: "entityLookupsTest/threadProbe", components: { read: [target, label] }, lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, }, (row, { lookup }) => { @@ -91,23 +88,22 @@ export const threadProbe = System.create({ }); /** Writes every `value` in its rows, stamping them. */ -export const bump = System.create({ name: "entityLookupsTest/bump", components: { write: [value] } }, row => { +export const bump = System.create({ components: { write: [value] } }, row => { row[value] += 1; }); /** The same as a kernel, whose stamps are muted unless something watches `value`. */ -export const bumpKernel = System.create({ name: "entityLookupsTest/bumpKernel", kernel: "cpu", components: { write: [value] } }, row => { +export const bumpKernel = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); /** Writes every `other`, which no lookup watches. */ -export const touch = System.create({ name: "entityLookupsTest/touch", components: { write: [other] } }, row => { +export const touch = System.create({ components: { write: [other] } }, row => { row[other] += 1; }); /** Changes the subject by command: `action` names how. */ export const edit = System.create({ - name: "entityLookupsTest/edit", }, ({ commands }, input: { readonly action: string }) => { const { subject } = scene; switch (input.action) { @@ -128,7 +124,7 @@ export const edit = System.create({ }); /** Points every probe at the subject. */ -export const retarget = System.create({ name: "entityLookupsTest/retarget", components: { write: [target] } }, row => { +export const retarget = System.create({ components: { write: [target] } }, row => { row[target] = scene.subject; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test.ts b/packages/ecs/src/implementation/world/entity-lookups.test.ts index c7b6932e..068b3ec2 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test.ts @@ -13,14 +13,14 @@ import { runReported } from "./reports.test-helper.js"; /** What a finalizer's lookup gave as probes were despawned. */ const finalized: (boolean | null)[] = []; -Finalizer.create({ name: "entityLookupsTest/finalize", components: { read: [target] }, lookup: { found: { read: [value], changed: [value] } } }, (row, { lookup }) => { +Finalizer.create({ components: { read: [target] }, lookup: { found: { read: [value], changed: [value] } } }, (row, { lookup }) => { finalized.push(lookup.found(row[target])?.changed ?? null); }); -const despawnProbes = System.create({ name: "entityLookupsTest/despawnProbes", components: { has: [target] } }, (row, { commands }) => { +const despawnProbes = System.create({ components: { has: [target] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const keepTicking = System.create({ name: "entityLookupsTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** A world shared with a thread on this one, through a MessageChannel, which claims the probes' thread. */ const joined = async () => { @@ -58,7 +58,7 @@ describe("lookups by entity with changed", () => { const { seen, reused } = await scenario(world, threadProbe); expect(reused).toBe(true); expect(seen).toEqual(expected); - expect((await runReported(world, threadProbe)).warnings.map(warning => warning.name)).toEqual(["entityLookupsTest/threadProbe"]); + expect((await runReported(world, threadProbe)).warnings.map(warning => warning.name)).toEqual(["threadProbe"]); await remote.dispose(); await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/execute.ts b/packages/ecs/src/implementation/world/execute.ts index 0bbcf394..5682cf50 100644 --- a/packages/ecs/src/implementation/world/execute.ts +++ b/packages/ecs/src/implementation/world/execute.ts @@ -19,7 +19,7 @@ import type { RunReport } from './failures.js'; import { liftColumnStamps, type RemoteSystem, type TaskChannel } from './remote-systems.js'; import { failureOf } from './failures.js'; import { checkCommands } from './remote.js'; -import { firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { firstKeyOf } from '../../interface/registry/index.js'; import { mergeUses, refreshUses, type IndexUse } from './indexes.js'; /** Shared by every report without failures or skipped systems: they're read-only, so none needs its own. */ @@ -59,7 +59,7 @@ export const execute = async (context: ExecuteContext, schedule: Schedule, args: // An earlier run's GPU work reports its errors to this run: waiting for them there would hold every run until the // GPU process catches up. const earlier = gpu.takeError(); - const failures = earlier === null ? null : [failureOf("gpu", earlier.name, earlier.message)]; + const failures = earlier === null ? null : [failureOf("gpu", earlier, earlier.message)]; gpu.device.pushErrorScope("out-of-memory"); gpu.device.pushErrorScope("validation"); try { @@ -252,14 +252,14 @@ const isResults = (result: ChunkResult | readonly ChunkResult[]): result is read */ const settled = (state: State, folds: ReadonlyMap | null, outcome: Outcome, runner: Runner, leaf: { readonly name: string }, error: unknown): boolean => { if (error === gone) { - const label = labelOfItem(leaf); - (outcome.failures ??= []).push(failureOf("thread", label, runner.remote?.channel.goneError())); + const label = leaf.name; + (outcome.failures ??= []).push(failureOf("thread", leaf, runner.remote?.channel.goneError())); (outcome.skipped ??= []).push(label); if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, null, true); return false; } const failed = error !== undefined; - if (failed) (outcome.failures ??= []).push(failureOf("system", labelOfItem(leaf), error)); + if (failed) (outcome.failures ??= []).push(failureOf("system", leaf, error)); if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); return failed; }; @@ -338,7 +338,7 @@ const runFused = async (context: ExecuteContext, schedule: Schedule, prepared: P } if (gpu !== null) markWrites(state, gpu, runners); if (failed) { - for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf.name); return -1; } } @@ -435,7 +435,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn started[i] = noChunks; const failed = error !== undefined; if (failed) { - (outcome.failures ??= []).push(failureOf("system", labelOfItem(tier[i]), error)); + (outcome.failures ??= []).push(failureOf("system", tier[i], error)); tierFailed = true; } if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); @@ -444,7 +444,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn countRan(state, runners); if (gpu !== null) markWrites(state, gpu, runners); if (tierFailed) { - for (let later = t + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + for (let later = t + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf.name); break; } } @@ -485,7 +485,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn // Applied even when the readback fails, which only a lost device makes it do: the CPU state // stays whole. dropped = state.plan.dropped; - for (const { finalizer, error, kind } of applySync(state)) (outcome.failures ??= []).push(failureOf(kind ?? "finalizer", labelOfItem(finalizer), error)); + for (const { finalizer, error, kind } of applySync(state)) (outcome.failures ??= []).push(failureOf(kind ?? "finalizer", finalizer, error)); } } } diff --git a/packages/ecs/src/implementation/world/failures.ts b/packages/ecs/src/implementation/world/failures.ts index 0c4d09da..93a23202 100644 --- a/packages/ecs/src/implementation/world/failures.ts +++ b/packages/ecs/src/implementation/world/failures.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Failure, Warning } from '../../interface/report/failure.js'; +import { idOrNull } from '../../interface/registry/index.js'; /** What went wrong in a run or tick, before the world spawns its report: empty lists and a zero when it's clean. */ export type RunReport = { @@ -9,7 +10,14 @@ export type RunReport = { readonly warnings: readonly Warning[]; }; -/** A failure as data: an Error's message and stack, or String() of anything else thrown. */ -export const failureOf = (kind: Failure["kind"], name: string, error: unknown): Failure => error instanceof Error - ? { kind, name, message: error.message, stack: typeof error.stack === "string" ? error.stack : null } - : { kind, name, message: String(error), stack: null }; +/** + * A failure of `of` as data, labeled by its `name` and its `id`, or for an item without `id` the id the plugin gave it: + * an Error's message and stack, or String() of anything else thrown. + */ +export const failureOf = (kind: Failure["kind"], of: { readonly name: string; readonly id?: string | null }, error: unknown): Failure => { + const { name } = of; + const id = of.id !== undefined ? of.id : idOrNull(of); + return error instanceof Error + ? { kind, name, id, message: error.message, stack: typeof error.stack === "string" ? error.stack : null } + : { kind, name, id, message: String(error), stack: null }; +}; diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index 487a039b..c9b5fc2d 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -54,10 +54,10 @@ declare module "@adobe/ecs" { } -Rule.create({ name: "finalizersTest/slots", components: { has: [source] }, derive: [slot] }); +Rule.create({ components: { has: [source] }, derive: [slot] }); let steps: ((commands: System.Commands) => void)[] = []; -const edit = System.create({ name: "finalizersTest/edit" }, ({ commands }, input: { readonly step: number }) => { +const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { steps[input.step]?.(commands); }); const step = (world: ReturnType) => (run: (commands: System.Commands) => void) => { @@ -68,7 +68,7 @@ const step = (world: ReturnType) => (run: (commands: System. describe("finalizers", () => { it("run for each entity that stops matching, by any cause, reading its values before the sync point applies", async () => { const seen: [Entity, number][] = []; - Finalizer.create({ name: "finalizersTest/release", components: { read: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); + Finalizer.create({ components: { read: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); const spawned: Entity[] = []; @@ -92,7 +92,7 @@ describe("finalizers", () => { it("run for rules' and references' removals too, and at disposal for every live match", async () => { const seen: number[] = []; - Finalizer.create({ name: "finalizersTest/slotReleased", components: { read: [slot, source] } }, row => { seen.push(row[source]); }); + Finalizer.create({ components: { read: [slot, source] } }, row => { seen.push(row[source]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); let root = Entity.none, child = Entity.none; @@ -111,7 +111,6 @@ describe("finalizers", () => { it("write resources at the sync point, queue commands for the next one, and see lookups as they were", async () => { const found: (number | null)[] = []; Finalizer.create({ - name: "finalizersTest/count", components: { read: [counted] }, lookup: { handles: { read: [counted] } }, resources: { write: [released] }, @@ -135,11 +134,11 @@ describe("finalizers", () => { it("leave their commands for the next run's sync point, even when that run first adds resources", async () => { let other = Entity.none; - Finalizer.create({ name: "finalizersTest/pending", components: { read: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); - const reads = System.create({ name: "finalizersTest/reads", resources: { read: [level] } }, ({ resources }) => { void resources[level]; }); + Finalizer.create({ components: { read: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); + const reads = System.create({ resources: { read: [level] } }, ({ resources }) => { void resources[level]; }); // Read after every run, as a read through a run of its own would apply the commands left for it. let seen: string | null = null; - const watch = System.create({ name: "finalizersTest/watchOther", observer: true, lookup: { labels: { read: [label] } } }, ({ lookup }) => { + const watch = System.create({ observer: true, lookup: { labels: { read: [label] } } }, ({ lookup }) => { seen = lookup.labels(other)?.[label] ?? null; }); const world = createWorld({ [workers]: 0 }); @@ -155,7 +154,7 @@ describe("finalizers", () => { }); it("keep reservations they make apart from the indexes their sync point frees", async () => { - Finalizer.create({ name: "finalizersTest/respawn", components: { read: [respawned] } }, (row, { commands }) => { + Finalizer.create({ components: { read: [respawned] } }, (row, { commands }) => { commands.spawn([respawned])({ [respawned]: row[respawned] + 100 }); }); const world = createWorld({ [workers]: 0 }); @@ -173,21 +172,22 @@ describe("finalizers", () => { it("run in their order, and a throw still applies the sync point and is reported", async () => { const order: string[] = []; - const second = Finalizer.create({ name: "finalizersTest/second", components: { read: [ordered] } }, () => { order.push("second"); }); - Finalizer.create({ name: "finalizersTest/first", components: { read: [ordered] }, before: [second] }, () => { order.push("first"); }); - Finalizer.create({ name: "finalizersTest/throws", components: { read: [ordered] } }, () => { throw new Error("finalizer failed"); }); + const second = Finalizer.create({ components: { read: [ordered] } }, () => { order.push("second"); }); + Finalizer.create({ components: { read: [ordered] }, before: [second] }, () => { order.push("first"); }); + const throwing = Finalizer.create({ components: { read: [ordered] } }, () => { throw new Error("finalizer failed"); }); + void throwing; const world = createWorld({ [workers]: 0 }); const run = step(world); let a = Entity.none; await run(commands => { a = commands.spawn([ordered])({ [ordered]: 1 }); }); const { failures } = await reportOf(world, await run(commands => commands.despawn(a))); - expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "finalizer", name: "finalizersTest/throws", message: "finalizer failed" }]); + expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "finalizer", name: "throwing", message: "finalizer failed" }]); expect(order).toEqual(["first", "second"]); expect(await read(world, a, handle)).toBeNull(); - const writer = (name: "finalizersTest/writerA" | "finalizersTest/writerB") => - Finalizer.create({ name, components: { read: [writing] }, resources: { write: [written] } }, (_, { resources }) => { resources[written] = 1; }); - writer("finalizersTest/writerA"); - expect(() => writer("finalizersTest/writerB")).toThrow("order them with before or after"); + const writer = (name: "writerA" | "writerB") => + Finalizer.create({ components: { read: [writing] }, resources: { write: [written] } }, (_, { resources }) => { resources[written] = 1; }); + writer("writerA"); + expect(() => writer("writerB")).toThrow("order them with before or after"); await world.dispose(); }); }); @@ -195,15 +195,15 @@ describe("finalizers", () => { describe("a finalizer declared again the same, as a module evaluated again declares it", () => { it("keeps its place in each world, and runs its new body once", async () => { const seen: string[] = []; - const first = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); + const first = Finalizer.create(identify({ components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); const world = createWorld({ [workers]: 0 }); let entity = Entity.none; await step(world)(commands => { entity = commands.spawn([reloaded])({ [reloaded]: 1 }); }); - const again = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); + const again = Finalizer.create(identify({ components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); expect(again).toBe(first); await step(world)(commands => { commands.despawn(entity); }); expect(seen).toEqual(["again"]); - expect(() => Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.create(identify({ components: { read: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); await world.dispose(); }); }); @@ -213,7 +213,7 @@ describe("a finalizer created after a world has entities it matches", () => { const world = createWorld({ [workers]: 0 }); await step(world)(commands => { commands.spawn([disposing])({ [disposing]: 4 }); }); const seen: number[] = []; - Finalizer.create({ name: "finalizersTest/late", components: { read: [disposing] }, resources: { read: [level] } }, row => { seen.push(row[disposing]); }); + Finalizer.create({ components: { read: [disposing] }, resources: { read: [level] } }, row => { seen.push(row[disposing]); }); await world.dispose(); expect(seen).toEqual([4]); }); @@ -222,7 +222,7 @@ describe("a finalizer created after a world has entities it matches", () => { describe("changes finalizers", () => { it("watch by default every component that isn't derived, including ones first used later, and no resources", async () => { const seen: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/everything", resources: { read: [level] } }, (changes, { resources }) => { + Finalizer.changes({ resources: { read: [level] } }, (changes, { resources }) => { void resources[level]; for (const group of changes.groups) seen.push(group.to); }); @@ -235,7 +235,7 @@ describe("changes finalizers", () => { it("see each sync point's structural changes to their components, grouped, with values before and after", async () => { const groups: unknown[] = []; const values: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/changes", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + Finalizer.changes({ watch: [handle, label] }, ({ groups: seen, previous, next }) => { for (const group of seen) { groups.push({ ...group, entities: group.entities.length }); for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); @@ -263,7 +263,7 @@ describe("changes finalizers", () => { it("see a value an add gives a component the entity keeps as replaced, removed and added again or not", async () => { const groups: unknown[] = []; const values: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/replacing", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + Finalizer.changes({ watch: [handle, label] }, ({ groups: seen, previous, next }) => { for (const group of seen) { groups.push({ ...group, entities: group.entities.length }); for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md index e56be80a..daa9aff8 100644 --- a/packages/ecs/src/implementation/world/generated-code.md +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -31,7 +31,7 @@ const parse = (code: string) => estreeOf(parseAst(code)); test("show", async () => { const source = `import { System, Vec3 } from "@adobe/ecs"; const position = "position"; const velocity = "velocity"; -const move = System.create({ name: "move", kernel: "gpu", components: { read: [velocity], write: [position] } }, +const move = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); });`; // Resolves the package to its source and relative imports to files, stripping types as the plugin does. const linker = createLinker({ diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 92d43679..1570ecd5 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -46,48 +46,48 @@ declare module "@adobe/ecs" { const count = 5000; -const spawn = System.create({ name: "gpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < count; i++) { if (i % 2 === 0) commands.spawn([position, velocity])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5] }); else commands.spawn([position, velocity, tag])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5], [tag]: true }); } }); -const move = System.create({ name: "gpuTest/move", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const move = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy * 2, pz + vz]; }); /** Between two GPU kernels: reads what the first wrote, and changes what both read. */ -const reset = System.create({ name: "gpuTest/reset", components: { write: [position, velocity] }, after: [move] }, row => { +const reset = System.create({ components: { write: [position, velocity] }, after: [move] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px * 2, py, pz + 100]; row[velocity] = [vz, vx, vy]; }); -const moveAgain = System.create({ name: "gpuTest/moveAgain", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [reset] }, row => { +const moveAgain = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] }, after: [reset] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; }); /** Between two GPU kernels: changes only what they read, so nothing needs reading back first. */ -const turn = System.create({ name: "gpuTest/turn", components: { write: [velocity] }, after: [move] }, row => { +const turn = System.create({ components: { write: [velocity] }, after: [move] }, row => { const [vx, vy, vz] = row[velocity]; row[velocity] = [vz * 3, vx, vy]; }); -const moveLater = System.create({ name: "gpuTest/moveLater", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [turn] }, row => { +const moveLater = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] }, after: [turn] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; }); -const turning = System.create({ name: "gpuTest/turning", systems: [move, turn, moveLater] }); +const turning = System.create({ systems: [move, turn, moveLater] }); -const spawnNumbers = System.create({ name: "gpuTest/spawnNumbers" }, ({ commands }) => { +const spawnNumbers = System.create({}, ({ commands }) => { for (const [c, a] of [[0, 0], [3, -2.5], [0xff00ff00, 2.5], [0xffffff00, 7], [0x80000000, -0.25]]) commands.spawn([code, amount])({ [code]: c, [amount]: a }); }); @@ -95,7 +95,7 @@ const spawnNumbers = System.create({ name: "gpuTest/spawnNumbers" }, ({ commands * JS semantics the GPU keeps: u32 values past 2^31, integer and bitwise chains bit for bit, infinities, rounding. * Arithmetic is f32, so only results f32 holds exactly are compared; WGSL lets implementations assume no value is NaN. */ -const numbers = System.create({ name: "gpuTest/numbers", kernel: "gpu", components: { write: [code, amount] } }, row => { +const numbers = System.create({ kernel: "gpu", components: { write: [code, amount] } }, row => { const c = row[code]; const a = row[amount]; const mixed = (c ^ 0x0f0f0f0f) >>> 0; @@ -104,18 +104,17 @@ const numbers = System.create({ name: "gpuTest/numbers", kernel: "gpu", componen + (c | 0) / 0x1000000 + Math.clz32(c) + (c > 0x80000000 ? 10000 : 0) + Math.max(a, -1) * 100 + (a % 1.5) * 10000; }); -const flagScore = System.create({ name: "gpuTest/flagScore", kernel: "gpu", components: { read: [flag], write: [score] } }, row => { +const flagScore = System.create({ kernel: "gpu", components: { read: [flag], write: [score] } }, row => { row[score] = row[flag] ? 1 : 0; }); -const spawnFollower = System.create({ name: "gpuTest/spawnFollower" }, ({ commands }) => { +const spawnFollower = System.create({}, ({ commands }) => { const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [5, 0, 0] }); commands.spawn([followed, score])({ [followed]: leader, [score]: 0 }); }); /** On the CPU, reads through a lookup what a GPU kernel wrote. */ const follow = System.create({ - name: "gpuTest/follow", components: { read: [followed], write: [score] }, lookup: { leader: { read: [position] } }, after: [move], @@ -123,10 +122,10 @@ const follow = System.create({ row[score] = leader(row[followed])?.[position][0] ?? -1; }); -const following = System.create({ name: "gpuTest/following", systems: [move, follow] }); +const following = System.create({ systems: [move, follow] }); /** Despawns the first entity, moving the last into its row, then makes the sync point throw. */ -const poison = System.create({ name: "gpuTest/poison", components: { read: [position] } }, (row, { commands }) => { +const poison = System.create({ components: { read: [position] } }, (row, { commands }) => { if (row[position][0] === 2) commands.despawn(row.entity); if (row[position][0] === 4) commands.add(row.entity, score, Symbol("poison") as unknown as number); }); @@ -135,36 +134,36 @@ let frameNumber = 0; const spawnedNow: Entity[] = []; /** Spawns a few particles each frame, through growth of their archetypes; nothing on the CPU reads their positions. */ -const spawnFew = System.create({ name: "gpuTest/churn" }, ({ commands }) => { +const spawnFew = System.create({}, ({ commands }) => { for (let i = 0; i < 37; i++) spawnedNow.push(commands.spawn([position, velocity])({ [position]: [frameNumber, i, 0], [velocity]: [1, i % 3, 0.25] })); // Every fifth frame, a despawn moves another particle into a row the GPU wrote. if (frameNumber % 5 === 4) commands.despawn(spawnedNow[frameNumber * 3]); }); /** A render body that fails after starting its pass. */ -const fail = System.render({ name: "gpuTest/fail", shader: { accessors: { place: position } }, after: [move] }, ({ encoder }) => { +const fail = System.render({ shader: { accessors: { place: position } }, after: [move] }, ({ encoder }) => { encoder.beginComputePass(); throw new Error("draw failed"); }); -const failing = System.create({ name: "gpuTest/failing", systems: [move, fail] }); +const failing = System.create({ systems: [move, fail] }); /** Math from three namespaces, inlined into a compute shader. */ -const transform = System.create({ name: "gpuTest/transform", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); }); -const spawnIntegers = System.create({ name: "gpuTest/spawnIntegers" }, ({ commands }) => { +const spawnIntegers = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) { commands.spawn([bits, sums])({ [bits]: [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, (i << 26) >>> 0], [sums]: [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)] }); } }); /** Integer builtins, which wrap and count bits as the functions do. */ -const integers = System.create({ name: "gpuTest/integers", kernel: "gpu", components: { write: [bits, sums] } }, row => { +const integers = System.create({ kernel: "gpu", components: { write: [bits, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[bits] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), Vec3.U32.bitwiseXor(b, [7, 7, 7])))); @@ -172,7 +171,7 @@ const integers = System.create({ name: "gpuTest/integers", kernel: "gpu", compon }); /** Float builtins across vectors, quaternions and matrices, on in-domain inputs. */ -const shading = System.create({ name: "gpuTest/shading", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const shading = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { const p = row[position]; const v = Vec3.add(row[velocity], [0.25, 0.5, 0.75]); const n = Vec3.normalize(v); @@ -188,17 +187,17 @@ const shading = System.create({ name: "gpuTest/shading", kernel: "gpu", componen }); /** Reads the entity, an f64. */ -const entityScore = System.create({ name: "gpuTest/entityScore", kernel: "gpu", components: { write: [score] } }, row => { +const entityScore = System.create({ kernel: "gpu", components: { write: [score] } }, row => { row[score] = row.entity; }); let watched = 0; -const watch = System.create({ name: "gpuTest/watch", components: { changed: [position] } }, () => { watched++; }); +const watch = System.create({ components: { changed: [position] } }, () => { watched++; }); -const frame = System.create({ name: "gpuTest/frame", systems: [move, reset, moveAgain] }); +const frame = System.create({ systems: [move, reset, moveAgain] }); /** Added while others come and go, so ticks go on. */ -const steady = System.create({ name: "gpuTest/steady" }, () => {}); +const steady = System.create({}, () => {}); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); @@ -210,14 +209,14 @@ const positions = async (world: ReturnType): Promise (await readEach(world, await select(world, { required: [position] }), position)).map(value => value ?? [NaN, NaN, NaN]); let nextDevice: GPUDevice | null = null; -const setDevice = System.create({ name: "gpuTest/setDevice", resources: { write: [gpuDevice] } }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); +const setDevice = System.create({ resources: { write: [gpuDevice] } }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); describe("removing GPU systems", () => { it("releases the GPU records of a render system and a GPU kernel once they're removed", async () => { const world = createWorld({ [workers]: 0, [always]: true, [gpuDevice]: await device() }); await world.run(spawn); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawUntilRemoved", shader: { accessors: { place: position } }, after: [move] }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } }, after: [move] }, () => { drawn++; }); const ticks = async (count: number): Promise => { const from = (await Observe.toPromise(Observe.resource(world, clock))).tick; await new Promise(resolve => { @@ -265,12 +264,12 @@ describe("the gpuDevice resource", () => { const world = createWorld({ [workers]: 0, [gpuDevice]: lost }); const cpuWorld = createWorld({ [workers]: 0 }); let changes = 0; - const watchDevice = System.create({ name: "gpuTest/watchDevice", resources: { read: [gpuDevice], changed: [gpuDevice] } }, ({ resources }) => { + const watchDevice = System.create({ resources: { read: [gpuDevice], changed: [gpuDevice] } }, ({ resources }) => { void resources[gpuDevice]; changes++; }); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawWhileLost", shader: { accessors: { place: position } } }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } } }, () => { drawn++; }); for (const each of [world, cpuWorld]) await each.run(spawn); await world.run(watchDevice); // This move stays on the lost device, so it's lost with it. @@ -313,7 +312,7 @@ describe("the gpuDevice resource", () => { const world = createWorld({ [workers]: 2, [gpuDevice]: await device() }, { minimumChunk: 1 }); const cpuWorld = createWorld({ [workers]: 0 }); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawUntilNull", shader: { accessors: { place: position } } }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } } }, () => { drawn++; }); for (const each of [world, cpuWorld]) await each.run(spawn); await world.run(draw); await world.run(move); @@ -372,7 +371,7 @@ describe("GPU kernels", () => { }); it("keep their writes when the sync point moves rows of their archetypes", async () => { - const spawnOne = System.create({ name: "gpuTest/spawnOne" }, ({ commands }) => { + const spawnOne = System.create({}, ({ commands }) => { commands.spawn([position, velocity])({ [position]: [-1, -1, -1], [velocity]: [0, 0, 0] }); }); const world = createWorld({ [gpuDevice]: await device() }); @@ -390,12 +389,12 @@ describe("GPU kernels", () => { it("keep their writes when a cascade moves rows of their archetypes", async () => { const owners: Entity[] = []; - const spawnOwned = System.create({ name: "gpuTest/spawnOwned" }, ({ commands }) => { + const spawnOwned = System.create({}, ({ commands }) => { owners.push(commands.spawn([score])({ [score]: 0 }), commands.spawn([score])({ [score]: 1 })); for (let i = 0; i < 64; i++) commands.spawn([position, velocity, ownedBy])({ [position]: [i, 0, 0], [velocity]: [1, 0, 0], [ownedBy]: owners[i === 0 ? 0 : 1] }); }); // No command touches the owned rows: despawning their owner removes them. - const despawnOwner = System.create({ name: "gpuTest/despawnOwner" }, ({ commands }) => { + const despawnOwner = System.create({}, ({ commands }) => { const [owner] = owners; if (owner !== undefined) commands.despawn(owner); }); @@ -548,7 +547,7 @@ describe("render systems", () => { it("that record invalid work make the next run report the GPU's error, under the run that submitted it", async () => { // A dispatch without a pipeline is a validation error when the encoder finishes. - const invalid = System.render({ name: "gpuTest/invalid", shader: { accessors: { place: position } } }, ({ encoder }) => { + const invalid = System.render({ shader: { accessors: { place: position } } }, ({ encoder }) => { const pass = encoder.beginComputePass(); pass.dispatchWorkgroups(1); pass.end(); @@ -560,7 +559,7 @@ describe("render systems", () => { // The device reports the error once it has processed the run's work. await own.queue.onSubmittedWorkDone(); const [failure] = (await runReported(world, spawn)).failures; - expect([failure.kind, failure.name]).toEqual(["gpu", "gpuTest/invalid"]); + expect([failure.kind, failure.name]).toEqual(["gpu", "invalid"]); expect(failure.message).toMatch(/^validation: /); expect((await runReported(world, spawn)).failures).toEqual([]); await world.dispose(); @@ -568,17 +567,16 @@ describe("render systems", () => { it("name accessors anything WGSL allows, and only that", async () => { const own = await device(); - const named = (name: "gpuTest/count" | "gpuTest/target", accessor: string) => System.render({ name, shader: { accessors: { [accessor]: position } } }, () => {}); - const module = own.createShaderModule({ code: `${Gpu.wgsl(named("gpuTest/count", "count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); + const named = (name: "count" | "target", accessor: string) => System.render({ shader: { accessors: { [accessor]: position } } }, () => {}); + const module = own.createShaderModule({ code: `${Gpu.wgsl(named("count", "count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); - expect(() => Gpu.wgsl(named("gpuTest/target", "target"))).toThrow(/isn't usable as a WGSL function name/); + expect(() => Gpu.wgsl(named("target", "target"))).toThrow(/isn't usable as a WGSL function name/); }); it("read their components on the GPU through the generated accessors", async () => { const own = await device(); const output = own.createBuffer({ size: count * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); const copy = System.render({ - name: "gpuTest/copy/2", components: { has: [tag] }, shader: { group: 0, accessors: { place: position } }, after: [move], @@ -604,7 +602,7 @@ describe("render systems", () => { }`, }); const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); - const frameWithCopy = System.create({ name: "gpuTest/frame/2", systems: [move, copy] }); + const frameWithCopy = System.create({ systems: [move, copy] }); await world.run(spawn); await world.run(frameWithCopy); const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); @@ -626,12 +624,12 @@ describe("render systems", () => { /** The x a spawned position reaches after one `move`, of the one row the nudges below write. */ const nudged = 3000; -const nudgeRow = System.create({ name: "gpuTest/nudgeRow", components: { write: [position] } }, row => { +const nudgeRow = System.create({ components: { write: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); -const nudgeKernel = System.create({ name: "gpuTest/nudgeKernel", kernel: "cpu", components: { write: [position] } }, row => { +const nudgeKernel = System.create({ kernel: "cpu", components: { write: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); diff --git a/packages/ecs/src/implementation/world/gpu.ts b/packages/ecs/src/implementation/world/gpu.ts index fbcd13cb..e29983cf 100644 --- a/packages/ecs/src/implementation/world/gpu.ts +++ b/packages/ecs/src/implementation/world/gpu.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { labelOfItem } from '../../interface/registry/index.js'; +import { idOrNull } from '../../interface/registry/index.js'; import { layout } from '../../interface/gpu/layout.js'; import { recordBytes } from '../../interface/gpu/record.js'; import type { Archetype, Storage } from './archetype.js'; @@ -83,7 +83,7 @@ export type GpuEngine = { /** Keeps the first error of a run's GPU work, with the label of the system whose run submitted it. */ readonly reportErrors: (system: { readonly name: string }, validation: Promise, memory: Promise) => void; /** The recorded error, cleared. */ - readonly takeError: () => { readonly name: string; readonly message: string } | null; + readonly takeError: () => { readonly name: string; readonly id: string | null; readonly message: string } | null; readonly destroy: () => void; }; @@ -110,7 +110,7 @@ export const createGpu = (device: GPUDevice): GpuEngine => { let groups: { render: GPUBindGroup; compute: GPUBindGroup } | null = null; let encoder: GPUCommandEncoder | null = null; let lostReason: string | null = null; - let recorded: { readonly name: string; readonly message: string } | null = null; + let recorded: { readonly name: string; readonly id: string | null; readonly message: string } | null = null; const residents = new Map(); const ahead = new Set(); void device.lost.then(info => { lostReason = info.message === "" ? info.reason : info.message; }); @@ -376,8 +376,8 @@ export const createGpu = (device: GPUDevice): GpuEngine => { reportErrors: (system, validation, memory) => { void (async () => { const [invalid, exhausted] = await Promise.all([validation, memory]); - if (invalid !== null) recorded ??= { name: labelOfItem(system), message: `validation: ${invalid.message}` }; - else if (exhausted !== null) recorded ??= { name: labelOfItem(system), message: `out of memory: ${exhausted.message}` }; + if (invalid !== null) recorded ??= { name: system.name, id: idOrNull(system), message: `validation: ${invalid.message}` }; + else if (exhausted !== null) recorded ??= { name: system.name, id: idOrNull(system), message: `out of memory: ${exhausted.message}` }; })(); }, takeError: () => { diff --git a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts index e28240aa..192330fb 100644 --- a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts +++ b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts @@ -15,7 +15,7 @@ declare module "@adobe/ecs" { } } -export const showing = Rule.create({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }); -export const recording = Finalizer.changes({ name: "worldIdsTest/changes", watch: [score] }, ({ groups }) => { +export const showing = Rule.create({ components: { has: [score] }, derive: [shown] }); +export const recording = Finalizer.changes({ watch: [score] }, ({ groups }) => { for (const group of groups) changes.push(group.added); }); diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts index a61eaeaa..86544a8f 100644 --- a/packages/ecs/src/implementation/world/ids.test.ts +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -9,7 +9,7 @@ import { score, shown } from "./ids-keys.test-helper.js"; import { read } from "./reads.test-helper.js"; const spawned: Entity[] = []; -const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); +const spawn = System.create({}, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); describe("a world given keys with ids", () => { it("stores the symbols of one id as one component, giving values by the newest", async () => { @@ -18,8 +18,8 @@ describe("a world given keys with ids", () => { // The keys' module evaluated again, after the world met its first keys: a new module by its query, its ids the same. const again: typeof import("./ids-keys.test-helper.js") = await import(/* @vite-ignore */ `./ids-keys.test-helper.js?again=${Date.now()}`); expect(again.score).not.toBe(score); - const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([again.score])({ [again.score]: 2 })); }); - const add = System.create({ name: "worldIdsTest/add", components: { write: [again.score] } }, row => { row[again.score] += 10; }); + const spawnAgain = System.create({}, ({ commands }) => { spawned.push(commands.spawn([again.score])({ [again.score]: 2 })); }); + const add = System.create({ components: { write: [again.score] } }, row => { row[again.score] += 10; }); await world.run(spawnAgain); await world.run(add); const [first, second] = spawned; diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts index e40c2312..7fb8b305 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -14,19 +14,19 @@ declare module "@adobe/ecs" { } } -const seed = System.create({ name: "importedListsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { commands.spawn([heat])({ [heat]: 2 }); commands.spawn([heat])({ [heat]: 5 }); }); /** Spawns and adds through lists another module declares, naming one of their keys only through the list. */ -const ignite = System.create({ name: "importedListsTest/ignite", kernel: "cpu", components: { read: [heat] } }, (row, { commands }) => { +const ignite = System.create({ kernel: "cpu", components: { read: [heat] } }, (row, { commands }) => { commands.spawn(Spark)({ [ember]: row[heat] * 2 }); commands.add(row.entity, Lists.Marks, { [ember]: row[heat] }); }); /** Declares an imported list, and removes through one. */ -const cool = System.create({ name: "importedListsTest/cool", kernel: "cpu", components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { +const cool = System.create({ kernel: "cpu", components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { if (row[ember] > 4) commands.remove(row.entity, Spark); }); diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts index 6c8b00c6..90fce8f6 100644 --- a/packages/ecs/src/implementation/world/index-buckets.test.ts +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -31,15 +31,15 @@ declare module "@adobe/ecs" { } const groups = [0, 1, 2]; -const byGroup = Index.create({ name: "indexBucketsTest/byGroup", key: [group], components: { has: [serial], not: [hidden] } }); +const byGroup = Index.create({ key: [group], components: { has: [serial], not: [hidden] } }); /** What a reader saw: each bucket's length, or each bucket's rows as entity index and serial, in order. */ let lengths: number[] = []; let rows: (readonly [number, number])[][] = []; -const readLengths = System.create({ name: "indexBucketsTest/readLengths", lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { +const readLengths = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { lengths = groups.map(value => lookup.byGroup(value).length); }); -const readRows = System.create({ name: "indexBucketsTest/readRows", lookup: { byGroup: { index: byGroup, read: [serial] } } }, ({ lookup }) => { +const readRows = System.create({ lookup: { byGroup: { index: byGroup, read: [serial] } } }, ({ lookup }) => { rows = groups.map(value => { const list = lookup.byGroup(value); const found: (readonly [number, number])[] = []; @@ -53,7 +53,7 @@ const readRows = System.create({ name: "indexBucketsTest/readRows", lookup: { by /** Each reader row's bucket's serials, each times its position plus one: a sum that tells the order. */ const orderSum = System.create({ - name: "indexBucketsTest/orderSum", kernel: "cpu", + kernel: "cpu", components: { read: [reads], write: [sum] }, lookup: { byGroup: { index: byGroup, read: [serial] } }, }, (row, { lookup }) => { @@ -63,7 +63,6 @@ const orderSum = System.create({ row[sum] = total; }); const orderSumByHand = System.create({ - name: "indexBucketsTest/orderSumByHand", components: { read: [reads], write: [sumByHand] }, lookup: { byGroup: { index: byGroup, read: [serial] } }, }, (row, { lookup }) => { @@ -73,11 +72,11 @@ const orderSumByHand = System.create({ row[sumByHand] = total; }); let sums: { kernel: number[]; byHand: number[] } = { kernel: [], byHand: [] }; -const readSums = System.create({ name: "indexBucketsTest/readSums", components: { read: [reads, sum, sumByHand] } }, row => { +const readSums = System.create({ components: { read: [reads, sum, sumByHand] } }, row => { sums.kernel[row[reads]] = row[sum]; sums.byHand[row[reads]] = row[sumByHand]; }); -const spawnReaders = System.create({ name: "indexBucketsTest/spawnReaders" }, ({ commands }) => { +const spawnReaders = System.create({}, ({ commands }) => { for (const value of groups) commands.spawn([reads, sum, sumByHand])({ [reads]: value, [sum]: 0, [sumByHand]: 0 }); }); @@ -89,7 +88,7 @@ type Change = | { readonly kind: "group"; readonly entity: Entity; readonly group: number }; let spawned: Entity[] = []; -const apply = System.create({ name: "indexBucketsTest/apply" }, ({ commands }, input: { readonly changes: readonly Change[] }) => { +const apply = System.create({}, ({ commands }, input: { readonly changes: readonly Change[] }) => { spawned = []; for (const change of input.changes) { switch (change.kind) { diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts index 21e90ffc..c8aa20b9 100644 --- a/packages/ecs/src/implementation/world/index-build.fixture.ts +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -20,28 +20,28 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ name: "indexBuildTest/grid", key: [cell], components: { has: [weight] } }); -const serials = Index.create({ name: "indexBuildTest/serials", key: [serial], unique: true }); +const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const serials = Index.create({ key: [serial], unique: true }); const width = 300; const height = 200; const count = 150_000; /** Spawns entities in cells, with serials of which a few repeat. */ -const seed = System.create({ name: "indexBuildTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < count; k++) { commands.spawn([cell, serial, weight])({ [cell]: [(k * 7919) % width, (k * 104729) % height], [serial]: k % 50_000 === 7 ? 7 : k, [weight]: k % 13 }); } }); /** Moves every entity to another cell, so the next refresh builds the index again. */ -const shuffle = System.create({ name: "indexBuildTest/shuffle", components: { write: [cell] } }, row => { +const shuffle = System.create({ components: { write: [cell] } }, row => { const [x, y] = row[cell]; row[cell] = [(x * 7 + 3) % width, (y + x) % height]; }); /** A kernel, so the pool starts, as it does with a world's first kernel. */ -const warm = System.create({ name: "indexBuildTest/warm", kernel: "cpu", components: { read: [serial], write: [weight] } }, row => { +const warm = System.create({ kernel: "cpu", components: { read: [serial], write: [weight] } }, row => { row[weight] = row[serial] % 13; }); @@ -49,7 +49,6 @@ const seen = { grid: 0, rows: 0, serials: 0 }; /** Every cell's rows, in order, folded into one number; and the unique index's row of each serial. */ const read = System.create({ - name: "indexBuildTest/read", lookup: { near: { index: grid, read: [weight] }, bySerial: { index: serials } }, }, ({ lookup }) => { let fold = 0; @@ -72,7 +71,7 @@ const read = System.create({ }); /** The same folds without an index: each cell's rows sorted by entity, and each serial's lowest entity. */ -const reference = System.create({ name: "indexBuildTest/reference", components: { read: [cell, serial, weight] } }, row => { +const reference = System.create({ components: { read: [cell, serial, weight] } }, row => { const [x, y] = row[cell]; const cellRows = byCell[x * height + y] ??= []; cellRows.push([row.entity, row[weight]]); diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index 849b81f3..c3300988 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -26,34 +26,34 @@ declare module "@adobe/ecs" { } } -const byGroup = Index.create({ name: "indexChangedTest/byGroup", key: [group], components: { has: [weight] } }); -const inOrder = Index.create({ name: "indexChangedTest/inOrder", key: [group], sorted: true }); -const weighted = Index.create({ name: "indexChangedTest/weighted", key: [], components: { has: [weight] } }); +const byGroup = Index.create({ key: [group], components: { has: [weight] } }); +const inOrder = Index.create({ key: [group], sorted: true }); +const weighted = Index.create({ key: [], components: { has: [weight] } }); const one = { type: "object", properties: { entity: Entity.schema, value: { type: "number" } } } as const; let spawned: Entity[] = []; -const spawn = System.create({ name: "indexChangedTest/spawn" }, ({ commands }, input: { readonly groups: readonly number[] }) => { +const spawn = System.create({}, ({ commands }, input: { readonly groups: readonly number[] }) => { for (const value of input.groups) spawned.push(commands.spawn([group, weight, total])({ [group]: value, [weight]: 1, [total]: 0 })); }); -const setGroup = System.create({ name: "indexChangedTest/setGroup", target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { +const setGroup = System.create({ target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[group] = input.value; }); -const setWeight = System.create({ name: "indexChangedTest/setWeight", target: "entity", components: { write: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { +const setWeight = System.create({ target: "entity", components: { write: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[weight] = input.value; }); -const despawn = System.create({ name: "indexChangedTest/despawn", target: "entity", components: { has: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const despawn = System.create({ target: "entity", components: { has: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); -const dropWeight = System.create({ name: "indexChangedTest/dropWeight", target: "entity", components: { has: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const dropWeight = System.create({ target: "entity", components: { has: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.remove(row.entity, weight); }); -const pause = System.create({ name: "indexChangedTest/pause", resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { +const pause = System.create({ resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); /** What each run of a gated system saw: its lists' lengths and `changed` for groups 1 and 2. */ let seen: string[] = []; -const counting = System.create({ name: "indexChangedTest/counting", lookup: { byGroup: { index: byGroup, changed: true } } }, ({ lookup }) => { +const counting = System.create({ lookup: { byGroup: { index: byGroup, changed: true } } }, ({ lookup }) => { const first = lookup.byGroup(1); const second = lookup.byGroup(2); seen.push(`${first.length}${first.changed ? "*" : ""} ${second.length}${second.changed ? "*" : ""}`); @@ -61,7 +61,7 @@ const counting = System.create({ name: "indexChangedTest/counting", lookup: { by /** Each row's total: how many rows share its group, by hundreds, and its weight; run only when the groups changed. */ const tally = System.create({ - name: "indexChangedTest/tally", kernel: "cpu", + kernel: "cpu", components: { read: [group, weight], write: [total] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, (row, { lookup }) => { @@ -108,7 +108,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const orders: string[] = []; - const ordering = System.create({ name: "indexChangedTest/ordering", observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { + const ordering = System.create({ observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { const all = lookup.inOrder(); const groups: number[] = []; for (let i = 0; i < all.length; i++) groups.push(all.at(i).entity === spawned[0] ? -1 : 0); @@ -131,7 +131,6 @@ describe("an index lookup's changed: true", () => { spawned = []; const flags: boolean[] = []; const watching = System.create({ - name: "indexChangedTest/watching", lookup: { byGroup: { index: byGroup, changed: true }, weights: { read: [weight], changed: [weight] } }, }, ({ lookup }) => { flags.push(lookup.weights(spawned[0])?.changed ?? false); @@ -152,7 +151,6 @@ describe("an index lookup's changed: true", () => { spawned = []; const rows: number[] = []; const filtering = System.create({ - name: "indexChangedTest/filtering", components: { read: [weight], changed: [weight] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, row => { @@ -179,7 +177,6 @@ describe("an index lookup's changed: true", () => { spawned = []; let runs = 0; const both = System.create({ - name: "indexChangedTest/both", resources: { not: [paused] }, lookup: { byGroup: { index: byGroup, changed: true }, weighted: { index: weighted, changed: true } }, }, () => { @@ -207,7 +204,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const totals: number[] = []; - const readTotals = System.create({ name: "indexChangedTest/readTotals", observer: true, components: { read: [total] } }, row => { + const readTotals = System.create({ observer: true, components: { read: [total] } }, row => { totals.push(row[total]); }); const totalsOf = async (): Promise => { @@ -229,9 +226,9 @@ describe("an index lookup's changed: true", () => { it("is only for a system's index lookups", () => { const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [declaration, () => {}]); - expect(() => untypedSystem({ name: "indexChangedTest/byEntity", lookup: { weights: { read: [weight], changed: true } } })) - .toThrow(`System "indexChangedTest/byEntity": lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); - expect(() => untypedFinalizer({ name: "indexChangedTest/finalizer", components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) - .toThrow(`Finalizer "indexChangedTest/finalizer": lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); + expect(() => untypedSystem({ name: "byEntity", lookup: { weights: { read: [weight], changed: true } } })) + .toThrow(`lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); + expect(() => untypedFinalizer({ name: "finalizer", components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) + .toThrow(`lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); }); }); diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index c41dc546..1f388847 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -52,18 +52,17 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con /** A label's start, which a command here gives it by this value. */ const blank = { text: "" }; -const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); -const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); -const lanes = Index.create({ name: "indexJoinedTest/lanes", key: [lane, rank], sorted: true }); -const names = Index.create({ name: "indexJoinedTest/names", key: [name] }); -const titles = Index.create({ name: "indexJoinedTest/titles", key: [title] }); -const weighted = Index.create({ name: "indexJoinedTest/weighted", key: [], components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true }); +const names = Index.create({ key: [name] }); +const titles = Index.create({ key: [title] }); +const weighted = Index.create({ key: [], components: { has: [weight] } }); /** Two long buckets, which changes leave unsorted until a list is read by position. */ -const bands = Index.create({ name: "indexJoinedTest/bands", key: [band], components: { has: [serial] } }); +const bands = Index.create({ key: [band], components: { has: [serial] } }); /** On the joined thread: the serials of a band's rows, in entity order. */ const describeBand = System.create({ - name: "indexJoinedTest/describeBand", components: { read: [target], write: [label], has: [bandReader] }, lookup: { inBand: { index: bands, read: [serial] } }, }, (row, { lookup }) => { @@ -72,12 +71,12 @@ const describeBand = System.create({ for (let i = 0; i < list.length; i++) serials.push(list.at(i)[serial]); row[label] = { text: `${row[target]}:${serials.join(",")}` }; }); -const seedBands = System.create({ name: "indexJoinedTest/seedBands" }, ({ commands }) => { +const seedBands = System.create({}, ({ commands }) => { for (let k = 0; k < 300; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); for (let k = 0; k < 2; k++) commands.spawn([target, label, bandReader])({ [target]: k, [label]: blank }); }); /** Moves some entities to the other band, despawns some and spawns others. */ -const stirBands = System.create({ name: "indexJoinedTest/stirBands", components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { +const stirBands = System.create({ components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); @@ -85,7 +84,7 @@ const stirBands = System.create({ name: "indexJoinedTest/stirBands", components: /** An observer system of the joined thread's labels: the serials of each reader's band, in entity order, by reader. */ let observedBands: string[] = []; const observeBands = System.create({ - name: "indexJoinedTest/observeBands", observer: true, + observer: true, components: { read: [target], has: [bandReader, label] }, lookup: { inBand: { index: bands, read: [serial] } }, }, (row, { lookup }) => { @@ -119,7 +118,7 @@ const bandScenario = async (world: CreatedWorld, holder: World): Promise { +const shift = System.create({ components: { read: [serial], write: [cell, label] } }, row => { if (row[serial] % 17 !== 0) return; const [x, y] = row[cell]; row[cell] = [(x + 1) % 13, y]; @@ -128,7 +127,6 @@ const shift = System.create({ name: "indexJoinedTest/shift", components: { read: /** On the joined thread: each cell's neighbors' count and weights, whether its list changed, and its serial's target's weight. */ const describeCells = System.create({ - name: "indexJoinedTest/describeCells", components: { read: [cell, target], write: [label] }, lookup: { near: { index: grid, read: [weight], changed: [weight] }, bySerial: { index: serials, read: [weight] }, all: { index: weighted, read: [weight] } }, after: [shift], @@ -152,7 +150,6 @@ const describeCells = System.create({ /** On the joined thread: the rows of the row's lane within 5 of its rank, in key order. */ const describeRanks = System.create({ - name: "indexJoinedTest/describeRanks", components: { read: [lane, rank], write: [label] }, lookup: { inLane: { index: lanes, read: [rank] } }, after: [describeCells], @@ -164,28 +161,27 @@ const describeRanks = System.create({ row[label] = { text: ranks.join(",") }; }); -const tick = System.create({ name: "indexJoinedTest/tick", systems: [shift, describeCells, describeRanks] }); +const tick = System.create({ systems: [shift, describeCells, describeRanks] }); -const seed = System.create({ name: "indexJoinedTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < 200; k++) commands.spawn([cell, weight, serial, target, label])({ [cell]: [k % 13, k % 7], [weight]: k % 5, [serial]: k, [target]: (k * 7) % 230, [label]: blank }); for (let k = 0; k < 150; k++) commands.spawn([lane, rank, label])({ [lane]: k % 3, [rank]: (k * 37) % 97, [label]: blank }); }); /** Moves cells, changes weights and ranks, despawns some and spawns others. */ -const stir = System.create({ name: "indexJoinedTest/stir", components: { read: [serial], write: [cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { +const stir = System.create({ components: { read: [serial], write: [cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((row[serial] + input.round) % 9 === 0) row[cell] = [(x + 1) % 13, y]; if ((row[serial] + input.round) % 11 === 0) row[weight] = row[weight] + 1; if ((row[serial] + input.round) % 23 === 0) commands.despawn(row.entity); if (row[serial] % 50 === input.round) commands.spawn([cell, weight, serial, target, label])({ [cell]: [input.round, 3], [weight]: 2, [serial]: 1000 + row[serial], [target]: row[serial], [label]: blank }); }); -const rerank = System.create({ name: "indexJoinedTest/rerank", components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { +const rerank = System.create({ components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 5 === 0) row[rank] = (row[rank] * 3 + 1) % 97; }); /** A system of the joined thread looking up through an index of its strings, which the world keeps on its own thread. */ const byName = System.create({ - name: "indexJoinedTest/byName", components: { read: [name], write: [label] }, lookup: { named: { index: names } }, }, (row, { lookup }) => { @@ -194,41 +190,40 @@ const byName = System.create({ /** On the joined thread, added and removed: labels each cell by its bucket's length. */ const counting = System.create({ - name: "indexJoinedTest/counting", components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { const [x, y] = row[cell]; row[label] = { text: String(lookup.near([x, y]).length) }; }); -const keepTicking = System.create({ name: "indexJoinedTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); -const ranks = Index.create({ name: "indexJoinedTest/ranks", key: [rank], sorted: true }); +const ranks = Index.create({ key: [rank], sorted: true }); /** Systems of the joined thread writing a string, and looking up by strings, which only the world's thread does. */ -const writeTitle = System.create({ name: "indexJoinedTest/writeTitle", components: { read: [label], write: [title] } }, row => { +const writeTitle = System.create({ components: { read: [label], write: [title] } }, row => { row[title] = row[label].text; }); -const byTitle = System.create({ name: "indexJoinedTest/byTitle", components: { read: [title], write: [label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { +const byTitle = System.create({ components: { read: [title], write: [label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { row[label] = { text: String(lookup.titled(row[title]).length) }; }); /** A system of the joined thread whose rows are in an index's order, which runs only on the world's thread. */ -const inOrder = System.create({ name: "indexJoinedTest/inOrder", components: { read: [rank], write: [label] }, order: ranks }, row => { +const inOrder = System.create({ components: { read: [rank], write: [label] }, order: ranks }, row => { row[label] = { text: String(row[rank]) }; }); /** Systems on the joined thread and an observer system of its objects, each with an index as a change filter, which run only on the world's thread. */ -const countWhenMoved = System.create({ name: "indexJoinedTest/countWhenMoved", components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { +const countWhenMoved = System.create({ components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { row[label] = { text: String(lookup.near(row[cell]).length) }; }); -const readWhenMoved = System.create({ name: "indexJoinedTest/readWhenMoved", observer: true, components: { read: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { +const readWhenMoved = System.create({ observer: true, components: { read: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { void row[label]; }); -const ownWhenMoved = System.create({ name: "indexJoinedTest/ownWhenMoved", observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); +const ownWhenMoved = System.create({ observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); /** The entities `readLabels` reads, and their labels. */ const reading: { entities: readonly Entity[]; labels: readonly string[] } = { entities: [], labels: [] }; -const readLabels = System.create({ name: "indexJoinedTest/readLabels", observer: true, lookup: { labeled: { read: [label] } } }, ({ lookup }) => { +const readLabels = System.create({ observer: true, lookup: { labeled: { read: [label] } } }, ({ lookup }) => { reading.labels = reading.entities.map(entity => lookup.labeled(entity)?.[label].text ?? "missing"); }); @@ -268,32 +263,32 @@ const joinedStatus = (world: CreatedWorld): Promise => new Promise(resolve }); /** Entities a keyless and a unique index hold, each with this thread's label and a string title. */ -const seedHeld = System.create({ name: "indexJoinedTest/seedHeld" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { +const seedHeld = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, weight, label, title])({ [serial]: k, [weight]: k % 5, [label]: blank, [title]: `t${k}` }); }); /** On the joined thread: every label, by serial and round. */ -const relabelHeld = System.create({ name: "indexJoinedTest/relabelHeld", components: { read: [serial], write: [label], has: [title] } }, (row, _context, input: { readonly round: number }) => { +const relabelHeld = System.create({ components: { read: [serial], write: [label], has: [title] } }, (row, _context, input: { readonly round: number }) => { row[label] = { text: `${row[serial]}.${input.round}` }; }); /** On the world's thread: every weight and title, by round. */ -const rewriteHeld = System.create({ name: "indexJoinedTest/rewriteHeld", components: { read: [serial], write: [weight, title] } }, (row, _context, input: { readonly round: number }) => { +const rewriteHeld = System.create({ components: { read: [serial], write: [weight, title] } }, (row, _context, input: { readonly round: number }) => { row[weight] = row[serial] * 10 + input.round; row[title] = `t${row[serial]}.${input.round}`; }); /** Moves every held entity to the other archetype, marked on one run and not on the next. */ -const markHeld = System.create({ name: "indexJoinedTest/markHeld", components: { has: [title], not: [marked] } }, (row, { commands }) => { +const markHeld = System.create({ components: { has: [title], not: [marked] } }, (row, { commands }) => { commands.add(row.entity, marked); }); -const unmarkHeld = System.create({ name: "indexJoinedTest/unmarkHeld", components: { has: [title, marked] } }, (row, { commands }) => { +const unmarkHeld = System.create({ components: { has: [title, marked] } }, (row, { commands }) => { commands.remove(row.entity, marked); }); -const held = Index.create({ name: "indexJoinedTest/held", key: [], components: { has: [serial, weight, label, title] } }); -const heldSerials = Index.create({ name: "indexJoinedTest/heldSerials", key: [serial], unique: true, components: { has: [weight, label, title] } }); +const held = Index.create({ key: [], components: { has: [serial, weight, label, title] } }); +const heldSerials = Index.create({ key: [serial], unique: true, components: { has: [weight, label, title] } }); /** What `observeHeld` read last. */ let observedHeld = ""; /** An observer system reading every held row through the keyless index, and serial 3's through the unique one. */ const observeHeld = System.create({ - name: "indexJoinedTest/observeHeld", observer: true, + observer: true, lookup: { all: { index: held, read: [serial, weight, label, title] }, bySerial: { index: heldSerials, read: [weight, label, title] } }, }, ({ lookup }) => { const all = lookup.all(); @@ -356,7 +351,7 @@ describe("systems on a joined thread looking up through indexes", () => { expect(there.labels).toEqual(alone.labels); expect(alone.labels.flat().some(text => text.includes("true"))).toBe(true); // The systems ran there, their tiers waiting for the thread. - expect(there.warnings.every(names => names.includes("indexJoinedTest/describeCells"))).toBe(true); + expect(there.warnings.every(names => names.includes("describeCells"))).toBe(true); expect(alone.warnings.flat()).toEqual([]); await remote.dispose(); await world.dispose(); @@ -453,14 +448,14 @@ describe("systems on a joined thread looking up through indexes", () => { world.share(channel.port1); const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); await joinedStatus(world); - await expect(world.run(byName)).rejects.toThrow('looks up through index "indexJoinedTest/names", whose key part "indexJoinedTest/name" is an object, so it runs only on the world\'s thread.'); + await expect(world.run(byName)).rejects.toThrow('looks up through index "names", whose key part "indexJoinedTest/name" is an object, so it runs only on the world\'s thread.'); await expect(world.run(inOrder)).rejects.toThrow("orders its rows by an index, which runs only on the world's thread"); - await expect(world.run(writeTitle)).rejects.toThrow(`System "indexJoinedTest/writeTitle" runs on thread "indexJoinedTest", which a joined thread fills, and writes "indexJoinedTest/title", a string, which only the world's thread writes.`); - await expect(world.run(byTitle)).rejects.toThrow('looks up through index "indexJoinedTest/titles", whose key part "indexJoinedTest/title" is a string, which the world\'s thread keeps keys of, so it runs only on the world\'s thread.'); - const moved = `and when index "indexJoinedTest/grid" changed, a lookup's changed being true, which runs only on the world's thread.`; - await expect(world.run(countWhenMoved)).rejects.toThrow(`System "indexJoinedTest/countWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); - await expect(world.run(readWhenMoved)).rejects.toThrow(`System "indexJoinedTest/readWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); - expect(() => remote.add(ownWhenMoved)).toThrow(`Observer system "indexJoinedTest/ownWhenMoved" runs when index "indexJoinedTest/grid" changed, as its lookup "near"'s changed is true, so it runs only on the world's thread.`); + await expect(world.run(writeTitle)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, and writes "indexJoinedTest/title", a string, which only the world's thread writes.`); + await expect(world.run(byTitle)).rejects.toThrow('looks up through index "titles", whose key part "indexJoinedTest/title" is a string, which the world\'s thread keeps keys of, so it runs only on the world\'s thread.'); + const moved = `and when index "grid" changed, a lookup's changed being true, which runs only on the world's thread.`; + await expect(world.run(countWhenMoved)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + await expect(world.run(readWhenMoved)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + expect(() => remote.add(ownWhenMoved)).toThrow(`Observer system "ownWhenMoved" runs when index "grid" changed, as its lookup "near"'s changed is true, so it runs only on the world's thread.`); await remote.dispose(); await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index 3e7aadfe..a1ac011f 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -37,13 +37,13 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ name: "indexKernelsTest/grid", key: [cell], components: { has: [weight] } }); -const lanes = Index.create({ name: "indexKernelsTest/lanes", key: [lane, rank], sorted: true, components: { has: [ranked] } }); -const serials = Index.create({ name: "indexKernelsTest/serials", key: [serial], unique: true, components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true, components: { has: [ranked] } }); +const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - name: "indexKernelsTest/neighbors", kernel: "cpu", + kernel: "cpu", components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } }, }, (row, { lookup }) => { @@ -64,7 +64,7 @@ const neighbors = System.create({ }); /** The same, on the main thread, for comparison. */ -const neighborsByHand = System.create({ name: "indexKernelsTest/neighborsByHand", components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { +const neighborsByHand = System.create({ components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; let found = 0; @@ -81,7 +81,7 @@ const neighborsByHand = System.create({ name: "indexKernelsTest/neighborsByHand" /** How many rows of the row's lane have a rank within 5 of its own, and the first of them, through a sorted range. */ const window = System.create({ - name: "indexKernelsTest/window", kernel: "cpu", + kernel: "cpu", components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } }, }, (row, { lookup }) => { @@ -91,7 +91,7 @@ const window = System.create({ row[seen] = near.at(0).entity; }); -const windowByHand = System.create({ name: "indexKernelsTest/windowByHand", components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { +const windowByHand = System.create({ components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { const r = row[rank]; const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); row[count] = near.length; @@ -100,7 +100,7 @@ const windowByHand = System.create({ name: "indexKernelsTest/windowByHand", comp /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - name: "indexKernelsTest/follow", kernel: "cpu", + kernel: "cpu", components: { read: [target], write: [total] }, lookup: { bySerial: { index: serials, read: [weight] } }, }, (row, { lookup }) => { @@ -110,18 +110,18 @@ const follow = System.create({ /** Counts runs whose list of the row's cell changed. */ const watch = System.create({ - name: "indexKernelsTest/watch", kernel: "cpu", + kernel: "cpu", components: { read: [cell], write: [changes] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { if (lookup.near([row[cell][0], row[cell][1]]).changed) row[changes] = row[changes] + 1; }); -const spawnGrid = System.create({ name: "indexKernelsTest/spawnGrid" }, ({ commands }, input: { readonly count: number }) => { +const spawnGrid = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 5, (k * 3) % 4], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); }); -const spawnLanes = System.create({ name: "indexKernelsTest/spawnLanes" }, ({ commands }, input: { readonly count: number }) => { +const spawnLanes = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([lane, rank, ranked, count, seen])({ [lane]: k % 3, [rank]: (k * 37) % 101, [ranked]: true, [count]: 0, [seen]: Entity.none }); }); @@ -178,7 +178,7 @@ describe.each([[true], [false]])("kernels looking up through indexes, WASM %s", /** A comparison on a hash index. */ const compared = System.create({ - name: "indexKernelsTest/compared", kernel: "cpu", + kernel: "cpu", components: { read: [serial], write: [count] }, lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index 8c8a741c..38be3ee8 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -30,9 +30,9 @@ declare module "@adobe/ecs" { } } -const shown = Index.create({ name: "indexKeylessTest/shown", key: [], components: { has: [weight], not: [hidden] } }); -const everything = Index.create({ name: "indexKeylessTest/everything", key: [] }); -const inOrder = Index.create({ name: "indexKeylessTest/inOrder", key: [], sorted: true, components: { has: [weight] } }); +const shown = Index.create({ key: [], components: { has: [weight], not: [hidden] } }); +const everything = Index.create({ key: [] }); +const inOrder = Index.create({ key: [], sorted: true, components: { has: [weight] } }); /** What a lookup gave: its entities and weights, in its order, its length and whether it changed. */ type Seen = { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly length: number; readonly changed: boolean }; @@ -40,7 +40,6 @@ let seen: Seen = { entities: [], weights: [], length: 0, changed: false }; let all: readonly Entity[] = []; const look = System.create({ - name: "indexKeylessTest/look", lookup: { shown: { index: shown, read: [weight] }, all: { index: everything } }, }, ({ lookup }) => { const rows = lookup.shown(); @@ -57,7 +56,7 @@ const look = System.create({ /** Whether the lookup through `shown` changed, with `color` watched. */ let watchedChanged = false; -const watchColor = System.create({ name: "indexKeylessTest/watchColor", lookup: { shown: { index: shown, changed: [weight] } } }, ({ lookup }) => { +const watchColor = System.create({ lookup: { shown: { index: shown, changed: [weight] } } }, ({ lookup }) => { watchedChanged = lookup.shown().changed; }); @@ -69,7 +68,7 @@ const expected = async (world: CreatedWorld): Promise<{ readonly entities: reado all: [...await select(world, {})].sort(byIndex), }); -const spawn = System.create({ name: "indexKeylessTest/spawn" }, ({ commands }, input: { readonly weights: readonly number[] }) => { +const spawn = System.create({}, ({ commands }, input: { readonly weights: readonly number[] }) => { for (const value of input.weights) commands.spawn([weight, color])({ [weight]: value, [color]: 0 }); }); @@ -203,7 +202,7 @@ describe("an incrementally refreshed index without a key", () => { /** Each row's tally of the rows of `shown`: how many, and the sum of a window of their weights. */ const tally = System.create({ - name: "indexKeylessTest/tally", kernel: "cpu", + kernel: "cpu", components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } }, }, (row, { lookup }) => { @@ -213,7 +212,7 @@ const tally = System.create({ row[total] = sum; row[count] = rows.length; }); -const tallyByHand = System.create({ name: "indexKeylessTest/tallyByHand", components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } } }, (row, { lookup }) => { +const tallyByHand = System.create({ components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } } }, (row, { lookup }) => { const rows = lookup.shown(); let sum = 0; for (let i = row[color] % 7; i < rows.length; i += 7) sum += rows.at(i)[weight]; @@ -222,7 +221,7 @@ const tallyByHand = System.create({ name: "indexKeylessTest/tallyByHand", compon }); /** Counts runs whose list changed, `weight` watched. */ const visit = System.create({ - name: "indexKeylessTest/visit", kernel: "cpu", + kernel: "cpu", components: { write: [visited] }, lookup: { shown: { index: shown, changed: [weight] } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index 12970bd6..ddb54f1c 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -27,32 +27,32 @@ declare module "@adobe/ecs" { } /** By lane, then rank, of the entities that have a weight. */ -const ranked = Index.create({ name: "indexOrderTest/ranked", key: [lane, rank], sorted: true, components: { has: [weight] } }); +const ranked = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); /** Each row's lane and rank as one number, in the order rows ran. */ let order: number[] = []; -const visit = System.create({ name: "indexOrderTest/visit", components: { read: [lane, rank] }, order: ranked }, row => { +const visit = System.create({ components: { read: [lane, rank] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); -const visitChanged = System.create({ name: "indexOrderTest/visitChanged", components: { read: [lane, rank], changed: [weight] }, order: ranked }, row => { +const visitChanged = System.create({ components: { read: [lane, rank], changed: [weight] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); /** A kernel spawning a marker for each row in turn, so the markers' entities, in order, are the rows' order. */ -const markEach = System.create({ name: "indexOrderTest/markEach", kernel: "cpu", components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { +const markEach = System.create({ kernel: "cpu", components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const markChanged = System.create({ name: "indexOrderTest/markChanged", kernel: "cpu", components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { +const markChanged = System.create({ kernel: "cpu", components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const keepTicking = System.create({ name: "indexOrderTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** Changes the weight of every row whose weight ends in 3, a tenth of them. */ -const nudge = System.create({ name: "indexOrderTest/nudge", components: { write: [weight] } }, row => { +const nudge = System.create({ components: { write: [weight] } }, row => { if (row[weight] % 10 === 3) row[weight] = row[weight] + 1; }); /** Entities in two archetypes, interleaved in the index's order, some without the weight the index requires. */ -const seed = System.create({ name: "indexOrderTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const values = { [lane]: k % 3, [rank]: (k * 37) % 101, [weight]: k }; if (k % 5 === 4) commands.spawn([lane, rank])({ [lane]: k % 3, [rank]: (k * 37) % 101 }); @@ -72,7 +72,7 @@ const expected = async (world: CreatedWorld, only?: (weighed: number) => boolean }; const markerValues: number[] = []; -const readMarkers = System.create({ name: "indexOrderTest/readMarkers", observer: true, components: { read: [marker] } }, row => { markerValues.push(row[marker]); }); +const readMarkers = System.create({ observer: true, components: { read: [marker] } }, row => { markerValues.push(row[marker]); }); /** Markers' values in the order they were spawned: their archetype's rows', in storage order. */ const markers = async (world: CreatedWorld): Promise => { diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index b73dee8c..826c9bfb 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -44,14 +44,14 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ name: "indexWorkersTest/grid", key: [cell], components: { has: [weight] } }); -const lanes = Index.create({ name: "indexWorkersTest/lanes", key: [lane, rank], sorted: true }); -const serials = Index.create({ name: "indexWorkersTest/serials", key: [serial], unique: true, components: { has: [weight] } }); -const weighted = Index.create({ name: "indexWorkersTest/weighted", key: [], components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true }); +const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); +const weighted = Index.create({ key: [], components: { has: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - name: "indexWorkersTest/neighbors", kernel: "cpu", + kernel: "cpu", components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } }, }, (row, { lookup }) => { @@ -73,7 +73,7 @@ const neighbors = System.create({ /** Counts runs whose list of the row's own cell changed. */ const watch = System.create({ - name: "indexWorkersTest/watch", kernel: "cpu", + kernel: "cpu", components: { read: [cell], write: [changes] }, lookup: { near: { index: grid, changed: [weight] } }, after: [neighbors], @@ -84,7 +84,7 @@ const watch = System.create({ /** How many rows of the row's lane have a rank within 5 of its own, and the first of them. */ const window = System.create({ - name: "indexWorkersTest/window", kernel: "cpu", + kernel: "cpu", components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } }, after: [watch], @@ -97,7 +97,7 @@ const window = System.create({ /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - name: "indexWorkersTest/follow", kernel: "cpu", + kernel: "cpu", components: { read: [target], write: [total] }, lookup: { bySerial: { index: serials, read: [weight] } }, after: [window], @@ -108,7 +108,7 @@ const follow = System.create({ /** Through an index without a key: how many entities it holds, a stride of their weights, and whether it changed. */ const census = System.create({ - name: "indexWorkersTest/census", kernel: "cpu", + kernel: "cpu", components: { read: [target], write: [tally] }, lookup: { weighted: { index: weighted, read: [weight], changed: [weight] } }, after: [follow], @@ -119,9 +119,9 @@ const census = System.create({ row[tally] = all.length * 1000000 + sum * 2 + (all.changed ? 1 : 0); }); -const tick = System.create({ name: "indexWorkersTest/tick", systems: [neighbors, watch, window, follow, census] }); +const tick = System.create({ systems: [neighbors, watch, window, follow, census] }); -const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < 3000; k++) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 31, (k * 3) % 29], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); } @@ -132,15 +132,15 @@ const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => }); /** Moves some cells, changes some weights and ranks, despawns some serials and spawns more: a different input each time. */ -const stir = System.create({ name: "indexWorkersTest/stir", components: { write: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { +const stir = System.create({ components: { write: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((x + y + input.round) % 11 === 0) row[cell] = [(x + 1) % 31, y]; if ((x * y + input.round) % 13 === 0) row[weight] = row[weight] + 1; }); -const rerank = System.create({ name: "indexWorkersTest/rerank", components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { +const rerank = System.create({ components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; }); -const churn = System.create({ name: "indexWorkersTest/churn", components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { +const churn = System.create({ components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); if (row[serial] % 101 === input.round) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); @@ -187,11 +187,11 @@ export const simulate = async (threads: number, wasm: boolean): Promise<{ readon }; /** Two long buckets, which changes leave unsorted until something reads them by position. */ -const bands = Index.create({ name: "indexWorkersTest/bands", key: [band], components: { has: [serial] } }); +const bands = Index.create({ key: [band], components: { has: [serial] } }); /** Each row's band's serials, each times its position plus one: a sum that tells the order. */ const bandOrder = System.create({ - name: "indexWorkersTest/bandOrder", kernel: "cpu", + kernel: "cpu", components: { read: [target], write: [bandSum] }, lookup: { inBand: { index: bands, read: [serial] } }, }, (row, { lookup }) => { @@ -201,7 +201,6 @@ const bandOrder = System.create({ row[bandSum] = sum; }); const bandOrderByHand = System.create({ - name: "indexWorkersTest/bandOrderByHand", components: { read: [target], write: [bandSumByHand] }, lookup: { inBand: { index: bands, read: [serial] } }, }, (row, { lookup }) => { @@ -210,12 +209,12 @@ const bandOrderByHand = System.create({ for (let i = 0; i < list.length; i++) sum += (i + 1) * list.at(i)[serial]; row[bandSumByHand] = sum; }); -const seedBands = System.create({ name: "indexWorkersTest/seedBands" }, ({ commands }) => { +const seedBands = System.create({}, ({ commands }) => { for (let k = 0; k < 600; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); for (let k = 0; k < 512; k++) commands.spawn([target, bandSum, bandSumByHand])({ [target]: k, [bandSum]: 0, [bandSumByHand]: 0 }); }); /** Moves some entities to the other band, despawns some and spawns others. */ -const stirBands = System.create({ name: "indexWorkersTest/stirBands", components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { +const stirBands = System.create({ components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index b2a3b308..592aa624 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -35,12 +35,12 @@ declare module "@adobe/ecs" { } -const grid = Index.create({ name: "indexesTest/grid", key: [cell], components: { has: [weight], not: [hidden] } }); -const byServerId = Index.create({ name: "indexesTest/byServerId", key: [serverId], unique: true }); -const byLabel = Index.create({ name: "indexesTest/byLabel", key: [label] }); -const children = Index.create({ name: "indexesTest/children", key: [owner] }); +const grid = Index.create({ key: [cell], components: { has: [weight], not: [hidden] } }); +const byServerId = Index.create({ key: [serverId], unique: true }); +const byLabel = Index.create({ key: [label] }); +const children = Index.create({ key: [owner] }); -const spawnCells = System.create({ name: "indexesTest/spawnCells" }, ({ commands }, input: { readonly cells: readonly (readonly number[])[] }) => { +const spawnCells = System.create({}, ({ commands }, input: { readonly cells: readonly (readonly number[])[] }) => { input.cells.forEach(([x, y], k) => commands.spawn([cell, weight])({ [cell]: [x ?? 0, y ?? 0], [weight]: k })); }); @@ -48,7 +48,6 @@ const spawnCells = System.create({ name: "indexesTest/spawnCells" }, ({ commands let seen: { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly changed: boolean; readonly length: number } | null = null; const look = System.create({ - name: "indexesTest/look", lookup: { near: { index: grid, read: [weight] } }, }, ({ lookup }, input: { readonly x: number; readonly y: number }) => { const near = lookup.near([input.x, input.y]); @@ -62,14 +61,14 @@ const look = System.create({ }); /** Moves every entity in cell [0, 0] to [1, 0], before `lookAfter` in the same run. */ -const shift = System.create({ name: "indexesTest/shift", components: { write: [cell] } }, row => { +const shift = System.create({ components: { write: [cell] } }, row => { if (row[cell][0] === 0 && row[cell][1] === 0) row[cell] = [1, 0]; }); let afterShift: number | null = null; -const lookAfter = System.create({ name: "indexesTest/lookAfter", lookup: { near: { index: grid } }, after: [shift] }, ({ lookup }) => { +const lookAfter = System.create({ lookup: { near: { index: grid } }, after: [shift] }, ({ lookup }) => { afterShift = lookup.near([1, 0]).length; }); -const shiftThenLook = System.create({ name: "indexesTest/shiftThenLook", systems: [shift, lookAfter] }); +const shiftThenLook = System.create({ systems: [shift, lookAfter] }); const entitiesOf = (world: CreatedWorld): Promise => Observe.toPromise(Observe.select(world, { required: [cell] })); @@ -189,8 +188,9 @@ describe("unique indexes", () => { expect(known).toBe(first); expect(conflicted.failures).toEqual([{ kind: "index", - name: "indexesTest/byServerId", - message: `Index "indexesTest/byServerId" is unique, and entities ${first} and ${second} both have key 7; lookups give ${first}, the one with the lower entity index.`, + name: "byServerId", + id: "@adobe/ecs@0.1.1/src/implementation/world/indexes.test.ts#byServerId", + message: `Index "byServerId" is unique, and entities ${first} and ${second} both have key 7; lookups give ${first}, the one with the lower entity index.`, stack: null, }]); // Found once: the next run reports nothing. @@ -228,7 +228,7 @@ describe("call sites", () => { describe("finalizers", () => { const released: number[] = []; - Finalizer.create({ name: "indexesTest/release", components: { read: [order, cell] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { + Finalizer.create({ components: { read: [order, cell] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { released.push(lookup.near(row[cell]).length); }); it("look up through indexes as they are before the sync point applies", async () => { @@ -246,9 +246,9 @@ describe("finalizers", () => { }); }); -const keepTicking = System.create({ name: "indexesTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** Uses `grid` every tick it's added to. */ -const watchGrid = System.create({ name: "indexesTest/watchGrid", lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; }); +const watchGrid = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; }); describe("index lifetime", () => { it("builds an index the first time a system uses it and releases it, arena blocks included, once none does", async () => { @@ -310,7 +310,7 @@ const bucketsOf = (lookup: (key: readonly [number, number]) => { readonly length const isOrdered = (bucket: readonly number[]): boolean => bucket.every((entity, k) => k === 0 || (bucket[k - 1] >>> 0) < (entity >>> 0)); let incremental: readonly (readonly number[])[] = []; -const probe = System.create({ name: "indexesTest/probe", lookup: { near: { index: grid } } }, ({ lookup }) => { incremental = bucketsOf(key => lookup.near(key)); }); +const probe = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { incremental = bucketsOf(key => lookup.near(key)); }); const folded = Symbol("indexesTest/folded"); declare module "@adobe/ecs" { @@ -324,7 +324,7 @@ const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity /** A kernel folding the entities of its row's own cell's list, so kernels' lookups are checked against the same index. */ const foldNear = System.create({ - name: "indexesTest/foldNear", kernel: "cpu", + kernel: "cpu", components: { read: [cell], write: [folded] }, lookup: { near: { index: grid } }, }, (row, { lookup }) => { @@ -337,7 +337,7 @@ const foldNear = System.create({ /** Moves each row whose weight is below `share` of 1000 to the cell `cell` gives it, so keys change during runs. */ const rekey = System.create({ - name: "indexesTest/rekey", components: { read: [weight], write: [cell] }, before: [probe], + components: { read: [weight], write: [cell] }, before: [probe], }, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[cell] = cells[(row[weight] + input.seed) % cells.length]; }); @@ -396,10 +396,10 @@ declare module "@adobe/ecs" { } } -const byRank = Index.create({ name: "indexesTest/byRank", key: [rank], sorted: true }); -const byLaneRank = Index.create({ name: "indexesTest/byLaneRank", key: [lane, rank], sorted: true, components: { has: [weight] } }); -const byName = Index.create({ name: "indexesTest/byName", key: [label], sorted: true, unique: true }); -const byFlagPair = Index.create({ name: "indexesTest/byFlagPair", key: [flag, pair], sorted: true }); +const byRank = Index.create({ key: [rank], sorted: true }); +const byLaneRank = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); +const byName = Index.create({ key: [label], sorted: true, unique: true }); +const byFlagPair = Index.create({ key: [flag, pair], sorted: true }); /** What a sorted lookup gave: its rows' entities and ranks, and whether it changed. */ let ranked: { readonly ranks: readonly number[]; readonly changed: boolean } = { ranks: [], changed: false }; @@ -542,13 +542,13 @@ describe("sorted indexes on the main thread", () => { await world.run(System.create(({ commands }) => { commands.add(second, label, "one"); })); const report = await runReported(world, find); expect(kept).toBe(first); - expect(report.failures).toEqual([{ kind: "index", name: "indexesTest/byName", message: `Index "indexesTest/byName" is unique, and entities ${first} and ${second} both have key "one"; lookups give ${first}, the one with the lower entity index.`, stack: null }]); + expect(report.failures).toEqual([{ kind: "index", name: "byName", id: "@adobe/ecs@0.1.1/src/implementation/world/indexes.test.ts#byName", message: `Index "byName" is unique, and entities ${first} and ${second} both have key "one"; lookups give ${first}, the one with the lower entity index.`, stack: null }]); }); }); let incrementalRanks: readonly number[] = []; let incrementalLanes: readonly (readonly number[])[] = []; -const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { +const probeSorted = System.create({ lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { const all = lookup.lanes(); incrementalRanks = Array.from({ length: all.length }, (_, i) => all.at(i).entity); incrementalLanes = [-1, 0, 1].map(laneValue => { @@ -557,14 +557,14 @@ const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { l }); }); const rerank = System.create({ - name: "indexesTest/rerank", components: { read: [weight], write: [rank] }, before: [probeSorted], + components: { read: [weight], write: [rank] }, before: [probeSorted], }, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[rank] = (row[weight] * 31 + input.seed * 17) % 1000; }); /** A kernel folding the entities of its row's lane's range of ranks within 50 of its own, so kernels' sorted lookups are checked against the same index. */ const foldLane = System.create({ - name: "indexesTest/foldLane", kernel: "cpu", + kernel: "cpu", components: { read: [lane, rank], write: [folded] }, lookup: { lanes: { index: byLaneRank } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index f7c23bd3..7259a355 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { IndexDefinition } from '../../interface/registry/index.js'; -import { indexDefinitionOf, indexOfLookup, labelOfItem, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { idOrNull, indexDefinitionOf, indexOfLookup, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Failure } from '../../interface/report/failure.js'; import type { SystemLookup } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; @@ -112,7 +112,7 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn const sorted = keptSorted(definition.declaration); const index: WorldIndex = { definition, - label: labelOfItem(definition), + label: definition.name, users: 1, keys, has: (components?.has ?? []).map(component => idOf(state, component)), @@ -275,6 +275,7 @@ const reportConflict = (state: State, index: WorldIndex, kept: number, other: nu failures.push({ kind: "index", name: index.label, + id: idOrNull(index.definition), message: `Index "${index.label}" is unique, and entities ${kept} and ${other} both have key ${keyText(state, index, archetype, row)}; lookups give ${kept}, the one with the lower entity index.`, stack: null, }); diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts index f7db2f9b..765ef0be 100644 --- a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -103,7 +103,7 @@ const three = (row: number, value: (row: number, lane: number) => number): reado const four = (row: number, value: (row: number, lane: number) => number): readonly [number, number, number, number] => [value(row, 0), value(row, 1), value(row, 2), value(row, 3)]; const rows = 64; -const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { commands.spawn([f2a, f2b, f2p, f2o, i2a, i2b, i2o, u2a, u2b, u2o, f3a, f3b, f3p, f3o, i3a, i3b, i3o, u3a, u3b, u3o, f4a, f4b, f4p, f4o, i4a, i4b, i4o, u4a, u4b, u4o, fo, ma, mb, mo, qa, qb, qo, ua, uo])({ [f2a]: two(i, float(0)), @@ -149,7 +149,7 @@ const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => } }); -const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2f = System.create({ kernel: "gpu", components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; const p = row[f2p]; @@ -194,7 +194,7 @@ const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", compo else if (op === 38) row[f2o] = Vec2.faceforward(a, b, p); }); -const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: "gpu", components: { read: [f2a, f2b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2fScalar = System.create({ kernel: "gpu", components: { read: [f2a, f2b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; if (op === 0) row[fo] = Vec2.dot(a, b); @@ -202,7 +202,7 @@ const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: else if (op === 2) row[fo] = Vec2.length(a); }); -const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", components: { read: [i2a, i2b], write: [i2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2i = System.create({ kernel: "gpu", components: { read: [i2a, i2b], write: [i2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i2a]; const b = row[i2b]; if (op === 0) row[i2o] = Vec2.I32.add(a, b); @@ -223,7 +223,7 @@ const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", compo else if (op === 15) row[i2o] = Vec2.I32.clamp(a, [-1000, -1000], [1000, 1000]); }); -const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", components: { read: [u2a, u2b], write: [u2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2u = System.create({ kernel: "gpu", components: { read: [u2a, u2b], write: [u2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u2a]; const b = row[u2b]; if (op === 0) row[u2o] = Vec2.U32.add(a, b); @@ -242,7 +242,7 @@ const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", compo else if (op === 13) row[u2o] = Vec2.U32.clamp(a, [1000, 1000], [100000, 100000]); }); -const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3f = System.create({ kernel: "gpu", components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; const p = row[f3p]; @@ -289,7 +289,7 @@ const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", compo else if (op === 40) row[f3o] = Vec3.faceforward(a, b, p); }); -const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: "gpu", components: { read: [f3a, f3b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3fScalar = System.create({ kernel: "gpu", components: { read: [f3a, f3b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; if (op === 0) row[fo] = Vec3.dot(a, b); @@ -297,7 +297,7 @@ const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: else if (op === 2) row[fo] = Vec3.length(a); }); -const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", components: { read: [i3a, i3b], write: [i3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3i = System.create({ kernel: "gpu", components: { read: [i3a, i3b], write: [i3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i3a]; const b = row[i3b]; if (op === 0) row[i3o] = Vec3.I32.add(a, b); @@ -319,7 +319,7 @@ const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", compo else if (op === 16) row[i3o] = Vec3.I32.clamp(a, [-1000, -1000, -1000], [1000, 1000, 1000]); }); -const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", components: { read: [u3a, u3b], write: [u3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3u = System.create({ kernel: "gpu", components: { read: [u3a, u3b], write: [u3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u3a]; const b = row[u3b]; if (op === 0) row[u3o] = Vec3.U32.add(a, b); @@ -339,7 +339,7 @@ const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", compo else if (op === 14) row[u3o] = Vec3.U32.clamp(a, [1000, 1000, 1000], [100000, 100000, 100000]); }); -const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4f = System.create({ kernel: "gpu", components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; const p = row[f4p]; @@ -385,7 +385,7 @@ const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", compo else if (op === 39) row[f4o] = Vec4.faceforward(a, b, p); }); -const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: "gpu", components: { read: [f4a, f4b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4fScalar = System.create({ kernel: "gpu", components: { read: [f4a, f4b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; if (op === 0) row[fo] = Vec4.dot(a, b); @@ -393,7 +393,7 @@ const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: else if (op === 2) row[fo] = Vec4.length(a); }); -const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", components: { read: [i4a, i4b], write: [i4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4i = System.create({ kernel: "gpu", components: { read: [i4a, i4b], write: [i4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i4a]; const b = row[i4b]; if (op === 0) row[i4o] = Vec4.I32.add(a, b); @@ -415,7 +415,7 @@ const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", compo else if (op === 16) row[i4o] = Vec4.I32.clamp(a, [-1000, -1000, -1000, -1000], [1000, 1000, 1000, 1000]); }); -const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", components: { read: [u4a, u4b], write: [u4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4u = System.create({ kernel: "gpu", components: { read: [u4a, u4b], write: [u4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u4a]; const b = row[u4b]; if (op === 0) row[u4o] = Vec4.U32.add(a, b); @@ -435,7 +435,7 @@ const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", compo else if (op === 14) row[u4o] = Vec4.U32.clamp(a, [1000, 1000, 1000, 1000], [100000, 100000, 100000, 100000]); }); -const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", components: { read: [ma, mb], write: [mo] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4 = System.create({ kernel: "gpu", components: { read: [ma, mb], write: [mo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[mb]; if (op === 0) row[mo] = Mat4x4.multiply(a, b); @@ -445,18 +445,18 @@ const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", com else if (op === 4) row[mo] = Mat4x4.scale(a, 0.5); }); -const mat4x4Vector = System.create({ name: "intrinsicsTest/mat4x4Vector", kernel: "gpu", components: { read: [ma, f4a], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4Vector = System.create({ kernel: "gpu", components: { read: [ma, f4a], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[f4a]; if (op === 0) row[f4o] = Mat4x4.multiplyVec4(a, b); }); -const mat4x4Scalar = System.create({ name: "intrinsicsTest/mat4x4Scalar", kernel: "gpu", components: { read: [ma], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4Scalar = System.create({ kernel: "gpu", components: { read: [ma], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; if (op === 0) row[fo] = Mat4x4.determinant(a); }); -const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", components: { read: [qa, qb], write: [qo] } }, (row, _context, { op }: { readonly op: number }) => { +const quat = System.create({ kernel: "gpu", components: { read: [qa, qb], write: [qo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[qo] = Quat.add(a, b); @@ -465,14 +465,14 @@ const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", compone else if (op === 3) row[qo] = Quat.negate(a); }); -const quatScalar = System.create({ name: "intrinsicsTest/quatScalar", kernel: "gpu", components: { read: [qa, qb], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const quatScalar = System.create({ kernel: "gpu", components: { read: [qa, qb], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[fo] = Quat.dot(a, b); else if (op === 1) row[fo] = Quat.length(a); }); -const u32 = System.create({ name: "intrinsicsTest/u32", kernel: "gpu", components: { read: [ua], write: [uo] } }, (row, _context, { op }: { readonly op: number }) => { +const u32 = System.create({ kernel: "gpu", components: { read: [ua], write: [uo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ua]; if (op === 0) row[uo] = U32.countLeadingZeros(a); else if (op === 1) row[uo] = U32.countOneBits(a); diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index 3059ca2e..426a9844 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { labelOfItem } from "../../interface/registry/index.js"; import { Observe } from "../../observe/index.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; @@ -33,24 +32,24 @@ declare module "@adobe/ecs" { const countsStart = new Map(); /** Adds its struct input to `total`, and its object input's names to `names`. */ -const add = System.create({ name: "joinTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const append = System.create({ name: "joinTest/append", resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { +const append = System.create({ resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -const spawn = System.create({ name: "joinTest/spawn" }, ({ commands }, input: { readonly x: number }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); /** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ -const tally = System.create({ name: "joinTest/tally", resources: { write: [counts] } }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { +const tally = System.create({ resources: { write: [counts] } }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { const next = new Map(resources[counts]); for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); resources[counts] = next; commands.spawn([words])({ [words]: input.words }); }); -const fail = System.create({ name: "joinTest/fail" }, () => { throw new Error("joinTest failure"); }); -const idle = System.create({ name: "joinTest/idle" }, () => {}); +const fail = System.create({}, () => { throw new Error("joinTest failure"); }); +const idle = System.create({}, () => {}); /** A world shared with a thread joined through a channel, and the joined world. */ const joined = (threads: readonly ("main")[] = []): { readonly world: CreatedWorld; readonly remote: World; readonly channel: MessageChannel } => { @@ -133,7 +132,7 @@ describe("joining", () => { const report = await reportOf(world, await remote.run(fail)); expect(report.failures.map(failure => failure.message)).toEqual(["joinTest failure"]); const local = System.create(() => {}); - await expect(remote.run(local)).rejects.toThrow(`System "${labelOfItem(local)}" has no id, so it can't run from another thread`); + await expect(remote.run(local)).rejects.toThrow(`has no id, so it can't run from another thread`); expect(await reportOf(world, await remote.run(idle))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); await remote.dispose(); await world.dispose(); diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 28bbd8fa..913621a0 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Thread } from '../../interface/thread/thread.js'; import type { World } from '../../interface/world/world.js'; -import { declarationOf, identifiedItems, idOfItem, idOfKey, indexOfLookup, keyOfId, labelOf, labelOfItem, requireDefinition, threadOf, threadOfDeclaration, watchedBy, type Definition } from '../../interface/registry/index.js'; +import { declarationOf, identifiedItems, idOfItem, idOfKey, indexOfLookup, keyOfId, labelOf, requireDefinition, threadOf, threadOfDeclaration, watchedBy, type Definition } from '../../interface/registry/index.js'; import { unsharedKeyOf } from './index-keys.js'; import { layoutOf, type Views } from '../../interface/registry/layout.js'; import { @@ -530,7 +530,7 @@ export const joinWorld = (port: Parameters[0], options: { readon /** What an observer system reads, for the world, by this thread's numbers; throws for what can't cross. */ const describe = (leaf: Leaf): ObserverDeclaration => { - const label = labelOfItem(leaf); + const label = leaf.name; const readable = (keys: readonly unknown[] | undefined): number[] => (keys ?? []).map(key => { const symbol = symbolOf(key); if (layoutOf(declarationOf(symbol)?.type).kind === "object" && !ownObject(symbol, claims)) { @@ -549,12 +549,12 @@ export const joinWorld = (port: Parameters[0], options: { readon let indexed: string | ObserverIndex | null = null; if (index !== undefined) { const object = unsharedKeyOf(index.declaration.key); - if (object !== undefined) throw new Error(`Observer system "${label}" looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`); - if (declared.changed === true) throw new Error(`Observer system "${label}" runs when index "${labelOfItem(index)}" changed, as its lookup "${name}"'s changed is true, so it runs only on the world's thread.`); + if (object !== undefined) throw new Error(`Observer system "${label}" looks up through index "${index.name}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`); + if (declared.changed === true) throw new Error(`Observer system "${label}" runs when index "${index.name}" changed, as its lookup "${name}"'s changed is true, so it runs only on the world's thread.`); const { key, components: held, sorted, unique } = index.declaration; // One without an id, made in a function, is named by its declaration, and the world makes one like it. indexed = idOfItem(index) ?? { - name: labelOfItem(index), key: readable(key), has: present(held?.has), not: present(held?.not), sorted: sorted === true, unique: unique === true, + name: index.name, key: readable(key), has: present(held?.has), not: present(held?.not), sorted: sorted === true, unique: unique === true, }; } lookups.push({ @@ -581,7 +581,7 @@ export const joinWorld = (port: Parameters[0], options: { readon localsByLeaf.set(leaf, local); localsByNumber.set(number, new WeakRef(local)); collected.register(leaf, number); - post({ protocol: protocolName, kind: "observer", number, name: labelOfItem(leaf), declaration, added: false }); + post({ protocol: protocolName, kind: "observer", number, name: leaf.name, declaration, added: false }); return local; }; @@ -615,7 +615,7 @@ export const joinWorld = (port: Parameters[0], options: { readon return Promise.all(locals.map(local => runNumbered(local.number, local.leaf, undefined, local))).then(lastOf); } id = idOfItem(definition); - if (id === undefined) throw new Error(`System "${labelOfItem(definition)}" has no id, so it can't run from another thread; the build gives top-level systems one.`); + if (id === undefined) throw new Error(`System "${definition.name}" has no id, so it can't run from another thread; the build gives top-level systems one.`); } catch (error) { return Promise.reject(error); diff --git a/packages/ecs/src/implementation/world/join.type-test.ts b/packages/ecs/src/implementation/world/join.type-test.ts index f94ba253..59cc6f61 100644 --- a/packages/ecs/src/implementation/world/join.type-test.ts +++ b/packages/ecs/src/implementation/world/join.type-test.ts @@ -15,7 +15,7 @@ const joined: World = join(channel.port2, { threads: ["main"] }); join(worker); join(self, {}); join(port, { threads: [] }); -const ran: Promise = joined.run(System.create({ name: "joinTypeTest/idle" }, () => {})); +const ran: Promise = joined.run(System.create({}, () => {})); // @ts-expect-error a joined thread fills logical threads other than the world's join(port, { threads: ["world"] }); // @ts-expect-error it claims declared threads diff --git a/packages/ecs/src/implementation/world/joined-observer.test.ts b/packages/ecs/src/implementation/world/joined-observer.test.ts index e10d3c41..00c0a0eb 100644 --- a/packages/ecs/src/implementation/world/joined-observer.test.ts +++ b/packages/ecs/src/implementation/world/joined-observer.test.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { /** What the observer system read, were it run: this test stands for the thread, so it never runs. */ const seen: number[] = []; -const watch = System.create({ name: "joinedObserverTest/watch", observer: true, components: { read: [amount] } }, row => { seen.push(row[amount]); }); +const watch = System.create({ observer: true, components: { read: [amount] } }, row => { seen.push(row[amount]); }); describe("an observer system on a joined thread", () => { it("is waited for, not cancelled, by a sync point after a writer copied the rest of a post the thread claimed partway", async () => { diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts index 577213fc..cac44437 100644 --- a/packages/ecs/src/implementation/world/joined-observer.ts +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; import type { SystemLookup } from '../../interface/system/access.js'; -import { idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { idOfItem, idOfKey, idOrNull, indexOfLookup, labelOf, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Archetype } from './archetype.js'; import { readCell } from './cells.js'; import { indexOf } from './entities.js'; @@ -88,6 +88,8 @@ export type JoinedObserver = Copied & { readonly target: ObserverTarget; readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; readonly runner: Runner; /** Struct components its lookups read or watch, and the key parts of the indexes they go through. */ readonly lookupReads: readonly number[]; @@ -200,13 +202,13 @@ const startCounting = (state: State): void => { export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: ObserverTarget, since = 0): Runner => { for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { const index = indexOfLookup(lookup); - if (index !== undefined && watchesEntries(lookup)) throw new Error(entriesOnJoined(leaf, threadOf(leaf.declaration, "System", leaf.name) ?? target.label, labelOfItem(index))); + if (index !== undefined && watchesEntries(lookup)) throw new Error(entriesOnJoined(leaf, threadOf(leaf.declaration, "System", leaf.name) ?? target.label, index.name)); } // One placed there by the objects it names, rather than the thread's own, is named there by its id and its keys'. if (!state.observer.proxies.has(leaf)) { if (idOfItem(leaf) === undefined) { const thread = threadOf(leaf.declaration, "System", leaf.name) ?? target.label; - throw new Error(`System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level systems one.`); + throw new Error(`System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level systems one.`); } const unnamed = unnamedKey(leaf); if (unnamed !== null) throw new Error(unnamed); @@ -261,7 +263,7 @@ export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: Obs const index = target.free.pop() ?? target.placed.length; const proxy = state.observer.proxies.get(leaf); const observer: JoinedObserver = { - target, index, label: proxy?.label ?? labelOfItem(leaf), runner: base, + target, index, label: proxy?.label ?? leaf.name, id: proxy === undefined ? idOrNull(leaf) : null, runner: base, matched: base.matched, reads: ids([...components?.read ?? [], ...components?.added ?? [], ...components?.changed ?? []]).filter(isStruct), resourceReads: resourceIds.filter(isStruct), @@ -793,7 +795,7 @@ export const settleObserverKeys = (state: State, components: readonly number[], /** Why an observer system's key can't cross to a joined thread, or null when every one can. */ export const unnamedKey = (leaf: Leaf): string | null => { const unnamed = keysOf(leaf).find(key => idOfKey(key) === undefined); - return unnamed === undefined ? null : `System "${labelOfItem(leaf)}" names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; + return unnamed === undefined ? null : `System "${leaf.name}" names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; }; /** Runs of observer systems from joined threads, by the value standing for their input: the run's sequence there. */ diff --git a/packages/ecs/src/implementation/world/joining-worker.fixture.ts b/packages/ecs/src/implementation/world/joining-worker.fixture.ts index 9ef3e75d..c314f97d 100644 --- a/packages/ecs/src/implementation/world/joining-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/joining-worker.fixture.ts @@ -10,7 +10,7 @@ import { drive, idle, names, total, until, withReports, type Driven, type Report /** A thread joining a world through the port it's sent and driving it, or holding a world it shares through it. */ /** A system only this thread imports, which a world elsewhere never did. */ -const unimportedByMain = System.create({ name: "joiningTest/unimportedByMain" }, () => {}); +const unimportedByMain = System.create({}, () => {}); type Port = MessagePort | NodeMessagePort; export type ToJoiningWorker = diff --git a/packages/ecs/src/implementation/world/joining.fixture.ts b/packages/ecs/src/implementation/world/joining.fixture.ts index 99b83e63..3a3cc332 100644 --- a/packages/ecs/src/implementation/world/joining.fixture.ts +++ b/packages/ecs/src/implementation/world/joining.fixture.ts @@ -14,7 +14,7 @@ import { add, drive, names, total, until, withReports } from "./joining.test-hel */ /** A system only this thread imports, which a world on a worker never did. */ -const unimportedByWorker = System.create({ name: "joiningTest/unimportedByWorker" }, () => {}); +const unimportedByWorker = System.create({}, () => {}); type Worker = { readonly ask: (message: ToJoiningWorker, transfer?: readonly unknown[]) => Promise; diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index d37b091c..8bf3c356 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -24,17 +24,17 @@ declare module "@adobe/ecs" { } /** Appends a digit to `total`, so the order of runs shows in it. */ -export const add = System.create({ name: "joiningTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +export const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -export const append = System.create({ name: "joiningTest/append", resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { +export const append = System.create({ resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -export const spawn = System.create({ name: "joiningTest/spawn" }, ({ commands }, input: { readonly x: number }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); -export const fail = System.create({ name: "joiningTest/fail" }, () => { throw new Error("joiningTest failure"); }); -export const idle = System.create({ name: "joiningTest/idle" }, () => {}); +export const fail = System.create({}, () => { throw new Error("joiningTest failure"); }); +export const idle = System.create({}, () => {}); /** The first value an observer gives that `accept` takes. */ export const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts index a71e304b..4b4cfa67 100644 --- a/packages/ecs/src/implementation/world/kernel-imports.test.ts +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -18,19 +18,18 @@ declare module "@adobe/ecs" { } -const spawn = System.create({ name: "kernelImports/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [0, 0, 0] }); commands.spawn([position, velocity, target])({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [target]: leader }); }); // `kernel: "cpu"` compiles the body at build time, to run in chunks across workers as WASM where it can. -const integrate = System.create({ name: "kernelImports/integrate", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const integrate = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); // Kernels use lookups, imported pure functions, commands and imported lists too. const chase = System.create({ - name: "kernelImports/chase", kernel: "cpu", components: { read: [position, target], write: [velocity] }, lookup: { targets: { read: [position] } }, @@ -56,10 +55,10 @@ it("runs systems compiled as kernels", async () => { }); // A kernel's `add` and `remove` take an imported `Symbol(...)` as one key, and an imported list as a list. -const ignite = System.create({ name: "kernelImports/ignite" }, ({ commands }) => { +const ignite = System.create({}, ({ commands }) => { commands.spawn(sparks)({ [position]: [0, 0, 0] }); }); -const settle = System.create({ name: "kernelImports/settle", kernel: "cpu", components: { has: [spark] } }, (row, { commands }) => { +const settle = System.create({ kernel: "cpu", components: { has: [spark] } }, (row, { commands }) => { commands.remove(row.entity, spark); }); diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts index d208454a..40de366c 100644 --- a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -17,7 +17,6 @@ declare module "@adobe/ecs" { } export const labels = System.create({ - name: "kernelLookupTest/labels", kernel: "cpu", components: { read: [target], write: [offset] }, // @ts-expect-error kernel lookups read linear values diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts index 7a30af6f..e97f501c 100644 --- a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -12,7 +12,7 @@ declare module "@adobe/ecs" { } } -export const beyond = System.create({ name: "kernelPathTest/beyond", kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { +export const beyond = System.create({ kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { // @ts-expect-error a pair has no third item row[sum] = row[pair][2]; }); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index 7bf89bbe..e87525d6 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -33,40 +33,40 @@ declare module "@adobe/ecs" { const spawned = Archetype.create([value, made]); -const seed = System.create({ name: "kernelSpawnsTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); /** One spawn per row, from a kernel. */ -const fromKernel = System.create({ name: "kernelSpawnsTest/fromKernel", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const fromKernel = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { commands.spawn(spawned)({ [value]: row[index] }); }); /** The same archetype from a main-thread system, before the kernel. */ -const fromMain = System.create({ name: "kernelSpawnsTest/fromMain", before: [fromKernel] }, ({ commands }) => { +const fromMain = System.create({ before: [fromKernel] }, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn(spawned)({ [value]: 100 + i }); }); /** Spawns, then adds to and removes from what it spawned, in the same kernel and tick. */ -const spawnAndEdit = System.create({ name: "kernelSpawnsTest/spawnAndEdit", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const spawnAndEdit = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { const entity = commands.spawn([value, flag, made])({ [value]: row[index] }); commands.add(entity, extra, row[index] * 10); commands.remove(entity, flag); }); /** A spawn with a tuple, and an object component left to its start. */ -const withObjects = System.create({ name: "kernelSpawnsTest/withObjects", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const withObjects = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { commands.spawn([value, position, name, made])({ [value]: row[index], [position]: [row[index], -row[index]] }); }); -const despawnMade = System.create({ name: "kernelSpawnsTest/despawnMade", components: { has: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const despawnMade = System.create({ components: { has: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); const added = { seen: 0 }; -const countAdded = System.create({ name: "kernelSpawnsTest/countAdded", components: { added: [value], has: [made] } }, () => { added.seen++; }); +const countAdded = System.create({ components: { added: [value], has: [made] } }, () => { added.seen++; }); /** Each made entity's value, in storage order. */ const madeValues: number[] = []; -const readMade = System.create({ name: "kernelSpawnsTest/readMade", observer: true, components: { read: [value], has: [made] } }, row => { madeValues.push(row[value]); }); +const readMade = System.create({ observer: true, components: { read: [value], has: [made] } }, row => { madeValues.push(row[value]); }); const valuesOf = async (world: ReturnType): Promise => { madeValues.length = 0; await world.run(readMade); @@ -89,7 +89,7 @@ describe("kernel spawns", () => { it("join main-thread spawns of the same archetype: the main thread's first, then the kernel's in row order", async () => { const world = createWorld({ [workers]: 0 }); await world.run(seed, { count: 4 }); - const frame = System.create({ name: "kernelSpawnsTest/mixed", systems: [fromKernel, fromMain] }); + const frame = System.create({ systems: [fromKernel, fromMain] }); await world.run(frame); expect(await valuesOf(world)).toEqual([100, 101, 102, 0, 1, 2, 3]); await world.run(frame); diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts index 476d695f..b21fe7cf 100644 --- a/packages/ecs/src/implementation/world/kernels-csp.test.ts +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -15,11 +15,11 @@ declare module "@adobe/ecs" { } -const seed = System.create({ name: "kernelsCspTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ name: "kernelsCspTest/double", kernel: "cpu", components: { write: [value] } }, row => { +const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts index 697076db..2f7f5dad 100644 --- a/packages/ecs/src/implementation/world/kernels.test.ts +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -26,12 +26,12 @@ declare module "@adobe/ecs" { const label = Symbol("kernelsTest/label"); /** Reads `value` and `other`, writes nothing, reads no resources. */ -const system = System.create({ name: "kernelsTest/read", kernel: "cpu", components: { read: [value, other] } }, row => { +const system = System.create({ kernel: "cpu", components: { read: [value, other] } }, row => { if (row[value] > row[other]) return; }); -const spawn = System.create({ name: "kernelsTest/spawn" }, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); -const double = System.create({ name: "kernelsTest/double", kernel: "cpu", components: { write: [value] } }, row => { +const spawn = System.create({}, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); +const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); @@ -61,13 +61,13 @@ describe("bindKernel", () => { describe("attachKernel", () => { it("rejects at registration a spawn site naming a component twice", () => { - expect(() => attachKernel(system, describing({ keys: [value, other], spawns: [[1, 0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming "kernelsTest/other" twice.'); + expect(() => attachKernel(system, describing({ keys: [value, other], spawns: [[1, 0, 1]] }))).toThrow('spawns a list naming "kernelsTest/other" twice.'); }); it("rejects at registration a tuple or struct given to a component without a linear layout", () => { - expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('Kernel "kernelsTest/read": gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); + expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [0] }))).not.toThrow(); }); it("rejects at registration a spawn site naming a component outside its keys", () => { - expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming component 1 of its 1 keys.'); + expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('spawns a list naming component 1 of its 1 keys.'); }); }); diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index 91a336a8..1cbc3ad1 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -10,7 +10,7 @@ import { commandIdOf, idOf, type Key, type State } from './state.js'; import { generateWasm, instantiateWasm, type Type, type WasmColumn, type WasmKernel, type WasmLookup, type WasmRun, type WasmScalar } from './wasm.js'; import { createDirectory, lookupParameters, type Directory } from './directories.js'; import { generateWgsl, type WgslKernel } from './wgsl.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf } from '../../interface/registry/index.js'; import type { WorldIndex } from './indexes.js'; import { kernelIndexesOf, refreshSource, writeIndexParameters, type KernelIndex } from './kernel-indexes.js'; import { createIndexReader, createIndexSource, type IndexReaderSetup } from './index-readers.js'; @@ -87,7 +87,7 @@ const leafType = (layout: Layout, path: readonly (number | string)[]): Type | nu */ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescription, byName: ReadonlyMap = new Map(), since: Since = { value: 0 }): BoundKernel => { const { lookup, components: query } = leaf.declaration; - const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; + const fail = (message: string): never => { throw new Error(`Kernel "${leaf.name}": ${message}`); }; const booleans: boolean[] = []; // Per column, in elements of its view: entity and tick columns are dense. const strides: number[] = []; diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index df9ba86b..04ce3380 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -42,41 +42,40 @@ declare module "@adobe/ecs" { const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill" }, ({ commands }, input: { readonly count: number }) => { +const fill = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { +const sum = System.create({ components: { read: [value] } }, row => { total.sum += row[value]; total.count++; }); /** Entities whose values the lookups read, recorded as they spawn. */ const spawned: Entity[] = []; -const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { +const make = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany" }, ({ commands }, input: { readonly count: number }) => { +const spawnMany = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { +const aim = System.create({ components: { write: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { has: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ - name: "layoutDirectoryTest/look", kernel: "cpu", components: { read: [target], write: [seen] }, lookup: { targets: { read: [value] } }, @@ -85,7 +84,7 @@ const look = System.create({ row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index 0d8a2d47..e80572f9 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -44,41 +44,40 @@ declare module "@adobe/ecs" { const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill" }, ({ commands }, input: { readonly count: number }) => { +const fill = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { +const sum = System.create({ components: { read: [value] } }, row => { total.sum += row[value]; total.count++; }); /** Entities whose values the lookups read, recorded as they spawn. */ const spawned: Entity[] = []; -const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { +const make = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany" }, ({ commands }, input: { readonly count: number }) => { +const spawnMany = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { +const aim = System.create({ components: { write: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { has: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ - name: "layoutDirectoryTest/look", kernel: "cpu", components: { read: [target], write: [seen] }, lookup: { targets: { read: [value] } }, @@ -87,7 +86,7 @@ const look = System.create({ row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 43917a70..6f8b9db6 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -30,18 +30,17 @@ declare module "@adobe/ecs" { export const rows = 4_096; -const seed = System.create({ name: "lifetimeFixture/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { let previous = commands.spawn([value])({ [value]: 0 }); for (let i = 1; i < rows; i++) previous = commands.spawn([value, target, out])({ [value]: i, [target]: previous, [out]: 0 }); }); /** A kernel that holds what kernels can: a lookup's directory, reduction partials and its load on each worker. */ const measure = System.create({ - name: "lifetimeFixture/measure", kernel: "cpu", components: { read: [value, target], write: [out] }, lookup: { targets: { read: [value] } }, - reduce: { [total]: "+" }, + resources: { reduce: [total] }, }, (row, { lookup: { targets }, reduce }) => { const other = targets(row[target]); row[out] = other === null ? -1 : row[value] - other[value]; diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts index 34702a2f..717f512a 100644 --- a/packages/ecs/src/implementation/world/lifetime.test.ts +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -23,13 +23,13 @@ declare module "@adobe/ecs" { } -const seed = System.create({ name: "lifetimeTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { const root = commands.spawn([count])({ [count]: 1 }); for (let i = 0; i < 4; i++) commands.spawn([count, parent])({ [count]: i, [parent]: root }); }); /** A module-level system, run between and after the fresh ones, as an app's own systems are. */ -const steady = System.create({ name: "lifetimeTest/steady", components: { read: [count], changed: [count] } }, row => { void row[count]; }); +const steady = System.create({ components: { read: [count], changed: [count] } }, row => { void row[count]; }); let copied = 0; diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts index c37e8001..db1b466f 100644 --- a/packages/ecs/src/implementation/world/loop.test.ts +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -16,10 +16,10 @@ declare module "@adobe/ecs" { } } -const increment = requireDefinition(System.create({ name: "loopTest/increment", components: { write: [count] } }, row => { row[count] += 1; })); -const reader = requireDefinition(System.create({ name: "loopTest/reader", components: { read: [count] } }, row => { void row[count]; })); -const ordered = requireDefinition(System.create({ name: "loopTest/ordered", components: { read: [count] }, after: [System.create({ name: "loopTest/phase" }, () => {})] }, row => { void row[count]; })); -const takesInput = requireDefinition(System.create({ name: "loopTest/input" }, (_context, input: { readonly by: number }) => {})); +const increment = requireDefinition(System.create({ components: { write: [count] } }, row => { row[count] += 1; })); +const reader = requireDefinition(System.create({ components: { read: [count] } }, row => { void row[count]; })); +const ordered = requireDefinition(System.create({ components: { read: [count] }, after: [System.create({}, () => {})] }, row => { void row[count]; })); +const takesInput = requireDefinition(System.create({}, (_context, input: { readonly by: number }) => {})); /** A host whose frames and timers run only when the test says, at the times it gives. */ const createHost = (settings: { cadence: Cadence; always: boolean; wake: number }) => { @@ -143,7 +143,7 @@ describe("the loop", () => { const { host } = createHost({ cadence: { kind: "frame" }, always: false, wake: Infinity }); const loop = createLoop(host); loop.add(increment); - expect(() => loop.add(reader)).toThrow('Systems "loopTest/increment" and "loopTest/reader" both access "loopTest/count" and one writes it'); + expect(() => loop.add(reader)).toThrow('Systems "increment" and "reader" both access "loopTest/count" and one writes it'); expect(() => loop.add(takesInput)).toThrow("takes input, which ticks don't pass"); loop.add(increment); expect(() => loop.add(ordered)).toThrow("both access"); @@ -164,7 +164,7 @@ describe("a world's loop platform", () => { it("can be given, so a back-to-back driver ticks as fast as tasks run", async () => { const driver = backToBack(); const world = createWorld({ [always]: true }, { loop: driver }); - world.add(System.create({ name: "loopTest/idle", components: { has: [count] } }, () => {})); + world.add(System.create({ components: { has: [count] } }, () => {})); const start = performance.now(); // At 60 frames a second, a timer's frames would take 1.6 s. await new Promise(resolve => { diff --git a/packages/ecs/src/implementation/world/loop.ts b/packages/ecs/src/implementation/world/loop.ts index 3ab89871..c9686b6e 100644 --- a/packages/ecs/src/implementation/world/loop.ts +++ b/packages/ecs/src/implementation/world/loop.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Cadence } from '../../interface/resources/cadence.js'; import type { Composite, Definition } from '../../interface/registry/index.js'; -import { labelOfItem } from '../../interface/registry/index.js'; import { requireDefinition } from '../../interface/registry/program.js'; import { isLeaf, isObserver, scheduleOf } from '../../interface/registry/schedule.js'; import type { Leaf } from '../../interface/system/leaf.js'; @@ -105,7 +104,7 @@ const checkNoInput = (system: Definition, seen: Set = new Set()): vo if (seen.has(system)) return; seen.add(system); if (isLeaf(system)) { - if (system.input !== undefined) throw new Error(`System "${labelOfItem(system)}" takes input, which ticks don't pass; run it instead.`); + if (system.input !== undefined) throw new Error(`System "${system.name}" takes input, which ticks don't pass; run it instead.`); return; } for (const member of system.declaration.systems) checkNoInput(requireDefinition(member), seen); @@ -248,7 +247,7 @@ export const createLoop = (host: LoopHost): Loop => { } if (systems.includes(system)) return; const next = [...systems, system]; - const candidate: Composite = { kind: "composite", name: "tick", declaration: { name: "tick", systems: next } }; + const candidate: Composite = { kind: "composite", name: "tick", declaration: { systems: next } }; // Throws for members ordered in a cycle, or conflicting with no order between them. scheduleOf(candidate); systems.push(system); @@ -266,7 +265,7 @@ export const createLoop = (host: LoopHost): Loop => { const at = systems.indexOf(system); if (at < 0 || composite === null) return null; const left = systems.filter((_, k) => k !== at); - const next: Composite | null = left.length === 0 ? null : { kind: "composite", name: "tick", declaration: { name: "tick", systems: left } }; + const next: Composite | null = left.length === 0 ? null : { kind: "composite", name: "tick", declaration: { systems: left } }; // Throws for members that conflict with no ordering path left between them. if (next !== null) scheduleOf(next); const previous = composite; diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index 1baf3e12..3b1f5179 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -24,30 +24,33 @@ declare module "@adobe/ecs" { } -// Two libraries, written apart, both name a rule, a finalizer and a system "linked". -Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [first] }); -Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [second] }); +// Two libraries, written apart, each declare a rule, a finalizer and a system labeled "linked". +const library = (finalizer: (row: { readonly [value]: number }) => void, derived: typeof first | typeof second) => { + Rule.create({ components: { has: [value] }, derive: [derived] }); + Finalizer.create({ components: { read: [value] } }, finalizer); +}; const released: string[] = []; let failing = false; -Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`first ${row[value]}`); }); -Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`second ${row[value]}`); if (failing) throw new Error("second failed"); }); +library(row => { released.push(`first ${row[value]}`); }, first); +library(row => { released.push(`second ${row[value]}`); if (failing) throw new Error("second failed"); }, second); const ran: string[] = []; -const spawn = System.create({ name: "namesTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); -const despawn = System.create({ name: "namesTest/despawn", components: { has: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); +const despawn = System.create({ components: { has: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); const failOnce = System.create(() => { throw new Error("unnamed"); }); -describe("systems, rules and finalizers of one name", () => { - it("are distinct in schedules, both run, and are told apart in reports by their labels", async () => { - const a = System.create({ name: "namesTest/linked", components: { write: [value] } }, row => { ran.push("a"); row[value] += 1; }); - const b = System.create({ name: "namesTest/linked", components: { write: [value] }, after: [a] }, row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); - const c = System.create({ name: "namesTest/linked", components: { read: [value] }, after: [b] }, row => { ran.push(`c${row[value]}`); }); +describe("systems, rules and finalizers of one label", () => { + it("are distinct in schedules, both run, and are told apart in reports by their ids", async () => { + const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { write: [value] }, after }, body); + const a = linked([], row => { ran.push("a"); row[value] += 1; }); + const b = linked([a], row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); + const c = linked([b], row => { ran.push(`c${row[value]}`); }); await using world = createWorld({ [workers]: 0 }); await world.run(spawn); - const report = await runReported(world, System.create({ name: "namesTest/frame", systems: [c, b, a] })); + const report = await runReported(world, System.create({ systems: [c, b, a] })); expect(ran).toEqual(["a", "b"]); - expect(report.failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "system", name: "namesTest/linked#6", message: "b failed" }]); - expect(report.skipped).toEqual(["namesTest/linked#7"]); + expect(report.failures.map(({ kind, name, id, message }) => ({ kind, name, id, message }))).toEqual([{ kind: "system", name: "linked", id: null, message: "b failed" }]); + expect(report.skipped).toEqual(["linked"]); }); it("both apply, as rules and as finalizers", async () => { await using world = createWorld({ [workers]: 0 }); @@ -58,19 +61,17 @@ describe("systems, rules and finalizers of one name", () => { const report = await runReported(world, despawn); failing = false; expect(released).toEqual(["first 1", "second 1"]); - expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "namesTest/linked#4" }]); + expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "library" }]); }); - it("are labeled by name and id where the plugin gave ids", async () => { - const write = (id: string) => System.create(identify({ name: "namesTest/library", components: { write: [other] } }, id), row => { row[other] = 1; throw new Error(id); }); - const one = write("one.ts#library"); - const two = write("two.ts#library"); + it("are labeled by the bindings of their ids, which reports give beside them", async () => { + const [one, two] = ["one.ts#library", "two.ts#library"].map(id => System.create(identify({ components: { write: [other] } }, id), row => { row[other] = 1; throw new Error(id); })); await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "namesTest/spawnOther" }, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); - const names = async (system: System) => (await runReported(world, system)).failures.map(failure => failure.name); - expect([await names(one), await names(two)]).toEqual([["namesTest/library (one.ts#library)"], ["namesTest/library (two.ts#library)"]]); + await world.run(System.create({}, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); + const labels = async (system: System | undefined) => system === undefined ? [] : (await runReported(world, system)).failures.map(failure => [failure.name, failure.id]); + expect([await labels(one), await labels(two)]).toEqual([[["library", "one.ts#library"]], [["library", "two.ts#library"]]]); }); - it("label a system without a name by its binding and the id the plugin gave it", async () => { + it("label a top-level system by its binding, its id naming its package, version, module and binding", async () => { await using world = createWorld({ [workers]: 0 }); - expect((await runReported(world, failOnce)).failures.map(failure => failure.name)).toEqual([expect.stringMatching(/^failOnce \(@adobe\/ecs@[^/]+\/src\/implementation\/world\/names\.test\.ts#failOnce\)$/)]); + expect((await runReported(world, failOnce)).failures.map(failure => [failure.name, failure.id])).toEqual([["failOnce", expect.stringMatching(/^@adobe\/ecs@[^/]+\/src\/implementation\/world\/names\.test\.ts#failOnce$/)]]); }); }); diff --git a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts index f28637ec..304761ef 100644 --- a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts @@ -25,15 +25,15 @@ declare module "@adobe/ecs" { let spawned: Entity[] = []; -const spawn = System.create({ name: "observeCasesGpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < 100; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 })); }); -const warm = System.create({ name: "observeCasesGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -const idle = System.create({ name: "observeCasesGpuTest/idle" }, () => {}); +const idle = System.create({}, () => {}); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 2b66146d..c73e4f7d 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -31,14 +31,14 @@ declare module "@adobe/ecs" { } -const raise = System.create({ name: "observeCasesTest/raise", resources: { write: [level] } }, ({ resources }, input: { readonly by: number }) => { +const raise = System.create({ resources: { write: [level] } }, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; resources[level] += input.by; }); -const touchOther = System.create({ name: "observeCasesTest/touchOther", resources: { write: [other] } }, ({ resources }) => { resources[other] += 1; }); -const spawn = System.create({ name: "observeCasesTest/spawn" }, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); -const sum = System.create({ name: "observeCasesTest/sum", components: { read: [weight] }, reduce: { [total]: "+" } }, (row, { reduce }) => { reduce[total](row[weight]); }); -const keep = System.create({ name: "observeCasesTest/keep", resources: { write: [level] } }, ({ resources }) => { resources[level] = resources[level] + 0; }); +const touchOther = System.create({ resources: { write: [other] } }, ({ resources }) => { resources[other] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); +const sum = System.create({ components: { read: [weight] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[weight]); }); +const keep = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = resources[level] + 0; }); /** Errors thrown out of band while `catching` runs, as uncaught errors would be, caught instead. */ const catching = async (run: () => Promise): Promise => { @@ -80,18 +80,18 @@ declare module "@adobe/ecs" { /** The entity the systems below act on, set by `create`. */ let target = Entity.none; -const create = System.create({ name: "observeCasesTest/create" }, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); -const despawn = System.create({ name: "observeCasesTest/despawn" }, ({ commands }) => { commands.despawn(target); }); -const weigh = System.create({ name: "observeCasesTest/weigh", components: { write: [weight], not: [filler] } }, row => { +const create = System.create({}, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); +const despawn = System.create({}, ({ commands }) => { commands.despawn(target); }); +const weigh = System.create({ components: { write: [weight], not: [filler] } }, row => { row[weight] += 1; row[weight] += 1; }); -const warm = System.create({ name: "observeCasesTest/warm", components: { write: [heat] } }, row => { row[heat] += 1; }); -const double = System.create({ name: "observeCasesTest/double", kernel: "cpu", components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); -const addTag = System.create({ name: "observeCasesTest/addTag" }, ({ commands }) => { commands.add(target, tag, true); }); -const removeTag = System.create({ name: "observeCasesTest/removeTag" }, ({ commands }) => { commands.remove(target, tag); }); -const removeWeight = System.create({ name: "observeCasesTest/removeWeight" }, ({ commands }) => { commands.remove(target, weight); }); -const addFlag = System.create({ name: "observeCasesTest/addFlag" }, ({ commands }) => { commands.add(target, flag, true); }); +const warm = System.create({ components: { write: [heat] } }, row => { row[heat] += 1; }); +const double = System.create({ kernel: "cpu", components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); +const addTag = System.create({}, ({ commands }) => { commands.add(target, tag, true); }); +const removeTag = System.create({}, ({ commands }) => { commands.remove(target, tag); }); +const removeWeight = System.create({}, ({ commands }) => { commands.remove(target, weight); }); +const addFlag = System.create({}, ({ commands }) => { commands.add(target, flag, true); }); const score = Symbol("observeCasesTest/score"); const benched = Symbol("observeCasesTest/benched"); @@ -108,30 +108,30 @@ declare module "@adobe/ecs" { /** Players the systems below act on, in spawn order, set by `players`. */ let roster: Entity[] = []; -const players = System.create({ name: "observeCasesTest/players" }, ({ commands }) => { +const players = System.create({}, ({ commands }) => { roster = [commands.spawn([score])({ [score]: 1 }), commands.spawn([score])({ [score]: 2 }), commands.spawn([score, benched])({ [score]: 3, [benched]: true })]; }); -const join = System.create({ name: "observeCasesTest/join" }, ({ commands }) => { roster.push(commands.spawn([score])({ [score]: 0 })); }); -const bench = System.create({ name: "observeCasesTest/bench" }, ({ commands }) => { commands.add(roster[0], benched, true); }); +const join = System.create({}, ({ commands }) => { roster.push(commands.spawn([score])({ [score]: 0 })); }); +const bench = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); }); /** The first player leaves the bench as the last joins it, so as many match as before. */ -const swap = System.create({ name: "observeCasesTest/swap" }, ({ commands }) => { +const swap = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); commands.remove(roster[2], benched); }); -const mark = System.create({ name: "observeCasesTest/mark" }, ({ commands }) => { commands.add(roster[0], marker, true); }); -const leave = System.create({ name: "observeCasesTest/leave" }, ({ commands }) => { commands.despawn(roster[1]); }); -const score10 = System.create({ name: "observeCasesTest/score10", components: { write: [score] } }, row => { row[score] += 10; }); -const nudge = System.create({ name: "observeCasesTest/nudge", components: { write: [score] } }, row => { row[score] += 0; }); -const invert = System.create({ name: "observeCasesTest/invert", components: { write: [score] } }, row => { row[score] = -row[score]; }); -const invertKernel = System.create({ name: "observeCasesTest/invertKernel", kernel: "cpu", components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); +const mark = System.create({}, ({ commands }) => { commands.add(roster[0], marker, true); }); +const leave = System.create({}, ({ commands }) => { commands.despawn(roster[1]); }); +const score10 = System.create({ components: { write: [score] } }, row => { row[score] += 10; }); +const nudge = System.create({ components: { write: [score] } }, row => { row[score] += 0; }); +const invert = System.create({ components: { write: [score] } }, row => { row[score] = -row[score]; }); +const invertKernel = System.create({ kernel: "cpu", components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); /** Swaps the bench like `swap`, then despawns more entities than the journal holds, so it drops the swap's entries. */ -const swapAndCrowd = System.create({ name: "observeCasesTest/swapAndCrowd" }, ({ commands }) => { +const swapAndCrowd = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); commands.remove(roster[2], benched); for (const spawned of crowded.splice(0)) commands.despawn(spawned); }); const crowded: Entity[] = []; -const crowdUp = System.create({ name: "observeCasesTest/crowdUp" }, ({ commands }) => { +const crowdUp = System.create({}, ({ commands }) => { for (let i = 0; i < 5000; i++) crowded.push(commands.spawn([filler])({ [filler]: i })); }); @@ -154,10 +154,10 @@ declare module "@adobe/ecs" { } // Kept at 32 bits, so a 64-bit bound may fall between two values it keeps. let coarseValues: readonly number[] = []; -const spawnCoarse = System.create({ name: "observeCasesTest/spawnCoarse" }, ({ commands }) => { +const spawnCoarse = System.create({}, ({ commands }) => { for (const each of coarseValues) commands.spawn([coarse])({ [coarse]: each }); }); -const shiftCoarse = System.create({ name: "observeCasesTest/shiftCoarse", components: { write: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); +const shiftCoarse = System.create({ components: { write: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); type Condition = number | { readonly "<"?: number; readonly ">"?: number; readonly "<="?: number; readonly ">="?: number; readonly "=="?: number; readonly "!="?: number }; @@ -169,12 +169,12 @@ const passesCondition = (value: number, condition: Condition): boolean => typeof let ranks: readonly (readonly [number, number, boolean])[] = []; /** The entities `rankAll` spawned, in order. */ let ranked: Entity[] = []; -const rankAll = System.create({ name: "observeCasesTest/rankAll" }, ({ commands }) => { +const rankAll = System.create({}, ({ commands }) => { ranked = ranks.map(([value, key, marked]) => commands.spawn(marked ? [rank, second, marker] : [rank, second])({ [rank]: value, [second]: key })); }); /** What `rerank` sets: an index into `ranked` and its new rank. */ let reranks: readonly (readonly [number, number])[] = []; -const rerank = System.create({ name: "observeCasesTest/rerank" }, ({ commands }) => { +const rerank = System.create({}, ({ commands }) => { for (const [index, value] of reranks) commands.add(ranked[index], rank, value); }); @@ -444,11 +444,11 @@ describe("observing entities", () => { await using world = createWorld(); await world.run(create); const seen: unknown[] = []; - const subscribe = System.create({ name: "observeCasesTest/subscribeEntity" }, () => { + const subscribe = System.create({}, () => { entity(world, target, { required: [weight] })(value => seen.push(value)); }); - const later = System.create({ name: "observeCasesTest/laterWeigh", components: { write: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); - await world.run(System.create({ name: "observeCasesTest/subscribeThenWeigh", systems: [subscribe, later] })); + const later = System.create({ components: { write: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); + await world.run(System.create({ systems: [subscribe, later] })); await settle(world); await world.run(warm); expect(seen).toEqual([{ [weight]: 3 }]); @@ -577,11 +577,11 @@ describe("observing queries", () => { await using world = createWorld(); await world.run(players); const seen: number[] = []; - const subscribe = System.create({ name: "observeCasesTest/subscribe" }, () => { + const subscribe = System.create({}, () => { count(world, { required: [score], where: { [score]: { "<": 0 } } })(value => seen.push(value)); }); - const later = System.create({ name: "observeCasesTest/later", components: { write: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); - await world.run(System.create({ name: "observeCasesTest/subscribeThenWrite", systems: [subscribe, later] })); + const later = System.create({ components: { write: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); + await world.run(System.create({ systems: [subscribe, later] })); await settle(world); expect(seen).toEqual([3]); }); @@ -759,8 +759,8 @@ describe("observing a page of a query", () => { describe("the factories' own runs", () => { it("rethrow their systems' failures out of band, from their reports, once a later run of theirs read them", async () => { await using world = createWorld({ [workers]: 0 }); - const fail = System.create({ name: "observeCasesTest/failRun" }, () => { throw new Error("run failed"); }); - const nothing = System.create({ name: "observeCasesTest/nothing" }, () => {}); + const fail = System.create({}, () => { throw new Error("run failed"); }); + const nothing = System.create({}, () => {}); let reports = -1; const caught = await catching(async () => { // Its notifier throws once the failed run's report loses its errors, which a run of the factories' own does diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index d3106dc5..47cd4dd0 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -27,17 +27,17 @@ declare module "@adobe/ecs" { } let spawned: Entity[] = []; -const seed = System.create({ name: "observeFactoriesTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { spawned = []; for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); }); -const grow = System.create({ name: "observeFactoriesTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const raise = System.create({ name: "observeFactoriesTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const nothing = System.create({ name: "observeFactoriesTest/nothing" }, () => {}); +const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); +const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({}, () => {}); /** Counts passes: the observer passes after each run's sync point. */ const passes = { count: 0 }; -const counter = System.create({ name: "observeFactoriesTest/counter", observer: true }, () => { passes.count++; }); +const counter = System.create({ observer: true }, () => { passes.count++; }); /** * A world of `count` entities, each amount its index and one more, at level 1: the runs below registered, so runners @@ -115,7 +115,7 @@ describe("observer factories", () => { const stop = Observe.entity(world, spawned[0], { required: [amount] })(value => entities.push(value?.[amount])); await settle(world); await world.run(grow); - await world.run(System.create({ name: "observeFactoriesTest/growOthers", components: { write: [amount] } }, row => { + await world.run(System.create({ components: { write: [amount] } }, row => { if (row.entity !== spawned[0]) row[amount] += 1; })); expect(entities).toEqual([4, 5]); @@ -170,7 +170,7 @@ describe("observer factories", () => { it("read only what they list, so a write elsewhere notifies nothing", async () => { await using world = await seeded(3); - const writeOther = System.create({ name: "observeFactoriesTest/writeOther", components: { write: [other] } }, row => { row[other] = 1; }); + const writeOther = System.create({ components: { write: [other] } }, row => { row[other] = 1; }); let notified = 0; Observe.entity(world, spawned[0], { required: [amount] })(() => notified++); Observe.select(world, { required: [amount], order: { [amount]: false }, page: { offset: 0, limit: 2 } })(() => notified++); @@ -183,7 +183,7 @@ describe("observer factories", () => { it("rethrow only their own systems' failures from their runs, not those of observer systems the world added", async () => { await using world = await seeded(3); - const failing = System.create({ name: "observeFactoriesTest/failing", observer: true }, () => { throw new Error("added failed"); }); + const failing = System.create({ observer: true }, () => { throw new Error("added failed"); }); world.add(failing); const caught: unknown[] = []; const original = globalThis.queueMicrotask; @@ -206,7 +206,7 @@ describe("observer factories", () => { } expect(caught).toEqual([]); // The world's runs still report it. - expect((await runReported(world, nothing)).failures.map(failure => failure.name)).toEqual(["observeFactoriesTest/failing"]); + expect((await runReported(world, nothing)).failures.map(failure => failure.name)).toEqual(["failing"]); }); }); diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index 452674b0..bf6d2af5 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -41,18 +41,18 @@ declare module "@adobe/ecs" { /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; -const add = System.create({ name: "observeJoinedTest/add", resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const spawn = System.create({ name: "observeJoinedTest/spawn" }, ({ commands }, input: { readonly x: number }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); -const spawnPlaced = System.create({ name: "observeJoinedTest/spawnPlaced" }, ({ commands }, input: { readonly x: number }) => { +const spawnPlaced = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([place, caption, widget, kept])({ [place]: input.x, [widget]: null, [kept]: `k${input.x}` }); }); -const nullCaptions = System.create({ name: "observeJoinedTest/nullCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ name: "observeJoinedTest/restartCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ name: "observeJoinedTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); +const nullCaptions = System.create({ components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); +const restartCaptions = System.create({ components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); +const dropCaptions = System.create({ components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); /** A world shared with a thread joined through a channel claiming `threads`, and the joined world. */ const joined = (threads: readonly "observeJoinedTest"[] = []): { readonly world: CreatedWorld; readonly remote: World } => { @@ -210,9 +210,11 @@ describe("observer factories on a joined thread", () => { await new Promise(resolve => setTimeout(resolve, 10)); for (const run of runs) for (const failure of (await reportOf(world, run)).failures) reported.push(failure.message); }); - // The entity observer's system, added, fails in a run's report; the selection's scan, run, rejects, rethrown. + // The entity observer's system, added, fails in a run's report, which the factories, whose system it is, rethrow; + // the selection's scan, run, rejects, rethrown. expect([...reported, ...caught.map(String)]).toEqual([ expect.stringMatching(/^System "Observe\.entity\(observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), + expect.stringMatching(/^Error: Observe\.entity\(observeJoinedTest\/caption\): System "Observe\.entity\(observeJoinedTest\/caption\)".*has no id/), expect.stringMatching(/System "Observe\.select\(observeJoinedTest\/place, observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), ]); expect(values).toEqual([]); diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 788ee088..316bc76f 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -25,38 +25,38 @@ declare module "@adobe/ecs" { } } -const count = System.create({ name: "observeTicksCasesTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const watch = System.create({ name: "observeTicksCasesTest/watch", resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); -const fail = System.create({ name: "observeTicksCasesTest/fail" }, () => { throw new Error("tick failed"); }); -const nothing = System.create({ name: "observeTicksCasesTest/nothing" }, () => {}); -const idle = System.create({ name: "observeTicksCasesTest/idle", observer: true }, () => {}); -const fast = System.create({ name: "observeTicksCasesTest/fast", resources: { write: [cadence, always] } }, ({ resources }) => { +const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const watch = System.create({ resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); +const fail = System.create({}, () => { throw new Error("tick failed"); }); +const nothing = System.create({}, () => {}); +const idle = System.create({ observer: true }, () => {}); +const fast = System.create({ resources: { write: [cadence, always] } }, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1 }; resources[always] = true; }); -const calm = System.create({ name: "observeTicksCasesTest/calm", resources: { write: [always] } }, ({ resources }) => { resources[always] = false; }); -const later = System.create({ name: "observeTicksCasesTest/later", resources: { read: [clock] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const calm = System.create({ resources: { write: [always] } }, ({ resources }) => { resources[always] = false; }); +const later = System.create({ resources: { read: [clock], reduce: [wake] } }, ({ resources, reduce }) => { if (resources[clock].tick === 0) reduce[wake](resources[clock].now + 20); }); -const seed = System.create({ name: "observeTicksCasesTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, watched])({ [value]: i, [watched]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ name: "observeTicksCasesTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); -const watchChanged = System.create({ name: "observeTicksCasesTest/watchChanged", kernel: "cpu", components: { write: [watched], changed: [value] }, after: [bump] }, row => { +const watchChanged = System.create({ kernel: "cpu", components: { write: [watched], changed: [value] }, after: [bump] }, row => { row[watched] = row[watched] + 1; }); /** Counts rows whose `value` was added since it last ran. */ -const fresh = System.create({ name: "observeTicksCasesTest/fresh", components: { write: [watched], added: [value] } }, row => { +const fresh = System.create({ components: { write: [watched], added: [value] } }, row => { row[watched] = row[watched] + 1; }); -const flagAll = System.create({ name: "observeTicksCasesTest/flagAll", components: { has: [value] } }, (row, { commands }) => { +const flagAll = System.create({ components: { has: [value] } }, (row, { commands }) => { commands.add(row.entity, flag, true); }); -const spawnOne = System.create({ name: "observeTicksCasesTest/spawnOne" }, ({ commands }) => { +const spawnOne = System.create({}, ({ commands }) => { commands.spawn([value, watched])({ [value]: -1, [watched]: 0 }); }); @@ -141,7 +141,7 @@ describe("ticks", () => { world.add(fail); await world.run(nothing); const last = await reports.until(next => next.failures.length > 0); - expect(last.failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observeTicksCasesTest/fail", "tick failed"]]); + expect(last.failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "fail", "tick failed"]]); }); it("put what an observer throws on a tick's values in a later run's report, once", async () => { diff --git a/packages/ecs/src/implementation/world/observe.test-helper.ts b/packages/ecs/src/implementation/world/observe.test-helper.ts index 4c4e71a5..585c0df3 100644 --- a/packages/ecs/src/implementation/world/observe.test-helper.ts +++ b/packages/ecs/src/implementation/world/observe.test-helper.ts @@ -2,7 +2,7 @@ import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; -const settled = System.create({ name: "observeTest/settled", observer: true }, () => {}); +const settled = System.create({ observer: true }, () => {}); /** * Resolves once every observer factory subscribed before it had its first value: after the factories' flush, which the diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts index 31a9da53..646099d0 100644 --- a/packages/ecs/src/implementation/world/observed-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -18,16 +18,16 @@ declare module "@adobe/ecs" { } -const seed = System.create({ name: "observedWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, seen])({ [value]: i, [seen]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ name: "observedWorkersTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); -const watch = System.create({ name: "observedWorkersTest/watch", kernel: "cpu", components: { write: [seen], changed: [value] }, after: [bump] }, row => { +const watch = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] }, after: [bump] }, row => { row[seen] = row[seen] + 1; }); diff --git a/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts index 0fe9ca94..76728d2a 100644 --- a/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts @@ -24,7 +24,7 @@ export type FromContainedWorker = const channel = scopeChannel(); let remote: World | null = null; -const placed = System.create({ name: "observerContainedTest/placed", observer: true }, () => {}); +const placed = System.create({ observer: true }, () => {}); channel.onMessage(message => { if ("protocol" in message) return; diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 6f83e6ef..72064e2c 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -25,14 +25,14 @@ export const gate: { words: Int32Array | null } = { words: null }; /** What the watcher's runs saw, in order. */ export const tallies: number[] = []; -export const bump = System.create({ name: "observerContainedTest/bump", resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); -export const nothing = System.create({ name: "observerContainedTest/nothing" }, () => {}); -export const setHolding = System.create({ name: "observerContainedTest/setHolding", resources: { write: [holding] } }, ({ resources }, input: { readonly holding: number }) => { +export const bump = System.create({ resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); +export const nothing = System.create({}, () => {}); +export const setHolding = System.create({ resources: { write: [holding] } }, ({ resources }, input: { readonly holding: number }) => { resources[holding] = input.holding; }); /** After each sync point that changed the tally: notes it, and, while `holding` says so, waits there to be released. */ -export const watchTally = System.create({ name: "observerContainedTest/watchTally", observer: true, resources: { read: [tally, holding], changed: [tally] } }, ({ resources }) => { +export const watchTally = System.create({ observer: true, resources: { read: [tally, holding], changed: [tally] } }, ({ resources }) => { const value = resources[tally]; tallies.push(value); const { words } = gate; diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index 6371ed9e..afd92345 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -18,21 +18,21 @@ declare module "@adobe/ecs" { } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ -const widen = System.create({ name: "observerContainedTest/widen", resources: { write: [wide] } }, ({ resources }) => { +const widen = System.create({ resources: { write: [wide] } }, ({ resources }) => { const first = resources[wide][0] + 1; const base = first * 100; resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; }); -const step = System.create({ name: "observerContainedTest/step", resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); -const pause = System.create({ name: "observerContainedTest/pause", resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { +const step = System.create({ resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); +const pause = System.create({ resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); const seen: { wide: number[][]; steps: number[] } = { wide: [], steps: [] }; -const watchWide = System.create({ name: "observerContainedTest/watchWide", observer: true, resources: { read: [wide], changed: [wide] } }, ({ resources }) => { +const watchWide = System.create({ observer: true, resources: { read: [wide], changed: [wide] } }, ({ resources }) => { seen.wide.push([...resources[wide]]); }); -const watchSteps = System.create({ name: "observerContainedTest/watchSteps", observer: true, resources: { read: [steps], not: [paused], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ observer: true, resources: { read: [steps], not: [paused], changed: [steps] } }, ({ resources }) => { seen.steps.push(resources[steps]); }); @@ -54,7 +54,7 @@ const joined = async () => { if (status === "joined") break; await new Promise(resolve => setTimeout(resolve, 0)); } - await remote.run(System.create({ name: "observerContainedTest/nothing", observer: true }, () => {})); + await remote.run(System.create({ observer: true }, () => {})); return { world, remote, close: async () => { await remote.dispose(); await world.dispose(); } }; }; diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 20eb4ad0..9af7336a 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -81,20 +81,20 @@ export const hold = (): void => { export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: the counter moves on once a tick, then every row's stamp, spot and word take it. */ -export const count = System.create({ name: "observerJoinedWorkerTest/count", resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); -export const advance = System.create({ name: "observerJoinedWorkerTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); -export const ticked = System.create({ name: "observerJoinedWorkerTest/ticked", systems: [count, advance] }); -export const seed = System.create({ name: "observerJoinedWorkerTest/seed" }, ({ commands }, input: { readonly count: number }) => { +export const ticked = System.create({ systems: [count, advance] }); +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ name: "observerJoinedWorkerTest/setStall", resources: { write: [stall] } }, ({ resources }, input: { readonly pause: number }) => { +export const setStall = System.create({ resources: { write: [stall] } }, ({ resources }, input: { readonly pause: number }) => { resources[stall] = input.pause; }); @@ -103,7 +103,7 @@ export type Seen = { counter: number; rows: number; wrong: number }; export const seen: Seen[] = []; /** Reads every row, which must all be of one tick, pausing at the end of its first chunk while `stall` says so. */ -export const watchRows = System.create({ name: "observerJoinedWorkerTest/watchRows", observer: true, components: { read: [stamp, spot, word] }, resources: { read: [counter, stall] } }, (row, { resources }) => { +export const watchRows = System.create({ observer: true, components: { read: [stamp, spot, word] }, resources: { read: [counter, stall] } }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); if (last === undefined || last.counter !== value || last.rows === 1000) seen.push(last = { counter: value, rows: 0, wrong: 0 }); @@ -117,7 +117,7 @@ export const watchRows = System.create({ name: "observerJoinedWorkerTest/watchRo export const looked: Entity[] = []; /** Looks up every row by entity, which must all be of one tick, spinning halfway while `stall` says so. */ -export const lookRows = System.create({ name: "observerJoinedWorkerTest/lookRows", observer: true, resources: { read: [counter, stall] }, lookup: { found: { read: [stamp, spot] } } }, ({ resources, lookup }) => { +export const lookRows = System.create({ observer: true, resources: { read: [counter, stall] }, lookup: { found: { read: [stamp, spot] } } }, ({ resources, lookup }) => { const value = resources[counter]; const run: Seen = { counter: value, rows: 0, wrong: 0 }; seen.push(run); @@ -132,27 +132,27 @@ export const lookRows = System.create({ name: "observerJoinedWorkerTest/lookRows }); /** 100 rows heated 0 to 99, which a GPU kernel warms by 100 each run. */ -export const spawnHeated = System.create({ name: "observerJoinedWorkerTest/spawnHeated" }, ({ commands }) => { +export const spawnHeated = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -export const warm = System.create({ name: "observerJoinedWorkerTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +export const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -export const nextPhase = System.create({ name: "observerJoinedWorkerTest/nextPhase", resources: { write: [phase] } }, ({ resources }) => { +export const nextPhase = System.create({ resources: { write: [phase] } }, ({ resources }) => { resources[phase] += 1; }); /** What `countHot` counted, run by run, on the joined thread. */ export const hot: number[] = []; -export const byHeat = Index.create({ name: "observerJoinedWorkerTest/byHeat", key: [heat], sorted: true }); +export const byHeat = Index.create({ key: [heat], sorted: true }); /** Counts the rows at least 150 hot, through the sorted index on what the GPU kernel writes. */ -export const countHot = System.create({ name: "observerJoinedWorkerTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +export const countHot = System.create({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot.push(lookup.hot({ ">=": 150 }).length); }); /** On the world's thread: the motto of a round, and the round, every motto of one size. */ export const mottoOf = (round: number): string => `motto ${round % 10}`; -export const setMotto = System.create({ name: "observerJoinedWorkerTest/setMotto", resources: { write: [motto, mottoRound] } }, ({ resources }, input: { readonly round: number }) => { +export const setMotto = System.create({ resources: { write: [motto, mottoRound] } }, ({ resources }, input: { readonly round: number }) => { resources[motto] = mottoOf(input.round); resources[mottoRound] = input.round; }); @@ -164,7 +164,7 @@ export const mottos: { readonly round: number; readonly motto: string }[] = []; * Reads only resources, a string among them: waits on the gate first, while `stall` says so, its round plus one, then * reads the motto, which must be its round's. */ -export const watchMotto = System.create({ name: "observerJoinedWorkerTest/watchMotto", observer: true, resources: { read: [motto, mottoRound, stall] } }, ({ resources }) => { +export const watchMotto = System.create({ observer: true, resources: { read: [motto, mottoRound, stall] } }, ({ resources }) => { const round = resources[mottoRound]; pauseAt(resources[stall], round + 1); mottos.push({ round, motto: resources[motto] }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 00b0d1af..2816aa5c 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -34,17 +34,17 @@ declare module "@adobe/ecs" { /** The label's start, which a command here gives it by this value. */ const blank = { text: "" }; -const byCell = Index.create({ name: "observerJoinedTest/byCell", key: [cell], components: { has: [amount] } }); -const allTagged = Index.create({ name: "observerJoinedTest/allTagged", key: [], components: { has: [tagged] } }); +const byCell = Index.create({ key: [cell], components: { has: [amount] } }); +const allTagged = Index.create({ key: [], components: { has: [tagged] } }); -const raise = System.create({ name: "observerJoinedTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const nothing = System.create({ name: "observerJoinedTest/nothing" }, () => {}); -const spawn = System.create({ name: "observerJoinedTest/spawn" }, ({ commands }, input: { readonly at: number }) => { +const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({}, () => {}); +const spawn = System.create({}, ({ commands }, input: { readonly at: number }) => { commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); }); -const spawnLabelled = System.create({ name: "observerJoinedTest/spawnLabelled" }, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); -const grow = System.create({ name: "observerJoinedTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const move = System.create({ name: "observerJoinedTest/move", components: { write: [cell] } }, row => { row[cell] += 10; }); +const spawnLabelled = System.create({}, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); +const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); +const move = System.create({ components: { write: [cell] } }, row => { row[cell] += 10; }); /** What the joined thread's observer bodies saw, reset by each test. */ const seen: { levels: number[]; amounts: number[][]; looked: (number | boolean)[][]; tagged: number[]; labels: string[]; calls: number } = { levels: [], amounts: [], looked: [], tagged: [], labels: [], calls: 0 }; @@ -56,32 +56,32 @@ const reset = (): void => { /** The entities the tests look up, by the joined thread's systems. */ const watched: Entity[] = []; -const watchLevel = System.create({ name: "observerJoinedTest/watchLevel", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevel = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { seen.calls++; seen.levels.push(resources[level]); }); -const watchAmounts = System.create({ name: "observerJoinedTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { +const watchAmounts = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { (seen.amounts[seen.amounts.length - 1] ??= []).push(row[amount]); }); -const startAmounts = System.create({ name: "observerJoinedTest/startAmounts", observer: true }, () => { seen.amounts.push([]); }); +const startAmounts = System.create({ observer: true }, () => { seen.amounts.push([]); }); const amountsPass = System.create([startAmounts, watchAmounts]); -const lookChanged = System.create({ name: "observerJoinedTest/lookChanged", observer: true, lookup: { found: { read: [amount], changed: [amount] } } }, ({ lookup }) => { +const lookChanged = System.create({ observer: true, lookup: { found: { read: [amount], changed: [amount] } } }, ({ lookup }) => { seen.looked.push(watched.flatMap(entity => { const row = lookup.found(entity); return row === null ? [] : [row[amount], row.changed]; })); }); -const countTagged = System.create({ name: "observerJoinedTest/countTagged", observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { +const countTagged = System.create({ observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { seen.tagged.push(lookup.all().length); }); -const inCell = System.create({ name: "observerJoinedTest/inCell", observer: true, lookup: { found: { index: byCell, read: [amount] } } }, ({ lookup }) => { +const inCell = System.create({ observer: true, lookup: { found: { index: byCell, read: [amount] } } }, ({ lookup }) => { const found = lookup.found(11); seen.tagged.push(found.length === 0 ? 0 : found.length * 100 + (found.at(0)?.[amount] ?? -1)); }); -const failing = System.create({ name: "observerJoinedTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { if (resources[level] > 1) throw new Error("observer joined failure"); }); -const readLabels = System.create({ name: "observerJoinedTest/readLabels", observer: true, components: { read: [label, amount] } }, row => { +const readLabels = System.create({ observer: true, components: { read: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); @@ -206,11 +206,11 @@ describe("observer systems on a joined thread", () => { await world.run(spawn, { at: 11 }); // Made in a function, so the build gives them no id. const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly has: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly has: readonly [typeof amount] } }>] => [ - Index.create({ name: "observerJoinedTest/madeTagged", key: [], components: { has: [tagged, cell] } }), - Index.create({ name: "observerJoinedTest/madeByCell", key: [cell], sorted: true, components: { has: [amount] } }), + Index.create({ key: [], components: { has: [tagged, cell] } }), + Index.create({ key: [cell], sorted: true, components: { has: [amount] } }), ]; const [madeTagged, madeByCell] = made(); - const sizes = System.create({ name: "observerJoinedTest/sizes", observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell, read: [amount] } } }, ({ lookup }) => { + const sizes = System.create({ observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell, read: [amount] } } }, ({ lookup }) => { const all = lookup.all(); seen.looked.push([all.changed]); const high = lookup.cells({ ">=": 5 }); @@ -252,7 +252,7 @@ describe("observer systems on a joined thread", () => { await world.run(raise); await turns(); const report = await runReported(world, nothing); - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "observerJoinedTest/failing", "observer joined failure"]]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "failing", "observer joined failure"]]); await close(); }); @@ -306,17 +306,17 @@ describe("observer systems on a joined thread", () => { const { world, close } = await joined(["observerJoinedTest"]); await world.run(spawnLabelled); // Made in a function, so the build gives it no id. - const made = () => System.create({ name: "observerJoinedTest/madeLabels", observer: true, components: { read: [label, amount] } }, row => { + const made = () => System.create({ observer: true, components: { read: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); const madeLabels = made(); - await expect(world.run(madeLabels)).rejects.toThrow(`System "observerJoinedTest/madeLabels" runs on thread "observerJoinedTest", which a joined thread fills, and has no id, so that thread can't name it`); - // Another of that name, numbered. + await expect(world.run(madeLabels)).rejects.toThrow(`runs on thread "observerJoinedTest", which a joined thread fills, and has no id, so that thread can't name it`); + // Another made there, labeled the same. const added = made(); world.add(added); await turns(); const report = await runReported(world, nothing); - expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["system", "observerJoinedTest/madeLabels#2"]]); + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["system", "made"]]); world.remove(added); await turns(); expect(seen.labels).toEqual([]); diff --git a/packages/ecs/src/implementation/world/observer.browser.test.ts b/packages/ecs/src/implementation/world/observer.browser.test.ts index 0fb70a2d..a81b3e87 100644 --- a/packages/ecs/src/implementation/world/observer.browser.test.ts +++ b/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -18,19 +18,19 @@ declare module "@adobe/ecs" { } -const spawn = System.create({ name: "observerGpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -const warm = System.create({ name: "observerGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); let total = 0; -const sum = System.create({ name: "observerGpuTest/sum", observer: true, components: { read: [heat] } }, row => { total += row[heat]; }); +const sum = System.create({ observer: true, components: { read: [heat] } }, row => { total += row[heat]; }); -const byHeat = Index.create({ name: "observerGpuTest/byHeat", key: [heat], sorted: true }); +const byHeat = Index.create({ key: [heat], sorted: true }); let hot = 0; -const countHot = System.create({ name: "observerGpuTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +const countHot = System.create({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot = lookup.hot({ ">=": 150 }).length; }); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index 3eec2f0e..86162038 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { } } -const byCell = Index.create({ name: "observerTest/byCell", key: [cell] }); +const byCell = Index.create({ key: [cell] }); /** What observer bodies saw, by system, reset by each test. */ const seen: { levels: number[]; amounts: number[]; counted: number[]; cells: number[]; reports: number[]; removals: number[]; contexts: object[] } = { levels: [], amounts: [], counted: [], cells: [], reports: [], removals: [], contexts: [] }; @@ -30,41 +30,41 @@ const reset = (): void => { for (const list of Object.values(seen)) list.length = 0; }; -const raise = System.create({ name: "observerTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const spawn = System.create({ name: "observerTest/spawn" }, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); -const grow = System.create({ name: "observerTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const nothing = System.create({ name: "observerTest/nothing" }, () => {}); +const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); +const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); +const nothing = System.create({}, () => {}); -const watchLevel = System.create({ name: "observerTest/watchLevel", observer: true, resources: { read: [level] } }, context => { +const watchLevel = System.create({ observer: true, resources: { read: [level] } }, context => { seen.levels.push(context.resources[level]); seen.contexts.push(context); }); -const watchAmounts = System.create({ name: "observerTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); -const count = System.create({ name: "observerTest/count", observer: true, components: { has: [amount] } }, () => { seen.counted.push(1); }); -const inCell = System.create({ name: "observerTest/inCell", observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { +const watchAmounts = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); +const count = System.create({ observer: true, components: { has: [amount] } }, () => { seen.counted.push(1); }); +const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { seen.cells.push(lookup.found(7).length); }); -const failing = System.create({ name: "observerTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); -const watchReport = System.create({ name: "observerTest/watchReport", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const watchReport = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { seen.reports.push(row[Report.errors].failures.length); }); const raiseThenWatch = System.create([raise, watchLevel]); -const watchLevelChanges = System.create({ name: "observerTest/watchLevelChanges", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevelChanges = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { seen.levels.push(resources[level]); }); /** The world a removing body acts on. */ let current: World | null = null; -const removeLevel = System.create({ name: "observerTest/removeLevel", observer: true }, () => { +const removeLevel = System.create({ observer: true }, () => { seen.removals.push(1); current?.remove(watchLevel); }); /** Adds `watchLevel` to the world the test runs, during a run. */ -const addWatch = System.create({ name: "observerTest/addWatch" }, () => { current?.add(watchLevel); }); -const addLevel = System.create({ name: "observerTest/addLevel", observer: true }, () => { +const addWatch = System.create({}, () => { current?.add(watchLevel); }); +const addLevel = System.create({ observer: true }, () => { current?.add(watchLevel); }); @@ -132,7 +132,7 @@ describe("observer systems", () => { expect((await reportOf(world, raised)).failures).toEqual([]); // Read by a run of its own, the next one, whose report holds them. const { failures } = await reportOf(world, raised + 2); - expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observerTest/failing", "observer failed"]]); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "failing", "observer failed"]]); }); it("don't make the loop tick, and runs of them alone don't wake it", async () => { @@ -164,7 +164,7 @@ describe("observer systems", () => { expect(counted).toBe(ran + 2); expect(seen.reports).toEqual([1]); expect((await reportOf(world, ran + 1)).failures).toEqual([]); - expect((await reportOf(world, counted)).failures.map(({ name }) => name)).toEqual(["observerTest/failing"]); + expect((await reportOf(world, counted)).failures.map(({ name }) => name)).toEqual(["failing"]); }); it("leave the composite ticks run as it is when one added alone is removed, and release what it held", async () => { diff --git a/packages/ecs/src/implementation/world/observer.ts b/packages/ecs/src/implementation/world/observer.ts index bb0c8fd5..1f06a1dd 100644 --- a/packages/ecs/src/implementation/world/observer.ts +++ b/packages/ecs/src/implementation/world/observer.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; import type { Failure } from '../../interface/report/failure.js'; -import { labelOfItem } from '../../interface/registry/index.js'; import { readAhead, type ExecuteContext } from './execute.js'; import { failureOf } from './failures.js'; import { refreshUses } from './indexes.js'; @@ -133,7 +132,7 @@ const runOne = (leaf: Leaf, runner: Runner, failures: Failure[] | null): Failure const chunk = chunks[c]; // An observer system runs here, as no kernel, so its chunks are settled. if (chunk instanceof Promise || chunk.error === undefined) continue; - (failures ??= []).push(failureOf("system", labelOfItem(leaf), chunk.error)); + (failures ??= []).push(failureOf("system", leaf, chunk.error)); break; } return failures; diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index 9a672ebc..56a8de59 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -16,11 +16,11 @@ declare module "@adobe/ecs" { } } -const seed = System.create({ name: "poolSizeTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ name: "poolSizeTest/double", kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); -const resize = System.create({ name: "poolSizeTest/resize", resources: { write: [workers] } }, ({ resources }, input: { readonly size: number | null }) => { +const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); +const resize = System.create({ resources: { write: [workers] } }, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts index fe0527a1..6e9d9f8e 100644 --- a/packages/ecs/src/implementation/world/ports.fixture.ts +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -17,12 +17,12 @@ declare module "@adobe/ecs" { } } -const seed = System.create({ name: "portsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 100_000; i++) commands.spawn([value])({ [value]: i }); }); /** Long enough per row that a task over 100,000 rows is still running when its worker is killed. */ -const heavy = System.create({ name: "portsTest/heavy", kernel: "cpu", components: { write: [value] } }, row => { +const heavy = System.create({ kernel: "cpu", components: { write: [value] } }, row => { let total = row[value]; for (let k = 0; k < 2000; k++) total = total * 0.5 + k; row[value] = total; diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index 610d10e0..5416d19b 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -29,14 +29,14 @@ declare module "@adobe/ecs" { export const rows = 100_000; export const weightAt = (i: number): number => Math.fround(Math.sin(i * 0.37) * 1000); -const spawn = System.create({ name: "reductionsBrowserTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { if (i % 4 === 0) commands.spawn([weight, sized])({ [weight]: weightAt(i), [sized]: true }); else commands.spawn([weight])({ [weight]: weightAt(i) }); } }); -const reduce = System.create({ name: "reductionsBrowserTest/reduce", kernel: "cpu", components: { read: [weight] }, reduce: { [count]: "+", [sum]: "+", [largest]: "max" } }, (row, { reduce }) => { +const reduce = System.create({ kernel: "cpu", components: { read: [weight] }, resources: { reduce: [count, sum, largest] } }, (row, { reduce }) => { reduce[count](1); reduce[sum](row[weight]); reduce[largest](row[weight]); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index 56430e8a..ef0bd6d0 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -48,14 +48,14 @@ const positionAt = (i: number): Vec3 => [Math.sin(i) * 100, (i % 97) - 50, i / 7 const massAt = (i: number): number => (i * 2654435761) >>> 20; /** Rows in two archetypes, so a run folds more than one call's partials. */ -const spawn = System.create({ name: "reductionsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { if (i % 3 === 0) commands.spawn([position, mass, heavy])({ [position]: positionAt(i), [mass]: massAt(i), [heavy]: true }); else commands.spawn([position, mass])({ [position]: positionAt(i), [mass]: massAt(i) }); } }); -const kernel = System.create({ name: "reductionsTest/kernel", kernel: "cpu", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { +const kernel = System.create({ kernel: "cpu", components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -65,7 +65,7 @@ const kernel = System.create({ name: "reductionsTest/kernel", kernel: "cpu", com }); /** The same reductions, run as written on the main thread. */ -const body = System.create({ name: "reductionsTest/body", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { +const body = System.create({ components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -74,29 +74,29 @@ const body = System.create({ name: "reductionsTest/body", components: { read: [p reduce[least](row[mass]); }); -const filtered = System.create({ name: "reductionsTest/filtered", kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { changed: [level] }, reduce: { [total]: "+", [least]: "min" } }, (row, { reduce }) => { +const filtered = System.create({ kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { changed: [level], reduce: [total, least] } }, (row, { reduce }) => { reduce[total](row[mass]); reduce[least](row[mass]); }); -const flags = System.create({ name: "reductionsTest/flags", kernel: "cpu", components: { read: [mass] }, reduce: { [any]: "|" } }, (row, { reduce }) => { +const flags = System.create({ kernel: "cpu", components: { read: [mass] }, resources: { reduce: [any] } }, (row, { reduce }) => { reduce[any](row[mass] > 4000); }); /** Two contributors to `total`, the heavy rows and the light ones, and a reader of the finished fold. */ -const heavyMass = System.create({ name: "reductionsTest/heavy", kernel: "cpu", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const heavyMass = System.create({ kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const lightMass = System.create({ name: "reductionsTest/light", components: { read: [mass], not: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const lightMass = System.create({ components: { read: [mass], not: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const reader = System.create({ name: "reductionsTest/reader", resources: { read: [total], write: [seen] } }, ({ resources }) => { +const reader = System.create({ resources: { read: [total], write: [seen] } }, ({ resources }) => { resources[seen] = resources[total]; }); -const frame = System.create({ name: "reductionsTest/frame", systems: [reader, heavyMass, lightMass] }); +const frame = System.create({ systems: [reader, heavyMass, lightMass] }); -const weigh = System.create({ name: "reductionsTest/weigh", components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); -const kernelChanged = System.create({ name: "reductionsTest/changed", kernel: "cpu", components: { read: [mass], changed: [mass] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const weigh = System.create({ components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); +const kernelChanged = System.create({ kernel: "cpu", components: { read: [mass], changed: [mass] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); }); @@ -157,7 +157,7 @@ describe("reductions", () => { it("keep the previous value when a contributor fails, or when every contributor skips the run", async () => { let fail = false; - const fails = System.create({ name: "reductionsTest/fails", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + const fails = System.create({ components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); if (fail) throw new Error("contributor failed"); }); @@ -200,7 +200,7 @@ describe("reductions", () => { it("stamp their resources changed only when the fold differs", async () => { let ran = 0; - const watcher = System.create({ name: "reductionsTest/reader/2", components: { has: [mass] }, resources: { changed: [total] } }, () => { ran++; }); + const watcher = System.create({ components: { has: [mass] }, resources: { changed: [total] } }, () => { ran++; }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(kernel); @@ -214,7 +214,7 @@ describe("reductions", () => { }); it("store an infinite sum as any write of it, and only an empty min or max as the integer type's extreme", async () => { - const infinite = System.create({ name: "reductionsTest/infinite", components: { has: [mass] }, reduce: { [wrapped]: "+" } }, (_, { reduce }) => { + const infinite = System.create({ components: { has: [mass] }, resources: { reduce: [wrapped] } }, (_, { reduce }) => { reduce[wrapped](Infinity); }); const world = createWorld({ [workers]: 0 }); @@ -235,16 +235,14 @@ describe("reductions", () => { it("reject what can't be reduced", async () => { // Booleans reduce bitwise. expect(() => declareResource$(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); - // @ts-expect-error a contributor repeats its resource's operator - expect(() => System.create({ name: "reductionsTest/other", components: { has: [mass] }, reduce: { [total]: "max" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total" with "max", but it's declared reduce: "+"`); // @ts-expect-error only a resource with a reduce trait is reduced - expect(() => System.create({ name: "reductionsTest/other/2", components: { has: [mass] }, reduce: { [level]: "+" } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); + expect(() => System.create({ components: { has: [mass] }, resources: { reduce: [level] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written - expect(() => System.create({ name: "reductionsTest/other/3", resources: { write: [total] } }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); + expect(() => System.create({ resources: { write: [total] } }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); // @ts-expect-error a contributor doesn't read what it reduces into - expect(() => System.create({ name: "reductionsTest/other/4", components: { has: [mass] }, resources: { read: [total] }, reduce: { [total]: "+" } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + expect(() => System.create({ components: { has: [mass] }, resources: { read: [total], reduce: [total] } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); // @ts-expect-error nor filters on it - expect(() => System.create({ name: "reductionsTest/other/5", components: { has: [mass] }, resources: { changed: [total] }, reduce: { [total]: "+" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + expect(() => System.create({ components: { has: [mass] }, resources: { changed: [total], reduce: [total] } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); }); it("leave nothing subscribed to sync points from a registration that failed", () => { @@ -252,7 +250,7 @@ describe("reductions", () => { const registrar = createRegistrar(state); // A declaration the build doesn't see is a kernel it didn't compile, which only a world's registration finds. const uncompiled = { name: "reductionsTest/other/6", kernel: "cpu", components: { read: [mass] } } as const; - const both = System.create({ name: "reductionsTest/both", systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); + const both = System.create({ systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); const definition = (system: System) => { const found = definitionOf(system); if (found === undefined) throw new Error("not a system"); diff --git a/packages/ecs/src/implementation/world/reductions.ts b/packages/ecs/src/implementation/world/reductions.ts index 5c29a1c4..12b2f80d 100644 --- a/packages/ecs/src/implementation/world/reductions.ts +++ b/packages/ecs/src/implementation/world/reductions.ts @@ -7,7 +7,7 @@ import { stampChanged } from './archetype.js'; import { readCell, writeCell } from './cells.js'; import { resourceCell } from './resources.js'; import { idOf, type Key, type State } from './state.js'; -import { labelOf } from '../../interface/registry/index.js'; +import { declarationOf, labelOf } from '../../interface/registry/index.js'; /** A resource a system reduces into: its operator, and the scalar leaves of its value. */ export type ReductionTarget = { @@ -20,18 +20,19 @@ const bitwise: ReadonlySet = new Set(["&", "|", "^"]); /** - * The resources a declaration reduces into, with their layouts' leaves. Creating the system checked each against its - * `reduce` trait, which registering the resource checked against its schema, so a throw here is an invariant broken. + * The resources a system reduces into, with their operators, their `reduce` traits, and their layouts' leaves. + * Creating the system checked each has a trait, which registering the resource checked against its schema, so a throw + * here is an invariant broken. */ -export const reductionTargets = (state: State, declaration: Pick): readonly ReductionTarget[] => - reductionTargetsOf(key => state.layouts[idOf(state, key)], declaration); +export const reductionTargets = (state: State, name: string, declaration: Declaration): readonly ReductionTarget[] => + reductionTargetsOf(key => state.layouts[idOf(state, key)], name, declaration); -/** The resources a declaration reduces into, by `layoutFor`, each resource's layout. */ -export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, { name, reduce }: Pick): readonly ReductionTarget[] => { +/** The resources a system `name` reduces into, by `layoutFor`, each resource's layout. */ +export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, name: string, declaration: Declaration): readonly ReductionTarget[] => { + const reduce = declaration.resources?.reduce; if (reduce === undefined) return []; - return Object.getOwnPropertySymbols(reduce).flatMap((key): ReductionTarget[] => { - const operator: unknown = Reflect.get(reduce, key); - if (operator === undefined) return []; + return reduce.map((key): ReductionTarget => { + const operator: unknown = declarationOf(key)?.reduce; if (!isReduction(operator)) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", which isn't an operator.`); const layout = layoutFor(key); if (layout?.kind !== "linear") throw new Error(`System "${name}" reduces "${labelOf(key)}", which isn't stored in linear memory.`); @@ -39,7 +40,7 @@ export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, if (leaves.some(leaf => leaf.boolean) && !bitwise.has(operator)) { throw new Error(`System "${name}" reduces "${labelOf(key)}", which has boolean values, with "${operator}"; booleans reduce with "&", "|" or "^".`); } - return [{ key, operator, leaves }]; + return { key, operator, leaves }; }); }; diff --git a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts index dab5712f..42308ba6 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts @@ -11,6 +11,6 @@ declare module "@adobe/ecs" { } -export const bump = System.create({ name: "reevaluation/bump", components: { write: [score] } }, row => { +export const bump = System.create({ components: { write: [score] } }, row => { row[score] += 1; }); diff --git a/packages/ecs/src/implementation/world/reevaluation.test.ts b/packages/ecs/src/implementation/world/reevaluation.test.ts index a7acfcdd..d7fe6e73 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test.ts @@ -6,17 +6,17 @@ import { bump, score } from "./reevaluation.test-helper.js"; import { createWorld } from "../index.js"; import { runReported } from "./reports.test-helper.js"; -const spawn = System.create({ name: "reevaluation/spawn" }, ({ commands }) => { commands.spawn([score])({ [score]: 0 }); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([score])({ [score]: 0 }); }); -// A name is a label, so other code may use it too: this isn't 20-score.ts's `bump`, and reports label the two apart. -const broken = System.create({ name: "reevaluation/bump" }, () => { +// A label isn't an identity: this `bump` isn't the helper's, and reports tell the two apart by their ids. +const bump2 = System.create({}, () => { throw new Error("offline"); }); -it("labels systems that share a name by their ids", async () => { +it("gives each system's id beside its label", async () => { await using world = createWorld(); - const { failures } = await runReported(world, broken); - expect(failures.map(({ name }) => name)).toEqual([expect.stringMatching(/^reevaluation\/bump \(@adobe\/ecs@[^/]+\/src\/implementation\/world\/reevaluation\.test\.ts#broken\)$/)]); + const { failures } = await runReported(world, bump2); + expect(failures.map(({ name, id }) => [name, id])).toEqual([["bump2", expect.stringMatching(/^@adobe\/ecs@[^/]+\/src\/implementation\/world\/reevaluation\.test\.ts#bump2$/)]]); }); it("matches the keys and systems of a module evaluated again by id", async () => { @@ -27,7 +27,7 @@ it("matches the keys and systems of a module evaluated again by id", async () => const path = "./reevaluation.test-helper.js?again"; const again: typeof import("./reevaluation.test-helper.js") = await import(/* @vite-ignore */ path); // Its key is the same component, and its system the same system, so a composite holds it once. - await world.run(System.create({ name: "reevaluation/both", systems: [bump, again.bump] })); + await world.run(System.create({ systems: [bump, again.bump] })); const [entity] = await Observe.toPromise(Observe.select(world, { required: [again.score] })); expect((await Observe.toPromise(Observe.entity(world, entity, { required: [again.score] })))?.[again.score]).toBe(2); }); diff --git a/packages/ecs/src/implementation/world/register.ts b/packages/ecs/src/implementation/world/register.ts index fa1c3eff..bccb76aa 100644 --- a/packages/ecs/src/implementation/world/register.ts +++ b/packages/ecs/src/implementation/world/register.ts @@ -6,7 +6,7 @@ import { createJoinedObserverRunner, type ObserverTarget } from './joined-observ import { createRunner, releaseHoldings, removedRunner, stopHoldings, type Holdings, type Runner } from './runner.js'; import { isLeaf, reducedBy, scheduleOf, type Schedule } from '../../interface/registry/schedule.js'; import { idOf, observe, unobserve, type Key, type State } from './state.js'; -import { labelOfItem, orderKeysOf } from '../../interface/registry/index.js'; +import { orderKeysOf } from '../../interface/registry/index.js'; import { entityWatchesOf, indexWatchesOf, lookupComponentsOf, orderComponentsOf } from './lookup-keys.js'; /** What a registration still has to do asynchronously: add resources, then load kernels; `rollback` undoes it if that fails. */ @@ -378,7 +378,7 @@ export const createRegistrar = (state: State): Registrar => { if (problem !== null) return; const thread = threadOf(leaf.declaration, "System", leaf.name); if (thread === null || !threads.includes(thread)) return; - const label = labelOfItem(leaf); + const label = leaf.name; const id = idOfItem(leaf); if (id === undefined) problem = `System "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level systems one.`; else if (!imported.has(id)) problem = `System "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index dd050ba8..cbec95a8 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -15,7 +15,7 @@ export const remoteFinalizersCases = (load: () => Promise) => de const fixture = await load(); expect(await fixture.diesHolding()).toEqual({ held: 3, whileHeld: 0, - failures: [["thread", "remoteFinalizersTest/release", 'Thread "remoteFinalizersTest" is gone, so 3 stops of finalizer "remoteFinalizersTest/release" didn\'t run there.']], + failures: [["thread", "release", 'Thread "remoteFinalizersTest" is gone, so 3 stops of finalizer "release" didn\'t run there.']], after: 3, }); }, 30000); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index d411fe31..6c10a974 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -77,7 +77,7 @@ export const finalized: Entity[] = []; export const throwing = new Set(); /** On the finalizers' thread: what each stop held there, its structs before the sync point applied. */ -export const release = Finalizer.create({ name: "remoteFinalizersTest/release", components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { +export const release = Finalizer.create({ components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { finalized.push(row.entity); released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone].text }); if (throwing.has(row[serial])) throw new Error(`remoteFinalizersTest: ${row[serial]} throws`); @@ -87,7 +87,7 @@ export const release = Finalizer.create({ name: "remoteFinalizersTest/release", export const entitled: { readonly serial: number; readonly title: string }[] = []; /** On the finalizers' thread: what each titled stop's title was before its sync point applied. */ -export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", components: { read: [serial, title, widget] } }, row => { +export const entitle = Finalizer.create({ components: { read: [serial, title, widget] } }, row => { entitled.push({ serial: row[serial], title: row[title] }); }); @@ -95,7 +95,7 @@ export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", export const headlined: { readonly serial: number; readonly heading: string }[] = []; /** On the finalizers' thread: the heading as it was before each headed stop's sync point applied. */ -export const headline = Finalizer.create({ name: "remoteFinalizersTest/headline", components: { read: [serial, widget], has: [headed] }, resources: { read: [heading] } }, (row, { resources }) => { +export const headline = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [heading] } }, (row, { resources }) => { headlined.push({ serial: row[serial], heading: resources[heading] }); }); @@ -103,7 +103,7 @@ export const headline = Finalizer.create({ name: "remoteFinalizersTest/headline" export const headlinedBoth: { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; /** On the finalizers' thread: two string resources as they were before each headed stop's sync point applied. */ -export const headlines = Finalizer.create({ name: "remoteFinalizersTest/headlines", components: { read: [serial, widget], has: [headed] }, resources: { read: [subheading, heading] } }, (row, { resources }) => { +export const headlines = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [subheading, heading] } }, (row, { resources }) => { headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); }); @@ -111,7 +111,7 @@ export const headlines = Finalizer.create({ name: "remoteFinalizersTest/headline export const gauged: { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; /** On the finalizers' thread: a number and a struct resource as they were before each headed stop's sync point applied. */ -export const gauge = Finalizer.create({ name: "remoteFinalizersTest/gauge", components: { read: [serial, widget], has: [headed] }, resources: { read: [origin, level] } }, (row, { resources }) => { +export const gauge = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [origin, level] } }, (row, { resources }) => { gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); }); @@ -120,7 +120,7 @@ export type Surveyed = { readonly serial: number; readonly caption: string; read export const surveyed: Surveyed[] = []; /** On the finalizers' thread: resources of mixed widths as they were before each headed stop's sync point applied. */ -export const survey = Finalizer.create({ name: "remoteFinalizersTest/survey", components: { read: [serial, widget], has: [headed] }, resources: { read: [material, caption, sample, lit, tint] } }, (row, { resources }) => { +export const survey = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [material, caption, sample, lit, tint] } }, (row, { resources }) => { const { alpha, color, glossy } = resources[material]; surveyed.push({ serial: row[serial], caption: resources[caption], lit: resources[lit], tint: [...resources[tint]], @@ -136,7 +136,7 @@ export const surveyOf = (at: number): Omit => ({ }); /** From the world's thread: the level, the origin at it, and the resources of mixed widths at it. */ -export const regauge = System.create({ name: "remoteFinalizersTest/regauge", resources: { write: [level, origin, caption, lit, tint, sample, material] } }, ({ resources }, input: { readonly level: number }) => { +export const regauge = System.create({ resources: { write: [level, origin, caption, lit, tint, sample, material] } }, ({ resources }, input: { readonly level: number }) => { resources[level] = input.level; resources[origin] = [input.level, input.level + 10]; const values = surveyOf(input.level); @@ -148,59 +148,59 @@ export const regauge = System.create({ name: "remoteFinalizersTest/regauge", res }); /** Spawns headed rows from the world's thread, with the thread's widget at null. */ -export const spawnHeaded = System.create({ name: "remoteFinalizersTest/spawnHeaded" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { +export const spawnHeaded = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); }); /** From the world's thread: the heading and subheading. */ -export const rehead = System.create({ name: "remoteFinalizersTest/rehead", resources: { write: [heading, subheading] } }, ({ resources }, input: { readonly heading: string }) => { +export const rehead = System.create({ resources: { write: [heading, subheading] } }, ({ resources }, input: { readonly heading: string }) => { resources[heading] = input.heading; resources[subheading] = `s${input.heading}`; }); /** Spawns titled rows from the world's thread, with the thread's widget at null. */ -export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { +export const spawnTitled = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); }); /** From the world's thread: every title as another of the same size, so the records of titles let go are reused. */ -export const retitle = System.create({ name: "remoteFinalizersTest/retitle", components: { write: [title] } }, (row, _context, input: { readonly round: number }) => { +export const retitle = System.create({ components: { write: [title] } }, (row, _context, input: { readonly round: number }) => { row[title] = `r${input.round}`; }); /** Spawns from the world's thread: structs, the thread's objects at null and their start. */ -export const spawn = System.create({ name: "remoteFinalizersTest/spawn" }, ({ commands }, input: { readonly from: number; readonly count: number }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, place, marked, widget, label])({ [serial]: k, [place]: [k, -k], [widget]: null }); }); /** On the thread: widgets written in place. */ -export const attach = System.create({ name: "remoteFinalizersTest/attach", components: { read: [serial], write: [widget] } }, row => { +export const attach = System.create({ components: { read: [serial], write: [widget] } }, row => { const tag = `w${row[serial]}`; row[widget] = { tag, draw: () => tag }; }); /** On the thread: labels given by command, staged there, for odd serials. */ -export const relabel = System.create({ name: "remoteFinalizersTest/relabel", components: { read: [serial, label] } }, (row, { commands }) => { +export const relabel = System.create({ components: { read: [serial, label] } }, (row, { commands }) => { const relabelled = { text: `l${row[serial]}${row[label]?.text === "none" ? "" : "+"}` }; if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); }); /** On the thread: its object resource. */ -export const retone = System.create({ name: "remoteFinalizersTest/retone", resources: { write: [tone] } }, ({ resources }, input: { readonly tone: string }) => { +export const retone = System.create({ resources: { write: [tone] } }, ({ resources }, input: { readonly tone: string }) => { resources[tone] = { text: input.tone }; }); /** From the world's thread: places move, so a stop's values are those before the sync point applies. */ -export const shift = System.create({ name: "remoteFinalizersTest/shift", components: { write: [place] } }, row => { +export const shift = System.create({ components: { write: [place] } }, row => { row[place] = [row[place][0] + 100, row[place][1]]; }); /** From the world's thread: despawns these serials. */ -export const despawn = System.create({ name: "remoteFinalizersTest/despawn", components: { read: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { +export const despawn = System.create({ components: { read: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.despawn(row.entity); }); /** From the world's thread: removes widgets of these serials, a stop that despawns nothing. */ -export const strip = System.create({ name: "remoteFinalizersTest/strip", components: { read: [serial], has: [widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { +export const strip = System.create({ components: { read: [serial], has: [widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.remove(row.entity, widget); }); /** From the world's thread: every row moves archetype, flagged on one run and not on the next. */ -export const flag = System.create({ name: "remoteFinalizersTest/flag", components: { has: [serial], not: [flagged] } }, (row, { commands }) => { +export const flag = System.create({ components: { has: [serial], not: [flagged] } }, (row, { commands }) => { commands.add(row.entity, flagged); }); -export const unflag = System.create({ name: "remoteFinalizersTest/unflag", components: { has: [serial, flagged] } }, (row, { commands }) => { +export const unflag = System.create({ components: { has: [serial, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index 35cc5fcf..2a7ee921 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -23,13 +23,13 @@ declare module "@adobe/ecs" { [third]: { type: number; default: 0 }; } } -const bump = System.create({ name: "remoteFinalizersTest/bump", resources: { write: [later] } }, ({ resources }) => { +const bump = System.create({ resources: { write: [later] } }, ({ resources }) => { resources[later] += 1; }); -const bumpSecond = System.create({ name: "remoteFinalizersTest/bumpSecond", resources: { write: [second] } }, ({ resources }) => { +const bumpSecond = System.create({ resources: { write: [second] } }, ({ resources }) => { resources[second] += 1; }); -const bumpThird = System.create({ name: "remoteFinalizersTest/bumpThird", resources: { write: [third] } }, ({ resources }) => { +const bumpThird = System.create({ resources: { write: [third] } }, ({ resources }) => { resources[third] += 1; }); @@ -71,7 +71,7 @@ describe("finalizers on a joined thread", () => { reset(); const seen: (readonly symbol[] | null)[][] = []; let watching = false; - Finalizer.changes({ name: "remoteFinalizersTest/everything" }, changes => { + Finalizer.changes({}, changes => { if (watching) for (const group of changes.groups) seen.push([group.to, group.added]); }); const { world, remote } = await joined(); @@ -110,7 +110,7 @@ describe("finalizers on a joined thread", () => { let reusedEarly = 0; let reused = 0; // Spawns check each entity they reserve: an index whose last entity stopped is reused only once it was finalized. - const check = System.create(identify({ name: "remoteFinalizersTest/check" }, "remoteFinalizersTest#check"), ({ commands }, input: { readonly serial: number }) => { + const check = System.create(identify({}, "remoteFinalizersTest#check"), ({ commands }, input: { readonly serial: number }) => { const entity = commands.spawn([serial, place, marked, widget])({ [serial]: input.serial, [place]: [0, 0], [widget]: null }); const before = stopped.get(indexOf(entity)); if (before === undefined) return; @@ -145,7 +145,7 @@ describe("finalizers on a joined thread", () => { await new Promise(resolve => setTimeout(resolve, 1)); report = await runReported(world, despawn, { serials: [] }); } - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/release", "remoteFinalizersTest: 1 throws"]]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "release", "remoteFinalizersTest: 1 throws"]]); expect((await runReported(world, despawn, { serials: [] })).failures).toEqual([]); await remote.dispose(); await world.dispose(); @@ -229,11 +229,11 @@ describe("finalizers on a joined thread", () => { const { world, remote } = await joined(); await world.run(spawn, { from: 0, count: 2 }); const late: string[] = []; - Finalizer.create(identify({ name: "remoteFinalizersTest/late", components: { read: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); + Finalizer.create(identify({ components: { read: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); // Declared in a function, so the build gives it no id. - Finalizer.create({ name: "remoteFinalizersTest/unnamed", components: { read: [widget] } }, () => {}); + Finalizer.create({ components: { read: [widget] } }, () => {}); const first = await runReported(world, despawn, { serials: [0] }); - expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/unnamed", 'Finalizer "remoteFinalizersTest/unnamed" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can\'t name it; the build gives top-level finalizers one.']]); + expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", expect.stringMatching(/^finalizer #\d+$/), expect.stringMatching(/^Finalizer "finalizer #\d+" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level finalizers one\.$/)]]); expect(await eventually(() => late.length === 1)).toBe(true); expect((await runReported(world, despawn, { serials: [1] })).failures).toEqual([]); expect(await eventually(() => late.length === 2)).toBe(true); @@ -241,8 +241,8 @@ describe("finalizers on a joined thread", () => { // Placed there by the thread's object resource alone: stops of rows holding none of its objects, which its log // has no entries for, run there too. const heard: string[] = []; - Finalizer.create(identify({ name: "remoteFinalizersTest/heard", components: { read: [serial], not: [widget] }, resources: { read: [tone] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); - const plain = System.create({ name: "remoteFinalizersTest/plain" }, ({ commands }) => { + Finalizer.create(identify({ components: { read: [serial], not: [widget] }, resources: { read: [tone] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); + const plain = System.create({}, ({ commands }) => { for (let k = 0; k < 3; k++) commands.spawn([serial, place])({ [serial]: 50 + k, [place]: [0, 0] }); }); await world.run(plain); diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index 8227a92d..4fa2b17d 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -29,7 +29,7 @@ const none = { text: "none" }; const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; /** The watched entity, serial 0. */ -export const seed = System.create({ name: "remoteObjectsTest/seed" }, ({ commands }) => { +export const seed = System.create({}, ({ commands }) => { commands.spawn([serial, phase, caption])({ [serial]: 0, [phase]: 0 }); }); @@ -38,7 +38,7 @@ export const seed = System.create({ name: "remoteObjectsTest/seed" }, ({ command * flagged when it's odd, so it moves archetype every tick; the other entities despawn, and four more spawn into its * archetypes, so rows swap into the holes they leave. */ -export const churn = System.create({ name: "remoteObjectsTest/churn", components: { read: [serial], write: [phase] } }, (row, { commands }) => { +export const churn = System.create({ components: { read: [serial], write: [phase] } }, (row, { commands }) => { if (row[serial] !== 0) { commands.despawn(row.entity); return; @@ -69,7 +69,7 @@ export type Observers = "factory" | "observer"; const observer: { entity: Entity | null; seen: ((odd: boolean, held: boolean, text: string | null | undefined) => void) | null } = { entity: null, seen: null }; /** After each sync point, on the joined thread: the watched entity's phase and caption, and whether it's flagged. */ -export const observerWatch = System.create({ name: "remoteObjectsTest/observerWatch", observer: true, lookup: { values: { read: [phase, caption] }, flagged: { has: [flag] } } }, ({ lookup }) => { +export const observerWatch = System.create({ observer: true, lookup: { values: { read: [phase, caption] }, flagged: { has: [flag] } } }, ({ lookup }) => { if (observer.entity === null) return; const row = lookup.values(observer.entity); if (row === null) return; diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts index 98a51a3f..a4bc45ad 100644 --- a/packages/ecs/src/implementation/world/remote-systems.ts +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -3,7 +3,7 @@ import type { Leaf } from '../../interface/system/leaf.js'; import { entityWatchesOf, lookupComponentsOf } from './lookup-keys.js'; import type { Leaf as FinalizerLeaf } from '../../interface/finalizer/created.js'; import type { Warning } from '../../interface/report/failure.js'; -import { declarationOf, idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { declarationOf, idOfItem, idOfKey, idOrNull, indexOfLookup, labelOf, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import { unsharedKeyOf } from './index-keys.js'; import { layoutOf } from '../../interface/registry/layout.js'; import { raiseRange, type Archetype } from './archetype.js'; @@ -43,6 +43,8 @@ export type RemoteSystem = { readonly channel: TaskChannel; readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; /** Its warning, listed in each run's report its tier blocks. */ readonly warning: Warning; readonly targets: readonly ReductionTarget[]; @@ -346,11 +348,11 @@ export const createTaskChannel = (state: State, log: MoveLog, post: (message: To /** An item's setup there: its id, and the world's numbers for the keys it names, by their ids. Throws without them. */ const setupOf = (state: State, item: Leaf | FinalizerLeaf, what: string, named: readonly symbol[]): { readonly id: string; readonly keys: readonly (readonly [number, string])[] } => { const id = idOfItem(item); - if (id === undefined) throw new Error(`${what} "${labelOfItem(item)}" runs on thread "${label}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level ${what === "System" ? "systems" : "finalizers"} one.`); + if (id === undefined) throw new Error(`${what} "${item.name}" runs on thread "${label}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level ${what === "System" ? "systems" : "finalizers"} one.`); const keys: (readonly [number, string])[] = []; for (const key of named) { const keyId = idOfKey(key); - if (keyId === undefined) throw new Error(`${what} "${labelOfItem(item)}" names "${labelOf(key)}", which has no id, so thread "${label}" can't name it; the build gives top-level keys one.`); + if (keyId === undefined) throw new Error(`${what} "${item.name}" names "${labelOf(key)}", which has no id, so thread "${label}" can't name it; the build gives top-level keys one.`); keys.push([idOf(state, key), keyId]); } return { id, keys }; @@ -471,13 +473,12 @@ export const createTaskChannel = (state: State, log: MoveLog, post: (message: To /** Every key a leaf names in its declaration, so the thread finds the world's numbers for them. */ export const keysOf = (leaf: Leaf): readonly symbol[] => { - const { components, lookup, resources, reduce } = leaf.declaration; + const { components, lookup, resources } = leaf.declaration; const keys = new Set([ ...components?.read ?? [], ...components?.write ?? [], ...components?.has ?? [], ...components?.not ?? [], ...components?.added ?? [], ...components?.changed ?? [], - ...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], + ...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], ...resources?.reduce ?? [], ]); for (const name in lookup) for (const key of lookupComponentsOf(lookup[name])) keys.add(key); - if (reduce !== undefined) for (const key of Object.getOwnPropertySymbols(reduce)) keys.add(key); return [...keys].filter((key): key is symbol => typeof key === "symbol"); }; @@ -494,22 +495,22 @@ export const finalizerKeysOf = (finalizer: FinalizerLeaf): readonly symbol[] => */ /** Why a leaf with an index as a change filter can't run on a joined thread. */ export const entriesOnJoined = (leaf: Leaf, thread: string, index: string): string => - `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and when index "${index}" changed, a lookup's changed being true, which runs only on the world's thread.`; + `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and when index "${index}" changed, a lookup's changed being true, which runs only on the world's thread.`; export const unplaceable = (leaf: Leaf, thread: string): string | null => { - if (leaf.declaration.up !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and declares up, which runs only on the world's thread.`; - if (leaf.declaration.order !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and orders its rows by an index, which runs only on the world's thread.`; - if (leaf.declaration.target !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and on one entity, which runs only on the world's thread.`; + if (leaf.declaration.up !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and declares up, which runs only on the world's thread.`; + if (leaf.declaration.order !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and orders its rows by an index, which runs only on the world's thread.`; + if (leaf.declaration.target !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and on one entity, which runs only on the world's thread.`; const { components, resources } = leaf.declaration; const string = [...components?.write ?? [], ...resources?.write ?? []].find(key => layoutOf(declarationOf(key)?.type).kind === "string"); - if (string !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and writes "${labelOf(string)}", a string, which only the world's thread writes.`; + if (string !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and writes "${labelOf(string)}", a string, which only the world's thread writes.`; for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { const index = indexOfLookup(lookup); const object = index === undefined ? undefined : unsharedKeyOf(index.declaration.key); if (index !== undefined && object !== undefined) { - return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`; + return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and looks up through index "${index.name}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`; } - if (index !== undefined && watchesEntries(lookup)) return entriesOnJoined(leaf, thread, labelOfItem(index)); + if (index !== undefined && watchesEntries(lookup)) return entriesOnJoined(leaf, thread, index.name); } return null; }; @@ -545,7 +546,7 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne const { declaration } = leaf; const { components: query } = declaration; const thread = threadOf(declaration, "System", leaf.name) ?? "world"; - const label = labelOfItem(leaf); + const label = leaf.name; const problem = unplaceable(leaf, thread); if (problem !== null) throw new Error(problem); const lookups = remoteIndexesOf(state, declaration.lookup, acquireLookups(state, declaration.lookup)); @@ -555,7 +556,7 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); const looks = [...new Set(Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []))].map(key => idOf(state, key)); - const targets = reductionTargets(state, declaration); + const targets = reductionTargets(state, label, declaration); const folds = targets.map(target => new Float64Array(target.leaves.length)); const matches: Archetype[] = []; let checked = 0; @@ -567,8 +568,8 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne return matches; }; const remote: RemoteSystem = { - channel, index, label, - warning: { kind: "blocking", name: label, message: `System "${label}" runs on thread "${thread}", which a joined thread fills, so its tier waits for that thread.` }, + channel, index, label, id: idOrNull(leaf), + warning: { kind: "blocking", name: label, id: idOrNull(leaf), message: `System "${label}" runs on thread "${thread}", which a joined thread fills, so its tier waits for that thread.` }, targets, folds, foldCount: folds.reduce((sum, leaves) => sum + leaves.length, 0), changed: (query?.changed ?? []).map(key => idOf(state, key)), watched: entityWatchesOf(declaration.lookup).map(key => idOf(state, key)), diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index f1722008..4639d0ee 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -37,38 +37,38 @@ type Widget = { readonly draw: () => string }; const none = { text: "none" }; /** A widget, or the number a kernel gives one, which only the world's thread could hold. */ -const spawn = System.create({ name: "remoteTest/spawn" }, ({ commands }, input: { readonly x: number }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); }); -const nullCaptions = System.create({ name: "remoteTest/nullCaptions", components: { has: [caption, point] } }, (row, { commands }) => { +const nullCaptions = System.create({ components: { has: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ name: "remoteTest/restartCaptions", components: { has: [caption, point] } }, (row, { commands }) => { +const restartCaptions = System.create({ components: { has: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ name: "remoteTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { +const dropCaptions = System.create({ components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); -const giveCaption = System.create({ name: "remoteTest/giveCaption", components: { has: [point] } }, (row, { commands }) => { +const giveCaption = System.create({ components: { has: [point] } }, (row, { commands }) => { commands.add(row.entity, caption, { text: "a caption only the thread gives" }); }); -const spawnCaptioned = System.create({ name: "remoteTest/spawnCaptioned" }, ({ commands }) => { +const spawnCaptioned = System.create({}, ({ commands }) => { commands.spawn([point, caption])({ [point]: 1, [caption]: { text: "a caption only the thread gives" } }); }); /** A kernel giving the thread's objects null, which they take, and a number, which they don't. */ -const nullCaptionsKernel = System.create({ name: "remoteTest/nullCaptionsKernel", kernel: "cpu", components: { has: [caption], read: [point] } }, (row, { commands }) => { +const nullCaptionsKernel = System.create({ kernel: "cpu", components: { has: [caption], read: [point] } }, (row, { commands }) => { if (row[point] > 0) commands.add(row.entity, caption, null); }); -const numberHandlesKernel = System.create({ name: "remoteTest/numberHandlesKernel", kernel: "cpu", components: { read: [point] } }, (row, { commands }) => { +const numberHandlesKernel = System.create({ kernel: "cpu", components: { read: [point] } }, (row, { commands }) => { commands.add(row.entity, handle, row[point]); }); /** Reaches its commands, so it isn't terminal: its tier blocks rather than running as a tail. */ -const readCaptions = System.create({ name: "remoteTest/readCaptions", components: { read: [caption] } }, (row, { commands }) => { +const readCaptions = System.create({ components: { read: [caption] } }, (row, { commands }) => { if (row[caption]?.text === "never") commands.despawn(row.entity); }); -const idle = System.create({ name: "remoteTest/idle" }, () => {}); -const after = System.create({ name: "remoteTest/after" }, () => {}); -Finalizer.create({ name: "remoteTest/releaseGuarded", components: { read: [guarded] } }, () => {}); +const idle = System.create({}, () => {}); +const after = System.create({}, () => {}); +Finalizer.create({ components: { read: [guarded] } }, () => {}); /** A world shared with a thread on this one, which claims `threads`. */ const joined = (threads: readonly ("remoteTest" | "remoteTestFinalized")[] = ["remoteTest"]): { readonly world: CreatedWorld; readonly remote: World } => { @@ -123,7 +123,7 @@ describe("objects a joined thread fills", () => { expect((await runReported(world, nullCaptionsKernel)).failures).toEqual([]); expect(await until(listed, value => value?.[caption] === null)).toEqual({ [caption]: null, [widget]: null }); const report = await runReported(world, numberHandlesKernel); - expect(report.failures.map(failure => [failure.name, failure.message])).toEqual([["remoteTest/numberHandlesKernel", '"remoteTest/handle" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.']]); + expect(report.failures.map(failure => [failure.name, failure.message])).toEqual([["numberHandlesKernel", '"remoteTest/handle" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.']]); expect(report.dropped).toBe(1); await remote.dispose(); await world.dispose(); @@ -134,7 +134,7 @@ describe("objects a joined thread fills", () => { await spawnOne(world, 3); expect(await runReported(world, readCaptions)).toEqual({ failures: [], skipped: [], dropped: 0, - warnings: [{ kind: "blocking", name: "remoteTest/readCaptions", message: 'System "remoteTest/readCaptions" runs on thread "remoteTest", which a joined thread fills, so its tier waits for that thread.' }], + warnings: [{ kind: "blocking", name: "readCaptions", id: "@adobe/ecs@0.1.1/src/implementation/world/remote.test.ts#readCaptions", message: 'System "readCaptions" runs on thread "remoteTest", which a joined thread fills, so its tier waits for that thread.' }], }); await remote.dispose(); await world.dispose(); @@ -158,21 +158,21 @@ describe("objects a joined thread fills", () => { await world.run(readCaptions); const taken = joined(); await statusOf(taken.world, "joined"); - expect((await runReported(taken.world, readCaptions)).warnings.map(warning => warning.name)).toEqual(["remoteTest/readCaptions"]); + expect((await runReported(taken.world, readCaptions)).warnings.map(warning => warning.name)).toEqual(["readCaptions"]); await taken.remote.dispose(); await taken.world.dispose(); // Declared in a function, so the build gives it no id, and another thread can't name it. - const unnamed = System.create({ name: "remoteTest/unnamed", components: { read: [caption] } }, row => { void row[caption]; }); + const unnamed = System.create({ components: { read: [caption] } }, row => { void row[caption]; }); await world.run(unnamed); const channel = new MessageChannel(); world.share(channel.port1); const remote = join(channel.port2, { threads: ["remoteTest"] }); - await expect(remote.run(idle)).rejects.toThrow('System "remoteTest/unnamed" runs on thread "remoteTest" and has no id, so a joined thread can\'t run it'); + await expect(remote.run(idle)).rejects.toThrow('System "unnamed" runs on thread "remoteTest" and has no id, so a joined thread can\'t run it'); await statusOf(world, "gone"); await remote.dispose(); const other = joined(["remoteTestFinalized"]); // Declared without a binding, so the build gives it no id. - await expect(other.remote.run(idle)).rejects.toThrow('Finalizer "remoteTest/releaseGuarded" runs on thread "remoteTestFinalized" and has no id, so a joined thread can\'t run it'); + await expect(other.remote.run(idle)).rejects.toThrow(/Finalizer "finalizer #\d+" runs on thread "remoteTestFinalized" and has no id, so a joined thread can't run it/); await other.remote.dispose(); await other.world.dispose(); await world.dispose(); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index 8b55e55c..da1575fc 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -38,35 +38,35 @@ const spin = (milliseconds: number): void => { }; /** Blocking, since its body reaches `commands`: a tier on the thread, and with `second`, one task. */ -export const first = System.create({ name: "removeTest/first", resources: { read: [mark, pause] } }, ({ resources, commands }) => { +export const first = System.create({ resources: { read: [mark, pause] } }, ({ resources, commands }) => { void commands; calls.first += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(second); }); -export const second = System.create({ name: "removeTest/second", resources: { read: [mark], write: [secondRuns] }, after: [first] }, ({ resources, commands }) => { +export const second = System.create({ resources: { read: [mark], write: [secondRuns] }, after: [first] }, ({ resources, commands }) => { void commands; calls.second += resources[mark].text.length; resources[secondRuns] += 1; }); /** Terminal, so a tail, run with `secondTail` as one task after the tick's sync point. */ -export const firstTail = System.create({ name: "removeTest/firstTail", resources: { read: [mark, pause] } }, ({ resources }) => { +export const firstTail = System.create({ resources: { read: [mark, pause] } }, ({ resources }) => { calls.firstTail += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(secondTail); }); /** Terminal, so a tail: removes `removal.target`, once, when it's set. */ -export const removerTail = System.create({ name: "removeTest/removerTail", resources: { read: [mark] } }, ({ resources }) => { +export const removerTail = System.create({ resources: { read: [mark] } }, ({ resources }) => { void resources[mark]; const { target } = removal; removal.target = null; if (target !== null) removal.world?.remove(target); }); -export const secondTail = System.create({ name: "removeTest/secondTail", resources: { read: [mark] }, after: [firstTail] }, ({ resources }) => { +export const secondTail = System.create({ resources: { read: [mark] }, after: [firstTail] }, ({ resources }) => { calls.secondTail += resources[mark].text.length; }); @@ -74,9 +74,9 @@ export const secondTail = System.create({ name: "removeTest/secondTail", resourc * On the world's thread, added while the others come and go, so ticks go on; after `second`, so the tiers of `first` and * `second` are one task there. */ -export const ticking = System.create({ name: "removeTest/ticking", after: [second] }, () => {}); +export const ticking = System.create({ after: [second] }, () => {}); /** Sets how long `first` and `firstTail` spin. */ -export const setPause = System.create({ name: "removeTest/setPause", resources: { write: [pause] } }, ({ resources }, input: { readonly milliseconds: number }) => { +export const setPause = System.create({ resources: { write: [pause] } }, ({ resources }, input: { readonly milliseconds: number }) => { resources[pause] = input.milliseconds; }); diff --git a/packages/ecs/src/implementation/world/remove.test.ts b/packages/ecs/src/implementation/world/remove.test.ts index e8afc749..c356715f 100644 --- a/packages/ecs/src/implementation/world/remove.test.ts +++ b/packages/ecs/src/implementation/world/remove.test.ts @@ -31,41 +31,41 @@ let current: World | null = null; /** What `selfRemoving` removes, once its body has been called this many times. */ let removeAfter = Infinity; -const seed = System.create({ name: "removeTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { const root = commands.spawn([value, shared])({ [value]: 0, [shared]: 0 }); for (let i = 1; i < 10; i++) commands.spawn([value, parent, shared])({ [value]: i, [parent]: root, [shared]: 0 }); }); /** Run before tables are taken, so `value` and `parent` keep ticks then, as they do once any system filters on them. */ -const ticksValue = System.create({ name: "removeTest/ticksValue", components: { added: [value, parent] } }, () => {}); +const ticksValue = System.create({ components: { added: [value, parent] } }, () => {}); /** Added for the whole of each test, so ticks go on. */ -const kept = System.create({ name: "removeTest/kept" }, () => { calls.kept++; }); +const kept = System.create({}, () => { calls.kept++; }); /** Holds what systems can on the world's thread: an `up` order, a change filter's count, command buffers. */ -const held = System.create({ name: "removeTest/held", components: { read: [value], changed: [value] }, up: { through: parent, read: [value] } }, (row, { up }) => { +const held = System.create({ components: { read: [value], changed: [value] }, up: { through: parent, read: [value] } }, (row, { up }) => { calls.held++; void (row[value] + (up?.[value] ?? 0)); }); -const alone = System.create({ name: "removeTest/alone" }, () => { calls.alone++; }); -const other = System.create({ name: "removeTest/other" }, () => { calls.other++; }); -const pair = System.create({ name: "removeTest/pair", systems: [alone, other] }); +const alone = System.create({}, () => { calls.alone++; }); +const other = System.create({}, () => { calls.other++; }); +const pair = System.create({ systems: [alone, other] }); /** In a later tier than `remover`, which removes it during the tick. */ -const late = System.create({ name: "removeTest/late", components: { read: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); -const remover = System.create({ name: "removeTest/remover", before: [late] }, () => { current?.remove(late); }); +const late = System.create({ components: { read: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); +const remover = System.create({ before: [late] }, () => { current?.remove(late); }); /** Removes itself partway through its rows. */ -const selfRemoving = System.create({ name: "removeTest/selfRemoving", components: { read: [value] } }, row => { +const selfRemoving = System.create({ components: { read: [value] } }, row => { calls.self += row[value] >= 0 ? 1 : 0; if (calls.self === removeAfter) current?.remove(selfRemoving); }); /** Ordered only through `phase`: removing `phase` would leave them conflicting with no order between them. */ -const phase = System.create({ name: "removeTest/phase" }, () => {}); -const writer = System.create({ name: "removeTest/writer", components: { write: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); -const reader = System.create({ name: "removeTest/reader", components: { read: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); +const phase = System.create({}, () => {}); +const writer = System.create({ components: { write: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); +const reader = System.create({ components: { read: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); /** A world ticking back to back once something is added, with `kept` added. */ const ticking = async (): Promise<{ readonly world: CreatedWorld; readonly ticks: (count: number) => Promise; readonly close: () => Promise }> => { diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts index 295e7ff5..f8333d0d 100644 --- a/packages/ecs/src/implementation/world/render-order.browser.test.ts +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -30,7 +30,7 @@ declare module "@adobe/ecs" { } -const byDepth = Index.create({ name: "renderOrderTest/byDepth", key: [depth], sorted: true }); +const byDepth = Index.create({ key: [depth], sorted: true }); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); @@ -45,7 +45,7 @@ const spawned: Entity[] = []; * Shapes and images interleaved in depth, spawned in reverse depth order, so neither spawn nor row order is paint * order. Every tenth is hidden or not visible, which the render system filters out, and plain depths match nothing. */ -const spawn = System.create({ name: "renderOrderTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = count - 1; i >= 0; i--) { if (i % 10 === 3) spawned[i] = commands.spawn([depth, code, shape, visible, hidden])({ [depth]: i, [code]: i, [shape]: true, [visible]: true, [hidden]: true }); else if (i % 10 === 7) spawned[i] = commands.spawn([depth, code, image])({ [depth]: i, [code]: i, [image]: true }); @@ -64,7 +64,6 @@ describe("render systems with an order", () => { const output = own.createBuffer({ size: (count + 16) * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); const tableCounts: number[][] = []; const draw = System.render({ - name: "renderOrderTest/draw", components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth, paintCode: code } }, @@ -117,14 +116,14 @@ describe("render systems with an order", () => { // A despawn moves another shape into its row, a spawn adds an image between two others, and a key moves a shape // to the back; a frame after each draws the new order. - const despawn = System.create({ name: "renderOrderTest/despawn" }, ({ commands }) => commands.despawn(spawned[count - 2])); + const despawn = System.create({}, ({ commands }) => commands.despawn(spawned[count - 2])); await world.run(despawn); codes.delete(count - 2); await frame(); expected = drawn(codes); expect(await read()).toEqual(expected.map(i => [i, i])); - const addImage = System.create({ name: "renderOrderTest/addImage" }, ({ commands }) => { + const addImage = System.create({}, ({ commands }) => { commands.spawn([depth, code, image, size, visible])({ [depth]: 500.5, [code]: 5001, [image]: true, [size]: [0, 0, 0], [visible]: true }); }); await world.run(addImage); @@ -132,7 +131,7 @@ describe("render systems with an order", () => { const withImage = expected.flatMap(i => i === 500 ? [[i, i], [500, 5001]] : [[i, i]]); expect(await read()).toEqual(withImage); - const toBack = System.create({ name: "renderOrderTest/toBack", components: { read: [code], write: [depth] } }, row => { + const toBack = System.create({ components: { read: [code], write: [depth] } }, row => { if (row[code] === 10) row[depth] = count + 5; }); await world.run(toBack); @@ -141,7 +140,7 @@ describe("render systems with an order", () => { expect(await read()).toEqual(atBack); // Showing a hidden shape adds it to the one table, in its place. - const show = System.create({ name: "renderOrderTest/show" }, ({ commands }) => commands.remove(spawned[13], hidden)); + const show = System.create({}, ({ commands }) => commands.remove(spawned[13], hidden)); await world.run(show); await frame(); const shown = atBack.flatMap(([d, c]) => d === 12 ? [[d, c], [13, 13]] : [[d, c]]); @@ -152,7 +151,7 @@ describe("render systems with an order", () => { it("write their order only when the index's entries change or move", async () => { const own = await device(); - const draw = System.render({ name: "renderOrderTest/writes", components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); + const draw = System.render({ components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); const world = createWorld({ [gpuDevice]: own }); await world.run(spawn); await world.run(draw); @@ -170,7 +169,7 @@ describe("render systems with an order", () => { await world.run(draw); expect([sizes, records]).toEqual([[], 0]); // A new key writes its chunk of the column and the order, in elements; the record stays. - const toBack = System.create({ name: "renderOrderTest/toBackOnce", components: { read: [code], write: [depth] } }, row => { + const toBack = System.create({ components: { read: [code], write: [depth] } }, row => { if (row[code] === 10) row[depth] = count + 5; }); await world.run(toBack); @@ -186,7 +185,7 @@ describe("render systems with an order", () => { it("give a shader one table when nothing matches", async () => { const own = await device(); let tables: readonly System.RenderTable[] = []; - const draw = System.render({ name: "renderOrderTest/empty", components: { has: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); + const draw = System.render({ components: { has: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); const world = createWorld({ [gpuDevice]: own }); await world.run(spawn); expect((await runReported(world, draw)).failures).toEqual([]); @@ -196,7 +195,7 @@ describe("render systems with an order", () => { it("declare their ordered accessors, which compile", async () => { const own = await device(); - const draw = System.render({ name: "renderOrderTest/declared", order: byDepth, shader: { group: 2, accessors: { paintDepth: depth, paintSize: size } } }, () => {}); + const draw = System.render({ order: byDepth, shader: { group: 2, accessors: { paintDepth: depth, paintSize: size } } }, () => {}); const code = Gpu.wgsl(draw); expect(code).toContain("fn paintDepth(row: u32) -> f32"); expect(code).toContain("fn paintSize(row: u32) -> vec3"); diff --git a/packages/ecs/src/implementation/world/reports.test-helper.ts b/packages/ecs/src/implementation/world/reports.test-helper.ts index 593d9ab6..aec3cb39 100644 --- a/packages/ecs/src/implementation/world/reports.test-helper.ts +++ b/packages/ecs/src/implementation/world/reports.test-helper.ts @@ -23,7 +23,7 @@ const readerOf = (world: World): Reader => { const known = readers.get(world); if (known !== undefined) return known; const asked: Reader["asked"] = []; - const system = System.create({ name: "test/readReport", observer: true, components: { read: [Report.run] }, lookup: { errorsOf: { read: [Report.errors] } } }, (row, { lookup }) => { + const system = System.create({ observer: true, components: { read: [Report.run] }, lookup: { errorsOf: { read: [Report.errors] } } }, (row, { lookup }) => { const read = asked[0]; const values = row[Report.run]; if (read === undefined || (read.run >= 0 ? values.number !== read.run : values.number <= (read.values?.number ?? -1))) return; @@ -90,7 +90,7 @@ export const followReports = (world: World) => { type Seen = { readonly run: number; readonly failures: readonly Report.Failure[] }; const seen: Seen[] = []; const waiting: (() => void)[] = []; - const system = System.create({ name: "test/followReports", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + const system = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { seen.push({ run: row[Report.run].number, failures: row[Report.errors].failures }); for (const wake of waiting.splice(0)) wake(); }); @@ -106,11 +106,11 @@ export const followReports = (world: World) => { }; /** Despawns every report, for tests that compare entity order with a world that has none: later runs spawn them again. */ -export const dropReports = System.create({ name: "test/dropReports", components: { has: [Report.run] } }, (row, { commands }) => { +export const dropReports = System.create({ components: { has: [Report.run] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const nothing = System.create({ name: "test/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** Runs a world until every report it keeps is spawned, so later runs reuse their entities and spawn none. */ export const fillReports = async (world: World): Promise => { diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index ad3e236e..a9e3dbc7 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -16,31 +16,31 @@ declare module "@adobe/ecs" { } } -const nothing = System.create({ name: "reportsTest/nothing" }, () => {}); -const fail = System.create({ name: "reportsTest/fail" }, () => { throw new Error("run failed"); }); -const raise = System.create({ name: "reportsTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const idle = System.create({ name: "reportsTest/idle", observer: true }, () => {}); -const spawn = System.create({ name: "reportsTest/spawn" }, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); -const despawnAll = System.create({ name: "reportsTest/despawnAll", components: { has: [amount] } }, (row, { commands }) => { +const nothing = System.create({}, () => {}); +const fail = System.create({}, () => { throw new Error("run failed"); }); +const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const idle = System.create({ observer: true }, () => {}); +const spawn = System.create({}, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); +const despawnAll = System.create({ components: { has: [amount] } }, (row, { commands }) => { commands.despawn(row.entity); commands.despawn(row.entity); }); -const failingObserver = System.create({ name: "reportsTest/failingObserver", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const failingObserver = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); /** What a logger following reports saw, by pass: each changed report's run and failures' messages. */ const logged: (readonly [number, readonly string[]])[] = []; -const logger = System.create({ name: "reportsTest/logger", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { +const logger = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { logged.push([row[Report.run].number, row[Report.errors].failures.map(failure => failure.message)]); }); /** The run numbers of every report, in entity order, and of those with errors. */ let numbers: number[] = []; let withErrors: number[] = []; -const allReports = Index.create({ name: "reportsTest/allReports", key: [], components: { has: [Report.run] } }); -const erredReports = Index.create({ name: "reportsTest/erredReports", key: [], components: { has: [Report.run, Report.errors] } }); -const listReports = System.create({ name: "reportsTest/listReports", observer: true, lookup: { all: { index: allReports, read: [Report.run] }, erred: { index: erredReports, read: [Report.run] } } }, ({ lookup }) => { +const allReports = Index.create({ key: [], components: { has: [Report.run] } }); +const erredReports = Index.create({ key: [], components: { has: [Report.run, Report.errors] } }); +const listReports = System.create({ observer: true, lookup: { all: { index: allReports, read: [Report.run] }, erred: { index: erredReports, read: [Report.run] } } }, ({ lookup }) => { const all = lookup.all(); numbers = []; for (let i = 0; i < all.length; i++) numbers.push(all.at(i)[Report.run].number); @@ -81,7 +81,7 @@ describe("reports", () => { const failed = await world.run(fail); expect(logged).toEqual([[failed, ["run failed"]]]); const { failures } = await reportOf(world, failed); - expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "reportsTest/fail", "run failed"]]); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "fail", "run failed"]]); await world.run(spawn); const dropping = await world.run(despawnAll); expect(await reportOf(world, dropping)).toEqual({ failures: [], skipped: [], dropped: 1, warnings: [] }); @@ -101,7 +101,7 @@ describe("reports", () => { expect((await reportOf(world, raised)).failures).toEqual([]); // The reader's run, the next, holds them, and the logger saw them in its pass. const { failures } = await reportOf(world, raised + 1); - expect(failures.map(({ name, message }) => [name, message])).toEqual([["reportsTest/failingObserver", "observer failed"]]); + expect(failures.map(({ name, message }) => [name, message])).toEqual([["failingObserver", "observer failed"]]); expect(logged).toEqual([[raised + 1, ["observer failed"]]]); // A failed run's report holds its own failures; its pass's are in the next. const both = System.create([fail, raise]); @@ -131,7 +131,7 @@ describe("reports", () => { await using world = inline(); logged.length = 0; world.add(logger); - const failWith = System.create({ name: "reportsTest/failWith" }, (_context, input: { readonly k: number }) => { throw new Error(`run ${input.k}`); }); + const failWith = System.create({}, (_context, input: { readonly k: number }) => { throw new Error(`run ${input.k}`); }); for (let k = 0; k < retainedRuns; k++) await world.run(failWith, { k }); logged.length = 0; const reused = await world.run(failWith, { k: retainedRuns }); @@ -150,7 +150,7 @@ describe("reports", () => { let ticked = (): void => {}; // Its first pass passes its filter; only a tick writes the clock after. let passes = 0; - const watchTicks = System.create({ name: "reportsTest/watchTicks", observer: true, resources: { changed: [clock] } }, () => { + const watchTicks = System.create({ observer: true, resources: { changed: [clock] } }, () => { if (++passes > 1) ticked(); }); world.add(watchTicks); diff --git a/packages/ecs/src/implementation/world/rows.test.ts b/packages/ecs/src/implementation/world/rows.test.ts index 71baba00..92e826d4 100644 --- a/packages/ecs/src/implementation/world/rows.test.ts +++ b/packages/ecs/src/implementation/world/rows.test.ts @@ -25,7 +25,7 @@ declare module "@adobe/ecs" { } -const seed = System.create({ name: "rowsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4; i++) commands.spawn(i < 2 ? [position, count, flag, tag] : [position, count, flag])({ [position]: [i, i + 0.5, -i], [count]: i, [flag]: i % 2 === 0 }); }); @@ -34,17 +34,17 @@ describe("accessor rows", () => { const seen: { entity: Entity; position: Vec3; count: number; flag: boolean }[] = []; const kept: Vec3[] = []; // Both run over two archetypes (with and without the tag), with a system using a different row between them. - const first = System.create({ name: "rowsTest/first", components: { read: [position, flag], write: [count] } }, row => { + const first = System.create({ components: { read: [position, flag], write: [count] } }, row => { const value = row[position]; kept.push(value); seen.push({ entity: row.entity, position: value, count: row[count], flag: row[flag] }); row[count] = row[count] + 10; }); - const other = System.create({ name: "rowsTest/other", components: { write: [position] }, after: [first] }, row => { + const other = System.create({ components: { write: [position] }, after: [first] }, row => { const [x, y, z] = row[position]; row[position] = [x + 100, y, z]; }); - const second = System.create({ name: "rowsTest/second", components: { read: [position, flag], write: [count] }, after: [other] }, row => { + const second = System.create({ components: { read: [position, flag], write: [count] }, after: [other] }, row => { seen.push({ entity: row.entity, position: row[position], count: row[count], flag: row[flag] }); row[count] = row[count] + 1; }); diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index 2f71b70d..cabc0c0e 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -9,7 +9,7 @@ import { count, read } from "./reads.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; import { declareComponent$ } from "../../interface/registry/declare.js"; -import { labelOfItem, programRules } from "../../interface/registry/index.js"; +import { programRules } from "../../interface/registry/index.js"; const source = Symbol("rulesTest/source"); const hidden = Symbol("rulesTest/hidden"); @@ -47,13 +47,13 @@ declare module "@adobe/ecs" { } -Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); -Rule.create({ name: "rulesTest/visible", components: { has: [slot] }, derive: [visible] }); -Rule.create({ name: "rulesTest/sprites", components: { has: [sprite] }, derive: [visible] }); -Rule.create({ name: "rulesTest/tag", components: { has: [tagged] }, derive: [tag] }); +Rule.create(identify({ components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); +Rule.create({ components: { has: [slot] }, derive: [visible] }); +Rule.create({ components: { has: [sprite] }, derive: [visible] }); +Rule.create({ components: { has: [tagged] }, derive: [tag] }); /** Runs `edit` with commands once, as its own run. */ -const edit = System.create({ name: "rulesTest/edit" }, ({ commands }, input: { readonly step: number }) => { +const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { for (const step of steps[input.step] ?? []) step(commands); }); let steps: ((commands: System.Commands) => void)[][] = []; @@ -71,8 +71,8 @@ const components = async (world: ReturnType, entity: Entity) describe("rules", () => { it("accept a rule declared again the same under its id, as a module evaluated again declares it, and reject a different one", () => { const rules = programRules().length; - Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); - expect(() => Rule.create(identify({ name: "rulesTest/slots", components: { has: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); + Rule.create(identify({ components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); + expect(() => Rule.create(identify({ components: { has: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); expect(programRules()).toHaveLength(rules); }); it("derive components where they match, from the schema default, and remove them where they stop", async () => { @@ -96,7 +96,7 @@ describe("rules", () => { }); it("write the default again where a derived component starts over, on a reused row", async () => { - const write = System.create({ name: "rulesTest/write", components: { write: [slot] } }, row => { row[slot] = 99; }); + const write = System.create({ components: { write: [slot] } }, row => { row[slot] = 99; }); const world = createWorld({ [workers]: 0 }); const step = at(world); let a = Entity.none; @@ -110,7 +110,7 @@ describe("rules", () => { }); it("apply in a world from the first sync point after it meets their components, deriving on the first spawn", async () => { - const spawn = System.create({ name: "rulesTest/tagged" }, ({ commands }) => { commands.spawn([tagged])({ [tagged]: true }); }); + const spawn = System.create({}, ({ commands }) => { commands.spawn([tagged])({ [tagged]: true }); }); const world = createWorld({ [workers]: 0 }); await world.run(edit, { step: -1 }); expect(await count(world, { required: [tag] })).toBe(0); @@ -121,13 +121,13 @@ describe("rules", () => { it("created after a world has entities they match, derive for them at the next run, and for batch spawns", async () => { let added = 0; - const observe = System.create({ name: "rulesTest/added", components: { added: [late] } }, () => { added++; }); - const spawn = System.create({ name: "rulesTest/spawn" }, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn([mark])({ [mark]: i }); }); + const observe = System.create({ components: { added: [late] } }, () => { added++; }); + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn([mark])({ [mark]: i }); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(observe); expect(await count(world, { required: [late] })).toBe(0); - Rule.create({ name: "rulesTest/late", components: { has: [mark] }, derive: [late] }); + Rule.create({ components: { has: [mark] }, derive: [late] }); await world.run(spawn); expect(await count(world, { required: [late] })).toBe(6); await world.run(observe); @@ -137,13 +137,10 @@ describe("rules", () => { it("are rejected when they can't hold", async () => { // @ts-expect-error a rule derives only derived components - expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); - expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); - // A rejected rule records nothing, so its name labels only the next; a system may share a rule's name. - expect(labelOfItem(Rule.create({ name: "rulesTest/bad", components: { has: [tagged] }, derive: [late] }))).toBe("rulesTest/bad"); - expect(labelOfItem(System.create({ name: "rulesTest/visible" }, () => {}))).toBe("rulesTest/visible#2"); + expect(() => Rule.create({ components: { has: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); + expect(() => Rule.create({ components: { has: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); // @ts-expect-error a reference changes only through commands - expect(() => System.create({ name: "rulesTest/writeReference", components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); + expect(() => System.create({ components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. declareComponent$(left, { type: { const: true }, within: right }); expect(() => declareComponent$(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 097a33f9..cbe476b6 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -24,7 +24,7 @@ import { combine, identityOf, reductionTargets, type ReductionTarget } from './r import { samePath, type TypedArray } from '../../interface/registry/layout.js'; import { inputFor } from './input.js'; import { createUp, type Run } from './up.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf } from '../../interface/registry/index.js'; import type { RemoteSystem } from './remote-systems.js'; import type { JoinedObserver } from './joined-observer.js'; import { releaseDirectory, type Directory } from './directories.js'; @@ -169,7 +169,7 @@ export const releaseHoldings = (state: State, holdings: Holdings): void => { /** The resources a declaration writes or reduces into, by id. */ export const resourceWritesOf = (state: State, declaration: Leaf["declaration"]): readonly number[] => - [...new Set([...declaration.resources?.write ?? [], ...declaration.reduce === undefined ? [] : Object.getOwnPropertySymbols(declaration.reduce)].map(key => idOf(state, key)))]; + [...new Set([...declaration.resources?.write ?? [], ...declaration.resources?.reduce ?? []].map(key => idOf(state, key)))]; /** A number for each leaf, so the GPU records of every runner made for it are its own, whatever its name. */ const leafNumbers = new WeakMap(); @@ -184,7 +184,7 @@ const numberOf = (leaf: Leaf): number => { export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { declaration } = leaf; const leafNumber = numberOf(leaf); - const label = labelOfItem(leaf); + const label = leaf.name; // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; @@ -224,7 +224,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const up = declaration.up === undefined ? null : createUp(state, declaration.up, filtered); /** Its rows in its `order` index's order, run by run. */ const ordered = indexLookups.ordered === null ? null : createOrdered(state.memory); - const targets = reductionTargets(state, declaration); + const targets = reductionTargets(state, label, declaration); /** For each partial of the kernel, the target and leaf it folds into. */ const partialLeaves = (kernel?.description.reductions ?? []).map(reduced => { const index = targets.findIndex(target => target.key === reduced.resource); diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts index ac5e0ab5..78bf6b9d 100644 --- a/packages/ecs/src/implementation/world/schedule.test.ts +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -25,98 +25,98 @@ const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(t describe("scheduleOf", () => { it("tiers by longest ordering path, keeping flatten order within a tier", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const c = leaf({ name: "scheduleTest/c", before: [b] }); - const d = leaf({ name: "scheduleTest/d" }); - const root = composite({ name: "scheduleTest/root", systems: [b, d, c, a] }); - expect(names(root)).toEqual([["scheduleTest/d", "scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const c = leaf({ name: "c", before: [b] }); + const d = leaf({ name: "d" }); + const root = composite({ name: "root", systems: [b, d, c, a] }); + expect(names(root)).toEqual([["d", "c", "a"], ["b"]]); }); it("applies a composite's constraints to every member, and resolves composites to their members", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b" }); - const d = leaf({ name: "scheduleTest/d" }); - const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [d] }); - const c = leaf({ name: "scheduleTest/c", after: [group] }); - expect(names(composite({ name: "scheduleTest/root", systems: [c, group, d] }))) - .toEqual([["scheduleTest/d"], ["scheduleTest/a", "scheduleTest/b"], ["scheduleTest/c"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b" }); + const d = leaf({ name: "d" }); + const group = composite({ name: "group", systems: [a, b], after: [d] }); + const c = leaf({ name: "c", after: [group] }); + expect(names(composite({ name: "root", systems: [c, group, d] }))) + .toEqual([["d"], ["a", "b"], ["c"]]); }); it("pulls during pairs into one tier unless an ordering path separates them", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const c = leaf({ name: "scheduleTest/c", during: [b] }); - const d = leaf({ name: "scheduleTest/d", after: [c] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a, b, c, d] }))) - .toEqual([["scheduleTest/a"], ["scheduleTest/b", "scheduleTest/c"], ["scheduleTest/d"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const c = leaf({ name: "c", during: [b] }); + const d = leaf({ name: "d", after: [c] }); + expect(names(composite({ name: "root", systems: [a, b, c, d] }))) + .toEqual([["a"], ["b", "c"], ["d"]]); }); it("orders a system during another between what that system follows and what follows it", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [update] }); - const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); - expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, update, render] }))) - .toEqual([["scheduleTest/writer", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const writer = leaf({ name: "writer", components: { write: [value] }, during: [update] }); + const reader = leaf({ name: "reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "root", systems: [reader, writer, update, render] }))) + .toEqual([["writer", "update"], ["reader", "render"]]); }); it("takes the order of a system during a system that is itself during another", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const physics = leaf({ name: "scheduleTest/physics", during: [update] }); - const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [physics] }); - const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); - expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, physics, update, render] }))) - .toEqual([["scheduleTest/writer", "scheduleTest/physics", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const physics = leaf({ name: "physics", during: [update] }); + const writer = leaf({ name: "writer", components: { write: [value] }, during: [physics] }); + const reader = leaf({ name: "reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "root", systems: [reader, writer, physics, update, render] }))) + .toEqual([["writer", "physics", "update"], ["reader", "render"]]); }); it("orders a system during a composite around the composite, not between its members", () => { - const start = leaf({ name: "scheduleTest/start", components: { write: [other] } }); - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [start] }); - const end = leaf({ name: "scheduleTest/end", components: { read: [value] }, after: [group] }); - const c = leaf({ name: "scheduleTest/c", components: { read: [other], write: [value] }, during: [group] }); - expect(names(composite({ name: "scheduleTest/root", systems: [c, start, group, end] }))) - .toEqual([["scheduleTest/start"], ["scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"], ["scheduleTest/end"]]); + const start = leaf({ name: "start", components: { write: [other] } }); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const group = composite({ name: "group", systems: [a, b], after: [start] }); + const end = leaf({ name: "end", components: { read: [value] }, after: [group] }); + const c = leaf({ name: "c", components: { read: [other], write: [value] }, during: [group] }); + expect(names(composite({ name: "root", systems: [c, start, group, end] }))) + .toEqual([["start"], ["c", "a"], ["b"], ["end"]]); }); it("still throws on conflicting systems during one system, and on a during that contradicts before or after", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const a = leaf({ name: "scheduleTest/a", components: { write: [value] }, during: [update] }); - const b = leaf({ name: "scheduleTest/b", components: { write: [value] }, during: [update] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); - const late = leaf({ name: "scheduleTest/late", during: [update], after: [render] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, late] }))).toThrow("Ordering cycle"); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const a = leaf({ name: "a", components: { write: [value] }, during: [update] }); + const b = leaf({ name: "b", components: { write: [value] }, during: [update] }); + expect(() => scheduleOf(composite({ name: "root", systems: [update, render, a, b] }))).toThrow('"a" and "b"'); + const late = leaf({ name: "late", during: [update], after: [render] }); + expect(() => scheduleOf(composite({ name: "root", systems: [update, render, late] }))).toThrow("Ordering cycle"); }); it("ignores constraints naming systems outside the one being scheduled", () => { - const a = leaf({ name: "scheduleTest/a", after: [leaf({ name: "scheduleTest/d" })] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a] }))).toEqual([["scheduleTest/a"]]); + const a = leaf({ name: "a", after: [leaf({ name: "d" })] }); + expect(names(composite({ name: "root", systems: [a] }))).toEqual([["a"]]); }); it("throws on a cycle", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a], before: [a] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow("Ordering cycle"); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a], before: [a] }); + expect(() => scheduleOf(composite({ name: "root", systems: [a, b] }))).toThrow("Ordering cycle"); }); it("throws on an unordered write conflict, but not when ordered or disjoint", () => { - const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); - const b = leaf({ name: "scheduleTest/b", components: { read: [value] } }); - const c = leaf({ name: "scheduleTest/c", components: { write: [other] }, during: [a] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); + const a = leaf({ name: "a", components: { write: [value] } }); + const b = leaf({ name: "b", components: { read: [value] } }); + const c = leaf({ name: "c", components: { write: [other] }, during: [a] }); + expect(() => scheduleOf(composite({ name: "root", systems: [a, b] }))).toThrow('"a" and "b"'); const ordered = leaf({ ...b.declaration, name: b.name, after: [a] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a, ordered, c] }))) - .toEqual([["scheduleTest/a", "scheduleTest/c"], ["scheduleTest/b"]]); + expect(names(composite({ name: "root", systems: [a, ordered, c] }))) + .toEqual([["a", "c"], ["b"]]); }); it("schedules two systems of one name as two, and one listed twice as one", () => { - const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); - const copy = leaf({ name: "scheduleTest/a", components: { write: [value] }, after: [a] }); - expect(scheduleOf(composite({ name: "scheduleTest/root", systems: [copy, a, a] })).tiers).toEqual([[a], [copy]]); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, leaf({ name: "scheduleTest/a", components: { write: [value] } })] }))).toThrow('Systems "scheduleTest/a" and "scheduleTest/a" both access'); + const a = leaf({ name: "a", components: { write: [value] } }); + const copy = leaf({ name: "a", components: { write: [value] }, after: [a] }); + expect(scheduleOf(composite({ name: "root", systems: [copy, a, a] })).tiers).toEqual([[a], [copy]]); + expect(() => scheduleOf(composite({ name: "root", systems: [a, leaf({ name: "a", components: { write: [value] } })] }))).toThrow('Systems "a" and "a" both access'); }); }); diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts index d7f2f1fb..f479ea6d 100644 --- a/packages/ecs/src/implementation/world/semantics.test.ts +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -23,14 +23,13 @@ declare module "@adobe/ecs" { } } -const seed = System.create({ name: "semanticsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { // @ts-expect-error untyped callers can store any number as an entity commands.spawn([position, target, score])({ [position]: [5, 6], [target]: -1, [score]: 0 }); // @ts-expect-error untyped callers can store any number as an entity commands.spawn([position, target, score])({ [position]: [7, 8], [target]: Number.NaN, [score]: 0 }); }); const chase = System.create({ - name: "semanticsTest/chase", kernel: "cpu", components: { read: [target], write: [score] }, lookup: { targets: { read: [position] } }, @@ -56,7 +55,7 @@ const run = async (wasm: boolean) => { describe("semantics", () => { it("a throwing sync point doesn't poison the next one or another world", async () => { let poison = false; - const spawn = System.create({ name: "semanticsTest/seed/2" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { commands.spawn([score])({ [score]: 1 }); commands.spawn([score])({ [score]: poison ? Symbol("x") as unknown as number : 2 }); }); @@ -82,9 +81,9 @@ describe("semantics", () => { it("re-adding an existing tag keeps its added tick", async () => { const seen: number[] = []; let entity = Entity.none; - const spawn = System.create({ name: "semanticsTest/seed/3" }, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); - const retag = System.create({ name: "semanticsTest/retag" }, ({ commands }) => { commands.add(entity, tag, true); }); - const watch = System.create({ name: "semanticsTest/watch", components: { has: [score], added: [tag] } }, row => { seen.push(row.entity); }); + const spawn = System.create({}, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); + const retag = System.create({}, ({ commands }) => { commands.add(entity, tag, true); }); + const watch = System.create({ components: { has: [score], added: [tag] } }, row => { seen.push(row.entity); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(watch); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index 192e9871..4236f2a3 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { World } from '../../interface/world/world.js'; import type { Failure } from '../../interface/report/failure.js'; -import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, labelOfItem, requireDefinition, type Definition } from '../../interface/registry/index.js'; +import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, requireDefinition, type Definition } from '../../interface/registry/index.js'; import type { Views } from '../../interface/registry/layout.js'; import { failureOf } from './failures.js'; import { @@ -436,7 +436,7 @@ export const createSharing = (host: SharingHost) => { if (tasks === null) return; for (const [index, error] of message.errors) { const finalizer = tasks.finalizerAt(index); - if (finalizer !== undefined) host.defer(failureOf("finalizer", labelOfItem(finalizer), Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + if (finalizer !== undefined) host.defer(failureOf("finalizer", finalizer, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); } return; } @@ -472,7 +472,7 @@ export const createSharing = (host: SharingHost) => { case "observer failed": { for (const [index, error] of message.errors) { const placed = thread.observer?.placed[index]; - if (placed !== undefined) host.defer(failureOf("system", placed.label, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + if (placed !== undefined) host.defer(failureOf("system", { name: placed.label, id: placed.id }, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); } return; } @@ -562,7 +562,7 @@ export const createSharing = (host: SharingHost) => { proxy = created; } catch (error) { - host.defer(failureOf("system", name, error)); + host.defer(failureOf("system", { name, id: null }, error)); // Its runs reject. thread.names.set(number, { id: `#observer:${number}`, value: null }); drain(thread); diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index 996f1ba8..cc2e6491 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -31,45 +31,45 @@ declare module "@adobe/ecs" { /** Text of every kind a string holds: none, letters, astral characters as surrogate pairs, and a long run. */ const textOf = (n: number): string => n % 4 === 0 ? "" : n % 4 === 1 ? `item ${n}` : n % 4 === 2 ? `✓ ${n} 😀 naïve` : `${"long ".repeat(40)}${n}`; -const spawn = System.create({ name: "stringsTest/spawn" }, ({ commands }, input: { readonly count: number; readonly from: number }) => { +const spawn = System.create({}, ({ commands }, input: { readonly count: number; readonly from: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: textOf(input.from + i), [order]: input.from + i }); }); /** `count` rows with labels of 400 code units, whose records are 1024-byte blocks of the arena. */ -const spawnLong = System.create({ name: "stringsTest/spawnLong" }, ({ commands }, input: { readonly count: number }) => { +const spawnLong = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: `${i}`.padStart(400, "·"), [order]: i }); }); /** A run over every row that changes nothing, for its sync point. */ -const pass = System.create({ name: "stringsTest/pass", components: { read: [order] } }, row => { void row[order]; }); +const pass = System.create({ components: { read: [order] } }, row => { void row[order]; }); -const rename = System.create({ name: "stringsTest/rename", components: { read: [order], write: [label] } }, (row, _context, input: { readonly suffix: string }) => { +const rename = System.create({ components: { read: [order], write: [label] } }, (row, _context, input: { readonly suffix: string }) => { row[label] = `${textOf(row[order])}${input.suffix}`; }); -const relabel = System.create({ name: "stringsTest/relabel", components: { read: [order], has: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { +const relabel = System.create({ components: { read: [order], has: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const tag = System.create({ name: "stringsTest/tag", components: { has: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); -const untag = System.create({ name: "stringsTest/untag", components: { has: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); -const unlabel = System.create({ name: "stringsTest/unlabel", components: { has: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); -const despawn = System.create({ name: "stringsTest/despawn", components: { has: [order] } }, (row, { commands }) => commands.despawn(row.entity)); -const annotate = System.create({ name: "stringsTest/annotate", components: { read: [order], write: [note] } }, row => { +const tag = System.create({ components: { has: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); +const untag = System.create({ components: { has: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); +const unlabel = System.create({ components: { has: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); +const despawn = System.create({ components: { has: [order] } }, (row, { commands }) => commands.despawn(row.entity)); +const annotate = System.create({ components: { read: [order], write: [note] } }, row => { row[note] = row[order] % 3 === 0 ? null : `note ${row[order]}`; }); -const unannotate = System.create({ name: "stringsTest/unannotate", components: { write: [note] } }, row => { +const unannotate = System.create({ components: { write: [note] } }, row => { row[note] = null; }); -const noted = System.create({ name: "stringsTest/noted", components: { has: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); -const despawnEven = System.create({ name: "stringsTest/despawnEven", components: { read: [order] } }, (row, { commands }) => { +const noted = System.create({ components: { has: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); +const despawnEven = System.create({ components: { read: [order] } }, (row, { commands }) => { if (row[order] % 2 === 0) commands.despawn(row.entity); }); -const labelAgain = System.create({ name: "stringsTest/labelAgain", components: { read: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { +const labelAgain = System.create({ components: { read: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const edit = System.create({ name: "stringsTest/edit", resources: { write: [mode, title] } }, ({ resources }, input: { readonly title: string }) => { +const edit = System.create({ resources: { write: [mode, title] } }, ({ resources }, input: { readonly title: string }) => { resources[mode] = "edit"; resources[title] = input.title; }); diff --git a/packages/ecs/src/implementation/world/tails.cases.ts b/packages/ecs/src/implementation/world/tails.cases.ts index a0a31665..22fd7797 100644 --- a/packages/ecs/src/implementation/world/tails.cases.ts +++ b/packages/ecs/src/implementation/world/tails.cases.ts @@ -39,7 +39,7 @@ export const tailsCases = (load: () => Promise) => describe("tai }, 30000); it("are skipped in every run queued when their thread left, and nothing waits for a tail on a thread that's gone", async () => { const fixture = await load(); - const skipped = ["tailsTest/lead", "tailsTest/watch"]; + const skipped = ["lead", "watch"]; expect(await fixture.leaveQueued()).toEqual({ skipped: [skipped, skipped], after: 0 }); }, 30000); }); diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index ae1b1791..a692aab3 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -52,40 +52,40 @@ const spin = (milliseconds: number): void => { }; /** On the world's thread: the counter moves on once a tick. */ -export const count = System.create({ name: "tailsTest/count", resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); /** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: every row's stamp, spot and word take the counter. */ -export const advance = System.create({ name: "tailsTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); /** On the world's thread: every row's word as another, of the size of the first ticks', so their records are reused. */ -export const reword = System.create({ name: "tailsTest/reword", components: { write: [word] } }, (row, _context, input: { readonly round: number }) => { +export const reword = System.create({ components: { write: [word] } }, (row, _context, input: { readonly round: number }) => { row[word] = `r${input.round}`; }); /** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ -export const grow = System.create({ name: "tailsTest/grow", resources: { read: [counter], write: [spawning] }, after: [count] }, ({ resources, commands }) => { +export const grow = System.create({ resources: { read: [counter], write: [spawning] }, after: [count] }, ({ resources, commands }) => { const value = resources[counter]; for (let k = 0; k < resources[spawning]; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); resources[spawning] = 0; }); /** Spawns `count` rows. */ -export const seed = System.create({ name: "tailsTest/seed" }, ({ commands }, input: { readonly count: number }) => { +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ name: "tailsTest/setStall", resources: { write: [stall] } }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { +export const setStall = System.create({ resources: { write: [stall] } }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; }); -export const setSpawning = System.create({ name: "tailsTest/setSpawning", resources: { write: [spawning] } }, ({ resources }, input: { readonly count: number }) => { +export const setSpawning = System.create({ resources: { write: [spawning] } }, ({ resources }, input: { readonly count: number }) => { resources[spawning] = input.count; }); -export const setFailAt = System.create({ name: "tailsTest/setFailAt", resources: { write: [failAt] } }, ({ resources }, input: { readonly at: number }) => { +export const setFailAt = System.create({ resources: { write: [failAt] } }, ({ resources }, input: { readonly at: number }) => { resources[failAt] = input.at; }); @@ -94,7 +94,7 @@ export type Seen = { counter: number; rows: number; wrong: number; labels: numbe export const seen: Seen[] = []; /** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ -export const lead = System.create({ name: "tailsTest/lead", resources: { read: [stall, canvas] } }, ({ resources }) => { +export const lead = System.create({ resources: { read: [stall, canvas] } }, ({ resources }) => { if (resources[stall].mode === stallBefore && resources[canvas].text === "canvas") spin(resources[stall].pause); }); @@ -103,7 +103,7 @@ export const lead = System.create({ name: "tailsTest/lead", resources: { read: [ * must be the last tick's where it wrote one. */ export const watch = System.create({ - name: "tailsTest/watch", components: { read: [stamp, spot, word], write: [label] }, resources: { read: [counter, stall, failAt] }, after: [advance, grow, lead], + components: { read: [stamp, spot, word], write: [label] }, resources: { read: [counter, stall, failAt] }, after: [advance, grow, lead], }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); @@ -119,7 +119,7 @@ export const watch = System.create({ }); /** Not terminal: it reaches its commands, so its tier blocks. */ -export const blocking = System.create({ name: "tailsTest/blocking", components: { read: [label] } }, (row, { commands }) => { +export const blocking = System.create({ components: { read: [label] } }, (row, { commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); }); @@ -129,7 +129,7 @@ export const changed = new Map(); * On the tail's thread, ahead of the watcher, reaching its commands, so its tier blocks: counts the labels the watcher * wrote since it last ran, which it sees once the world stamped them, from the watcher's snapshot too. */ -export const tally = System.create({ name: "tailsTest/tally", components: { read: [label], changed: [label] }, resources: { read: [counter] }, after: [count], before: [watch] }, (row, { resources, commands }) => { +export const tally = System.create({ components: { read: [label], changed: [label] }, resources: { read: [counter] }, after: [count], before: [watch] }, (row, { resources, commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); changed.set(resources[counter], (changed.get(resources[counter]) ?? 0) + 1); }); @@ -138,19 +138,19 @@ export const tally = System.create({ name: "tailsTest/tally", components: { read export const gate: { words: Int32Array | null } = { words: null }; /** On the tail's thread, terminal, reading only that thread's objects: held in its body until the world opens the gate. */ -export const held = System.create({ name: "tailsTest/held", resources: { read: [canvas] } }, ({ resources }) => { +export const held = System.create({ resources: { read: [canvas] } }, ({ resources }) => { const { words } = gate; if (words === null || resources[canvas].text !== "canvas") return; Atomics.store(words, 1, 1); Atomics.notify(words, 1); Atomics.wait(words, 0, 0); }); -export const heldTick = System.create({ name: "tailsTest/heldTick", systems: [held] }); +export const heldTick = System.create({ systems: [held] }); /** Every tick: the world's systems, then the tails. */ -export const ticked = System.create({ name: "tailsTest/ticked", systems: [count, advance, grow, lead, watch] }); +export const ticked = System.create({ systems: [count, advance, grow, lead, watch] }); /** The same, with `tally` counting what the watcher wrote. */ -export const tallied = System.create({ name: "tailsTest/tallied", systems: [count, advance, grow, lead, tally, watch] }); +export const tallied = System.create({ systems: [count, advance, grow, lead, tally, watch] }); /** Every system the joined thread runs, which it imports so the world can place them there. */ export const tailSystems: readonly System[] = [ticked, tallied, blocking, heldTick]; diff --git a/packages/ecs/src/implementation/world/tails.test.ts b/packages/ecs/src/implementation/world/tails.test.ts index 08936f55..cb38ee55 100644 --- a/packages/ecs/src/implementation/world/tails.test.ts +++ b/packages/ecs/src/implementation/world/tails.test.ts @@ -48,7 +48,7 @@ describe("tails", () => { }); it("block in their tier where a body reaches its commands, which isn't terminal", async () => { const { world, remote } = await joined(); - expect((await runReported(world, blocking)).warnings.map(warning => warning.name)).toEqual(["tailsTest/blocking"]); + expect((await runReported(world, blocking)).warnings.map(warning => warning.name)).toEqual(["blocking"]); await remote.dispose(); await world.dispose(); }); @@ -59,12 +59,12 @@ describe("tails", () => { await world.run(seed, { count: 10 }); await world.run(setFailAt, { at: 1 }); const ran = await runReported(world, ticked); - expect(ran.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 1"]]); + expect(ran.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "watch", "tailsTest failure at 1"]]); const failing = await world.run(setFailAt, { at: 6 }); const reports = followReports(world); world.add(ticked); const failed = await reports.until(value => value.run > failing && value.failures.length > 0); - expect(failed.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 6"]]); + expect(failed.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "watch", "tailsTest failure at 6"]]); await remote.dispose(); await world.dispose(); driver.close(); @@ -104,8 +104,8 @@ describe("tails", () => { const leaving = remote.dispose(); const report = await runReported(world, ticked); await leaving; - expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["thread", "tailsTest/lead"], ["thread", "tailsTest/watch"]]); - expect(report.skipped).toEqual(["tailsTest/lead", "tailsTest/watch"]); + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["thread", "lead"], ["thread", "watch"]]); + expect(report.skipped).toEqual(["lead", "watch"]); expect(await runReported(world, ticked)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); expect(seen.map(({ counter, rows, wrong }) => [counter, rows, wrong])).toEqual([[1, 10, 0], [3, 10, 0]]); // A registration waits for tails in flight, of which a thread that's gone has none. diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 8b28ba92..94f1a69d 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; import type { Failure } from '../../interface/report/failure.js'; -import { declarationOf, firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { declarationOf, firstKeyOf } from '../../interface/registry/index.js'; import { layoutOf, type Layout } from '../../interface/registry/layout.js'; import { accessOf, type Schedule } from '../../interface/registry/schedule.js'; import { stampChanged, type Archetype } from './archetype.js'; @@ -40,7 +40,7 @@ export const isTerminal = (leaf: Leaf): boolean => { const { declaration } = leaf; const terminal = leaf.kind === "system" && declaration.kernel === undefined && declaration.up === undefined && provenTerminal(leaf.body) && Object.keys(declaration.lookup ?? {}).length === 0 - && (declaration.reduce === undefined || Object.getOwnPropertySymbols(declaration.reduce).length === 0) + && (declaration.resources?.reduce?.length ?? 0) === 0 && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); terminalLeaves.set(leaf, terminal); return terminal; @@ -130,7 +130,7 @@ const isStruct = (state: State, id: number): boolean => state.layouts[id].kind ! /** A runner's tail, made the first time it runs as one; its block is the channel's, freed with it. */ export const tailOf = (state: State, runner: Runner, leaf: Leaf): Tail => { const remote = runner.remote; - if (remote === null) throw new Error(`System "${labelOfItem(leaf)}" runs as a tail only on a joined thread.`); + if (remote === null) throw new Error(`System "${leaf.name}" runs as a tail only on a joined thread.`); if (remote.tail !== null) return remote.tail; const { components, resources } = leaf.declaration; const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; @@ -222,10 +222,10 @@ const outcomeOf = async (tasks: readonly Promise<{ readonly done: boolean; reado for (const { done, systems } of await Promise.all(tasks)) { for (const system of systems) { if (!done) { - (failures ??= []).push(failureOf("thread", system.label, system.channel.goneError())); + (failures ??= []).push(failureOf("thread", { name: system.label, id: system.id }, system.channel.goneError())); (skipped ??= []).push(system.label); } - else if (system.result.error !== undefined) (failures ??= []).push(failureOf("system", system.label, system.result.error)); + else if (system.result.error !== undefined) (failures ??= []).push(failureOf("system", { name: system.label, id: system.id }, system.result.error)); } } return { failures, skipped }; diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts index b7131acc..f0bff91b 100644 --- a/packages/ecs/src/implementation/world/target.test.ts +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -13,19 +13,19 @@ declare module "@adobe/ecs" { } let spawned: Entity[] = []; -const spawn = System.create({ name: "targetTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = [commands.spawn([amount, tagged])({ [amount]: 1 }), commands.spawn([amount, tagged])({ [amount]: 2 }), commands.spawn([amount])({ [amount]: 3 })]; }); -const bump = System.create({ name: "targetTest/bump", target: "entity", components: { write: [amount], has: [tagged] } }, (row, _context, input: { readonly entity: Entity }) => { +const bump = System.create({ target: "entity", components: { write: [amount], has: [tagged] } }, (row, _context, input: { readonly entity: Entity }) => { row[amount] += 10; }); -const remove = System.create({ name: "targetTest/remove", target: "entity", components: { has: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const remove = System.create({ target: "entity", components: { has: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); let amounts: number[] = []; let changed: number[] = []; -const readAmounts = System.create({ name: "targetTest/readAmounts", observer: true, components: { read: [amount] } }, row => { amounts.push(row[amount]); }); -const watchChanged = System.create({ name: "targetTest/watchChanged", observer: true, components: { read: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); +const readAmounts = System.create({ observer: true, components: { read: [amount] } }, row => { amounts.push(row[amount]); }); +const watchChanged = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); const inline = () => createWorld({ [workers]: 0 }); const amountsOf = async (world: ReturnType): Promise => { @@ -62,9 +62,9 @@ describe("systems on one entity", () => { it("take their entity from an entity field of their input, and have components, no kernel, up, order or reduce", () => { // @ts-expect-error its target isn't an entity field - expect(() => System.create({ name: "targetTest/notEntity", target: "count", components: { has: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) - .toThrow('System "targetTest/notEntity" runs on one entity, so it takes it from an input field "count" holding an entity.'); + expect(() => System.create({ target: "count", components: { has: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) + .toThrow('runs on one entity, so it takes it from an input field "count" holding an entity.'); // @ts-expect-error it needs components - expect(() => System.create({ name: "targetTest/noComponents", target: "entity" }, (_context, input: { readonly entity: Entity }) => {})).toThrow('System "targetTest/noComponents" runs on one entity, so it needs components.'); + expect(() => System.create({ target: "entity" }, (_context, input: { readonly entity: Entity }) => {})).toThrow('runs on one entity, so it needs components.'); }); }); diff --git a/packages/ecs/src/implementation/world/thread-observer.ts b/packages/ecs/src/implementation/world/thread-observer.ts index 572864e3..8bd5a39d 100644 --- a/packages/ecs/src/implementation/world/thread-observer.ts +++ b/packages/ecs/src/implementation/world/thread-observer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Commands } from '../../interface/system/commands.js'; import type { Leaf } from '../../interface/system/leaf.js'; -import { definitionOf, isLeaf, itemOfId, labelOfItem } from '../../interface/registry/index.js'; +import { definitionOf, idOrNull, isLeaf, itemOfId } from '../../interface/registry/index.js'; import { cancelled, notRun, opening, outcomeWord, postedWord, removedWord, runsWord, observerBlockWords, syncWord, tickSlot } from './joined-observer.js'; import { protocolName, type ObserverSetup, type ToWorld } from './joining.js'; import type { Mirror } from './mirror.js'; @@ -50,6 +50,8 @@ type Block = { readonly control: Int32Array; readonly ticks: Float64Array; handl type Placed = { readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; readonly blocks: readonly Block[]; /** Whether its posts read only their blocks, never this thread's objects. */ readonly contained: boolean; @@ -92,13 +94,13 @@ export const createThreadObserver = (host: ThreadObserverHost) => { if (own === undefined) continue; own.runner = createThreadRunner(runnerHost, own.leaf, records); own.placed = true; - placed[index] = { index, label: labelOfItem(own.leaf), blocks, contained, runner: null, local: new WeakRef(own), stopped: false }; + placed[index] = { index, label: own.leaf.name, id: idOrNull(own.leaf), blocks, contained, runner: null, local: new WeakRef(own), stopped: false }; continue; } const item = id === null ? undefined : itemOfId(id); const definition = item === undefined ? undefined : definitionOf(item); const leaf = definition !== undefined && isLeaf(definition) ? definition : null; - placed[index] = { index, label: leaf === null ? String(id) : labelOfItem(leaf), blocks, contained, runner: leaf === null ? null : createThreadRunner(runnerHost, leaf, records), local: null, stopped: false }; + placed[index] = { index, label: leaf === null ? String(id) : leaf.name, id: leaf === null ? null : idOrNull(leaf), blocks, contained, runner: leaf === null ? null : createThreadRunner(runnerHost, leaf, records), local: null, stopped: false }; } }; diff --git a/packages/ecs/src/implementation/world/thread-systems.cases.ts b/packages/ecs/src/implementation/world/thread-systems.cases.ts index 79f1f7ff..8aaca95e 100644 --- a/packages/ecs/src/implementation/world/thread-systems.cases.ts +++ b/packages/ecs/src/implementation/world/thread-systems.cases.ts @@ -14,13 +14,13 @@ export const threadSystemsCases = (load: () => Promise) => descr it("are skipped with a \"thread\" failure when the worker dies mid-task, and run on the world's thread once it's gone", async () => { const fixture = await load(); expect(await fixture.diesMidTask()).toEqual({ - report: { failures: [["thread", "threadSystemsTest/stall", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']], skipped: ["threadSystemsTest/stall"] }, + report: { failures: [["thread", "stall", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']], skipped: ["stall"] }, after: 0, }); }, 30000); it("can't join claiming the thread of a system the joined thread never imported", async () => { const fixture = await load(); - expect(await fixture.unimported()).toBe('System "threadSystemsTest/onlyHere" runs on thread "threadSystemsTest", which the joined thread claiming it never imported.'); + expect(await fixture.unimported()).toBe('System "onlyHere" runs on thread "threadSystemsTest", which the joined thread claiming it never imported.'); }, 30000); it("don't stall ticks that don't touch their thread when it never yields", async () => { const fixture = await load(); diff --git a/packages/ecs/src/implementation/world/thread-systems.fixture.ts b/packages/ecs/src/implementation/world/thread-systems.fixture.ts index b5a6e4dd..aefc3f99 100644 --- a/packages/ecs/src/implementation/world/thread-systems.fixture.ts +++ b/packages/ecs/src/implementation/world/thread-systems.fixture.ts @@ -16,7 +16,7 @@ import { reportOf, runReported } from "./reports.test-helper.js"; */ /** A system on the worker's thread, which only this module declares, so the worker never imported it. */ -const onlyHere = System.create({ name: "threadSystemsTest/onlyHere", components: { read: [caption] } }, row => { void row[caption]; }); +const onlyHere = System.create({ components: { read: [caption] } }, row => { void row[caption]; }); type Worker = { readonly ask: (message: ToThreadSystemsWorker, transfer?: readonly unknown[]) => Promise; diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 3efeb1aa..2692c4de 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -53,7 +53,7 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con const makeNote = (text: string): Note => ({ text, read: () => text }); /** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ -export const seed = System.create({ name: "threadSystemsTest/seed", resources: { write: [nextUid] } }, ({ resources, commands }, input: { readonly count: number }) => { +export const seed = System.create({ resources: { write: [nextUid] } }, ({ resources, commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); @@ -61,18 +61,18 @@ export const seed = System.create({ name: "threadSystemsTest/seed", resources: { }); /** On the world's thread: positions move by their velocity. */ -export const move = System.create({ name: "threadSystemsTest/move", components: { read: [velocity], write: [position] } }, row => { +export const move = System.create({ components: { read: [velocity], write: [position] } }, row => { const [x, y] = row[position]; row[position] = [x + row[velocity], y - row[velocity]]; }); /** On the thread: captions written in place, from the structs it reads. */ -export const describe = System.create({ name: "threadSystemsTest/describe", components: { read: [position, velocity], write: [caption] }, after: [move] }, row => { +export const describe = System.create({ components: { read: [position, velocity], write: [caption] }, after: [move] }, row => { if (row[velocity] !== 0) row[caption] = { text: `at ${row[position][0]}` }; }); /** On the thread: commands giving its objects values it stages, struct values and tags, and despawns. */ -export const relabel = System.create({ name: "threadSystemsTest/relabel", components: { read: [uid, caption, position], has: [velocity] }, after: [describe] }, (row, { commands }) => { +export const relabel = System.create({ components: { read: [uid, caption, position], has: [velocity] }, after: [describe] }, (row, { commands }) => { const id = row[uid]; if (row[position][0] > 40) { commands.despawn(row.entity); @@ -85,58 +85,58 @@ export const relabel = System.create({ name: "threadSystemsTest/relabel", compon }); /** On the thread, reading its object resource: spawns giving its objects values, with a struct resource's next uid. */ -export const breed = System.create({ name: "threadSystemsTest/breed", resources: { read: [paint], write: [nextUid] }, after: [relabel] }, ({ resources, commands }) => { +export const breed = System.create({ resources: { read: [paint], write: [nextUid] }, after: [relabel] }, ({ resources, commands }) => { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, note])({ [uid]: id, [position]: [0, id], [velocity]: 1, [caption]: { text: `${resources[paint].text} ${id}` }, [note]: makeNote(`bred ${id}`) }); }); /** On the thread: a reduction over its objects and structs. */ -export const tally = System.create({ name: "threadSystemsTest/tally", components: { read: [caption, position] }, reduce: { [total]: "+" }, after: [breed] }, (row, { reduce }) => { +export const tally = System.create({ components: { read: [caption, position] }, resources: { reduce: [total] }, after: [breed] }, (row, { reduce }) => { reduce[total]((row[caption]?.text.length ?? 0) + row[position][1]); }); /** On the thread: a change filter on its own objects, writing a struct resource. */ -export const watchCaptions = System.create({ name: "threadSystemsTest/watchCaptions", components: { changed: [caption] }, resources: { write: [changes] }, after: [tally] }, (_row, { resources }) => { +export const watchCaptions = System.create({ components: { changed: [caption] }, resources: { write: [changes] }, after: [tally] }, (_row, { resources }) => { resources[changes] += 1; }); /** On the thread, while `enabled` holds: writes its object resource, and a struct resource from it. */ -export const repaint = System.create({ name: "threadSystemsTest/repaint", resources: { write: [paint, painted], is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { +export const repaint = System.create({ resources: { write: [paint, painted], is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { resources[paint] = { text: `${resources[paint].text}+` }; resources[painted] = resources[paint].text.length; }); /** On the thread: a lookup reading an entity's structs and this thread's objects. */ -export const peek = System.create({ name: "threadSystemsTest/peek", components: { has: [uid, note] }, lookup: { self: { read: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { +export const peek = System.create({ components: { has: [uid, note] }, lookup: { self: { read: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { const self = lookup.self(row.entity); if (self !== null && ((self[caption]?.text.length ?? 0) + self[position][1]) % 2 === 1) commands.add(row.entity, flagged); }); /** On the world's thread, beside `tally`: a world object from the structs. */ -export const keep = System.create({ name: "threadSystemsTest/keep", components: { read: [uid], write: [kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { +export const keep = System.create({ components: { read: [uid], write: [kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { row[kept] = `${row[kept]}.${row[uid] % 10}`; }); /** On the thread, failing after its commands: later tiers are skipped, and its commands still apply. */ -export const fail = System.create({ name: "threadSystemsTest/fail", components: { read: [uid, caption] } }, (row, { commands }) => { +export const fail = System.create({ components: { read: [uid, caption] } }, (row, { commands }) => { commands.add(row.entity, flagged); if (row[uid] === 3 && row[caption] !== undefined) throw new Error("threadSystemsTest failure"); }); -export const afterFail = System.create({ name: "threadSystemsTest/afterFail", components: { read: [caption] }, after: [fail] }, (row, { commands }) => { +export const afterFail = System.create({ components: { read: [caption] }, after: [fail] }, (row, { commands }) => { if (row[caption] !== undefined) commands.despawn(row.entity); }); -export const failing = System.create({ name: "threadSystemsTest/failing", systems: [fail, afterFail] }); +export const failing = System.create({ systems: [fail, afterFail] }); /** A tick's systems, on both threads, in tiers some of which run on the joined thread alone, one after another. */ -export const tick = System.create({ name: "threadSystemsTest/tick", systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); +export const tick = System.create({ systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); /** On the thread, with input, which comes to it by message. */ -export const stamp = System.create({ name: "threadSystemsTest/stamp", components: { read: [uid], write: [caption] } }, (row, _context, input: { readonly text: string }) => { +export const stamp = System.create({ components: { read: [uid], write: [caption] } }, (row, _context, input: { readonly text: string }) => { if (row[uid] === 1) row[caption] = { text: input.text }; }); /** Disables `repaint`, from the world's thread. */ -export const disable = System.create({ name: "threadSystemsTest/disable", resources: { write: [enabled] } }, ({ resources }) => { +export const disable = System.create({ resources: { write: [enabled] } }, ({ resources }) => { resources[enabled] = false; }); @@ -172,7 +172,6 @@ const reading: { entities: readonly Entity[]; objects: readonly { readonly [name /** Each entity's objects of the thread, as data, a note by its text: none for an entity that's dead or has no uid. */ const readObjects = System.create({ - name: "threadSystemsTest/readObjects", observer: true, lookup: { ids: { has: [uid] }, captions: { read: [caption] }, notes: { read: [note] } }, }, ({ lookup }) => { @@ -242,40 +241,40 @@ export const scenario = async (world: World, objects: Objects = objectsThere(wor }; /** Spawns entities with captions, from the world's thread. */ -export const crowd = System.create({ name: "threadSystemsTest/crowd" }, ({ commands }, input: { readonly count: number }) => { +export const crowd = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([uid, caption])({ [uid]: 1000 + k }); }); /** On the world's thread: every crowd entity but uid 1000 moves archetype, flagged on one run and not on the next. */ -export const flagCrowd = System.create({ name: "threadSystemsTest/flagCrowd", components: { read: [uid], has: [caption], not: [flagged] } }, (row, { commands }) => { +export const flagCrowd = System.create({ components: { read: [uid], has: [caption], not: [flagged] } }, (row, { commands }) => { if (row[uid] !== 1000) commands.add(row.entity, flagged); }); -export const unflagCrowd = System.create({ name: "threadSystemsTest/unflagCrowd", components: { has: [caption, flagged] } }, (row, { commands }) => { +export const unflagCrowd = System.create({ components: { has: [caption, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); /** On the thread: writes uid 1000's caption in place. */ -export const write = System.create({ name: "threadSystemsTest/write", components: { read: [uid], write: [caption] } }, row => { +export const write = System.create({ components: { read: [uid], write: [caption] } }, row => { if (row[uid] === 1000) row[caption] = { text: "written" }; }); /** On the thread: counts captions it holds that the thread wrote, into a struct resource. */ -export const countWritten = System.create({ name: "threadSystemsTest/countWritten", components: { read: [caption] }, resources: { write: [changes] } }, (row, { resources }) => { +export const countWritten = System.create({ components: { read: [caption] }, resources: { write: [changes] } }, (row, { resources }) => { if (row[caption]?.text === "written") resources[changes] += 1; }); /** On the thread: gives an object of the world's thread a value, which can't cross, so its command drops and it fails. */ -export const giveKept = System.create({ name: "threadSystemsTest/giveKept", components: { read: [caption] } }, (row, { commands }) => { +export const giveKept = System.create({ components: { read: [caption] } }, (row, { commands }) => { if (row[caption] !== undefined) commands.add(row.entity, kept, "given there"); }); /** On the thread: spawns giving an object of the world's thread a value, which throws when it's issued. */ -export const spawnKept = System.create({ name: "threadSystemsTest/spawnKept", resources: { read: [paint] } }, ({ resources, commands }) => { +export const spawnKept = System.create({ resources: { read: [paint] } }, ({ resources, commands }) => { commands.spawn([uid, kept])({ [uid]: resources[paint].text.length, [kept]: "spawned there" }); }); /** On the thread: runs until its thread ends, so its task never completes. */ -export const stall = System.create({ name: "threadSystemsTest/stall", resources: { read: [paint] } }, ({ resources }) => { +export const stall = System.create({ resources: { read: [paint] } }, ({ resources }) => { while (resources[paint].text !== "never") { /* until the thread ends */ } }); /** On the world's thread: counts its runs, ticking with nothing on the thread. */ -export const count = System.create({ name: "threadSystemsTest/count", resources: { write: [changes] } }, ({ resources }) => { +export const count = System.create({ resources: { write: [changes] } }, ({ resources }) => { resources[changes] += 1; }); /** @@ -295,12 +294,12 @@ const setQueued = (task: () => void): void => { ranHere.atDue = -1; }; /** On the thread: counts its runs, so the world's ticks post it a task each. */ -export const countThere = System.create({ name: "threadSystemsTest/countThere", resources: { read: [paint], write: [changes] } }, ({ resources }) => { +export const countThere = System.create({ resources: { read: [paint], write: [changes] } }, ({ resources }) => { if (resources[paint].text !== "never") resources[changes] += 1; }); /** On the thread: counts its runs there in `ranHere`; it reaches its commands, so it blocks its tier, a task a tick. */ -export const counted = System.create({ name: "threadSystemsTest/counted", resources: { read: [paint] } }, ({ resources, commands }) => { +export const counted = System.create({ resources: { read: [paint] } }, ({ resources, commands }) => { void resources[paint]; void commands; ranHere.runs++; diff --git a/packages/ecs/src/implementation/world/thread-systems.test.ts b/packages/ecs/src/implementation/world/thread-systems.test.ts index 96a7c547..afd10d2e 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test.ts @@ -46,7 +46,7 @@ describe("systems on a joined thread", () => { expect(actual.reports).toEqual(expected.reports); expect(expected.warnings.every(names => names.length === 0)).toBe(true); // Every tick waits for the joined thread's tiers. - expect(actual.warnings[1]).toEqual(["threadSystemsTest/describe", "threadSystemsTest/relabel", "threadSystemsTest/breed", "threadSystemsTest/tally", "threadSystemsTest/watchCaptions", "threadSystemsTest/repaint", "threadSystemsTest/peek"]); + expect(actual.warnings[1]).toEqual(["describe", "relabel", "breed", "tally", "watchCaptions", "repaint", "peek"]); await remote.dispose(); await world.dispose(); }); @@ -65,10 +65,10 @@ describe("systems on a joined thread", () => { const { world, remote } = await joined(); await world.run(crowd, { count: 1 }); const given = await runReported(world, giveKept); - expect(given.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/giveKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(given.failures.map(failure => [failure.name, failure.message])).toEqual([["giveKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); expect(given.dropped).toBe(1); const spawned = await runReported(world, spawnKept); - expect(spawned.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/spawnKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(spawned.failures.map(failure => [failure.name, failure.message])).toEqual([["spawnKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); expect(await Observe.toPromise(Observe.count(world, { required: [kept] }))).toBe(0); await remote.dispose(); await world.dispose(); @@ -80,8 +80,8 @@ describe("systems on a joined thread", () => { const leaving = remote.dispose(); const report = await runReported(world, write); await leaving; - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["thread", "threadSystemsTest/write", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']]); - expect(report.skipped).toEqual(["threadSystemsTest/write"]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["thread", "write", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']]); + expect(report.skipped).toEqual(["write"]); const [entity] = await Observe.toPromise(Observe.select(world, { required: [uid] })); expect(await runReported(world, write)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); expect(await Observe.toPromise(Observe.entity(world, entity, { required: [caption] }))).toEqual({ [caption]: { text: "written" } }); @@ -111,7 +111,7 @@ describe("systems on a joined thread", () => { return ran; }; const failuresOf = async (ran: number | null) => ran === null ? null : (await reportOf(world, ran)).failures.map(failure => [failure.kind, failure.name, failure.message]); - const failed = [["system", "threadSystemsTest/write", `This thread's memory is ${pages * pageBytes} bytes, short of the ${(pages + 1) * pageBytes} the world stored.`]]; + const failed = [["system", "write", `This thread's memory is ${pages * pageBytes} bytes, short of the ${(pages + 1) * pageBytes} the world stored.`]]; // Read once every run ran, as the thread saw them. const runs = [await short(), await world.run(write), await short(), await world.run(write)]; expect(await Promise.all(runs.map(failuresOf))).toEqual([failed, [], failed, []]); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 41ab5ad5..44c16d1c 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -652,7 +652,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon } }; const ownInput = inputFor(leaf); - const targets: readonly ReductionTarget[] = reductionTargetsOf(host.layoutOf, declaration); + const targets: readonly ReductionTarget[] = reductionTargetsOf(host.layoutOf, leaf.name, declaration); const reduced = targets.map(target => new Float64Array(target.leaves.length)); // The context: resources, lookups, reductions, input and commands, each under its own key when declared. diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index 859015fb..65f67bd0 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -14,8 +14,8 @@ declare module "@adobe/ecs" { } } -const count = System.create({ name: "ticksBrowserTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const start = System.create({ name: "ticksBrowserTest/start", resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); +const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const start = System.create({ resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); describe("a frame cadence", () => { it("ticks on animation frames, with their time", async () => { diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 0910343e..5e6e2c7b 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -12,12 +12,12 @@ declare module "@adobe/ecs" { } } -const count = System.create({ name: "ticksTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); describe("ticks", () => { it("reject a system that conflicts with one added with no order between them, and one with input", async () => { - const other = System.create({ name: "ticksTest/other", resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); - const withInput = System.create({ name: "ticksTest/withInput" }, (_context, input: { readonly by: number }) => {}); + const other = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); + const withInput = System.create({}, (_context, input: { readonly by: number }) => {}); await using world = createWorld({ [workers]: 0 }); world.add(count); expect(() => world.add(other)).toThrow('both access "ticksTest/frames"'); diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts index 5a22f894..30fa31d8 100644 --- a/packages/ecs/src/implementation/world/traits.test.ts +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -24,7 +24,7 @@ declare module "@adobe/ecs" { describe("traits", () => { it("keep derived components out of commands, for untyped callers too", async () => { const errors: string[] = []; - const derived = System.create({ name: "traitsTest/derived" }, ({ commands }) => { + const derived = System.create({}, ({ commands }) => { const entity = commands.spawn([score])({ [score]: 1 }); const attempts = [ // @ts-expect-error derived components are added only by rules @@ -49,8 +49,8 @@ describe("traits", () => { it("leave index 0 to Entity.none, which no lookup finds", async () => { let first = Entity.none; let found: unknown = undefined; - const spawn = System.create({ name: "traitsTest/spawn" }, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); - const lookup = System.create({ name: "traitsTest/lookup", lookup: { scores: { read: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); + const spawn = System.create({}, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); + const lookup = System.create({ lookup: { scores: { read: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(lookup); diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts index a1363f71..752d6bb8 100644 --- a/packages/ecs/src/implementation/world/transfers.test-helper.ts +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -49,21 +49,21 @@ const store = (value: unknown, resources: { [order]: number }): void => { }; /** On "transfersTest": records the buffer it's given. */ -export const storeThere = System.create({ name: "transfersTest/storeThere", resources: { read: [anchor], write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { +export const storeThere = System.create({ resources: { read: [anchor], write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { if (resources[anchor].text === "anchor") store(input.buffer, resources); }); /** On the world's thread: records the buffer it's given. */ -export const storeHere = System.create({ name: "transfersTest/storeHere", resources: { write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { +export const storeHere = System.create({ resources: { write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { store(input.buffer, resources); }); /** On the world's thread: what a run from a joined thread saw of the order, by its value. */ export const marks: { readonly value: number; readonly order: number }[] = []; -export const mark = System.create({ name: "transfersTest/mark", resources: { write: [order] } }, ({ resources }, input: { readonly value: number }) => { +export const mark = System.create({ resources: { write: [order] } }, ({ resources }, input: { readonly value: number }) => { marks.push({ value: input.value, order: resources[order]++ }); }); /** On "transfersTest": gives an entity the port or bitmap it's given. */ export const attachThere = System.create({ - name: "transfersTest/attachThere", resources: { read: [anchor] }, + resources: { read: [anchor] }, }, ({ resources, commands }, input: { readonly entity: Entity; readonly value: unknown }) => { if (resources[anchor].text !== "anchor") return; kept.push(input.value); @@ -71,18 +71,18 @@ export const attachThere = System.create({ else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmap, input.value); }); /** On the world's thread: gives an entity the port or bitmap it's given. */ -export const attachHere = System.create({ name: "transfersTest/attachHere" }, ({ commands }, input: { readonly entity: Entity; readonly value: unknown }) => { +export const attachHere = System.create({}, ({ commands }, input: { readonly entity: Entity; readonly value: unknown }) => { kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, portHere, input.value); else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmapHere, input.value); }); /** On "transfersTest": says hello through the port it's given, which only moving it there, not copying it, gives. */ -export const greetThere = System.create({ name: "transfersTest/greetThere", resources: { read: [anchor] } }, ({ resources }, input: { readonly port: unknown }) => { +export const greetThere = System.create({ resources: { read: [anchor] } }, ({ resources }, input: { readonly port: unknown }) => { if (input.port instanceof MessagePort) input.port.postMessage(resources[anchor].text); }); /** On the world's thread: spawns an entity holding the port it's given. */ -export const spawnHere = System.create({ name: "transfersTest/spawnHere" }, ({ commands }, input: { readonly value: unknown }) => { +export const spawnHere = System.create({}, ({ commands }, input: { readonly value: unknown }) => { if (input.value instanceof MessagePort) commands.spawn([portHere])({ [portHere]: input.value }); }); @@ -91,13 +91,13 @@ export const spawnHere = System.create({ name: "transfersTest/spawnHere" }, ({ c * "transfersTest", it fails to register, so a run of it never runs. */ export const unregistered = System.create({ - name: "transfersTest/unregistered", components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, + components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, }, (row, { up }, input: { readonly value: unknown }) => { row[depth] = (up === null ? 0 : up[depth]) + (row[port] === input.value ? 1 : 0); }); /** On the world's thread: nothing, a run of its own. */ -export const idle = System.create({ name: "transfersTest/idle" }, () => {}); +export const idle = System.create({}, () => {}); /** Every system the joined threads run or place, which they import so the world can place them there. */ export const transferSystems: readonly { readonly name: string }[] = [storeThere, storeHere, mark, attachThere, attachHere, spawnHere, greetThere, unregistered]; diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts index 351c163c..643ca8e8 100644 --- a/packages/ecs/src/implementation/world/up-tree.test.ts +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -25,7 +25,6 @@ declare module "@adobe/ecs" { const propagateFull = System.create({ - name: "upTreeTest/propagateFull", kernel: "cpu", components: { read: [local], write: [full], not: [hidden] }, up: { through: parent, read: [full] }, @@ -33,7 +32,6 @@ const propagateFull = System.create({ row[full] = (up === null ? 0 : up[full]) + row[local]; }); const propagateFiltered = System.create({ - name: "upTreeTest/propagateFiltered", kernel: "cpu", components: { read: [local], write: [filtered], changed: [local], not: [hidden] }, up: { through: parent, read: [filtered] }, @@ -47,7 +45,7 @@ type Operation = | { readonly kind: "reparent"; readonly entity: Entity; readonly to: Entity } | { readonly kind: "set"; readonly entity: Entity; readonly local: number }; const operations: Operation[] = []; -const apply = System.create({ name: "upTreeTest/apply" }, ({ commands }) => { +const apply = System.create({}, ({ commands }) => { for (const operation of operations.splice(0)) { switch (operation.kind) { case "spawn": diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts index ab80305b..f76d5f44 100644 --- a/packages/ecs/src/implementation/world/up-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -22,7 +22,6 @@ declare module "@adobe/ecs" { const propagate = System.create({ - name: "upWorkersTest/propagate", kernel: "cpu", components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, @@ -30,7 +29,7 @@ const propagate = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local] * 2; }); /** 100 trees, each a root with 40 levels of two children, ordered so storage order isn't depth order. */ -const grow = System.create({ name: "upWorkersTest/grow" }, ({ commands }) => { +const grow = System.create({}, ({ commands }) => { for (let tree = 0; tree < 100; tree++) { let level = [commands.spawn([local, world])({ [local]: tree, [world]: 0 })]; for (let depth = 1; depth < 40; depth++) { @@ -39,7 +38,7 @@ const grow = System.create({ name: "upWorkersTest/grow" }, ({ commands }) => { } } }); -const nudge = System.create({ name: "upWorkersTest/nudge", components: { write: [local] } }, row => { +const nudge = System.create({ components: { write: [local] } }, row => { if (row[local] === 7) row[local] = 70; }); diff --git a/packages/ecs/src/implementation/world/up.browser.test.ts b/packages/ecs/src/implementation/world/up.browser.test.ts index edc5e765..51ada68b 100644 --- a/packages/ecs/src/implementation/world/up.browser.test.ts +++ b/packages/ecs/src/implementation/world/up.browser.test.ts @@ -25,7 +25,6 @@ import { workers } from "../../interface/resources/workers.js"; import type { F32 } from "../../interface/math/index.js"; const place = System.create({ - name: "upBrowserTest/place", kernel: "gpu", components: { read: [offset], write: [placed] }, up: { through: parent, read: [placed] }, @@ -33,7 +32,7 @@ const place = System.create({ row[placed] = up === null ? row[offset] : up[placed] + row[offset]; }); /** Trees whose children are in two archetypes, so a depth's rows have parents in several. */ -const plant = System.create({ name: "upBrowserTest/plant" }, ({ commands }) => { +const plant = System.create({}, ({ commands }) => { for (let tree = 0; tree < 50; tree++) { let level = [commands.spawn([offset, placed])({ [offset]: tree, [placed]: 0 })]; for (let depth = 1; depth < 12; depth++) { diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index d15e933b..e5c98702 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -33,7 +33,6 @@ declare module "@adobe/ecs" { const ran: string[] = []; const propagate = System.create({ - name: "upTest/propagate", components: { read: [local, label], write: [world] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -41,7 +40,6 @@ const propagate = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChanged = System.create({ - name: "upTest/propagateChanged", components: { read: [local, label], write: [world], changed: [local] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -49,7 +47,6 @@ const propagateChanged = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateAdded = System.create({ - name: "upTest/propagateAdded", components: { read: [local, label], write: [world], added: [local] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -57,7 +54,6 @@ const propagateAdded = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChangedKernel = System.create({ - name: "upTest/propagateChangedKernel", kernel: "cpu", components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, @@ -65,7 +61,6 @@ const propagateChangedKernel = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateKernel = System.create({ - name: "upTest/propagateKernel", kernel: "cpu", components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, @@ -75,27 +70,27 @@ const propagateKernel = System.create({ /** Spawns a root with two trees under it, the grandchild stored before its parent so storage order isn't depth order. */ const entities: { [name: string]: Entity } = {}; -const spawn = System.create({ name: "upTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { entities.root = commands.spawn([label, local, world])({ [label]: "root", [local]: 1, [world]: 0 }); entities.grandchild = commands.spawn([label, local, world, parent])({ [label]: "grandchild", [local]: 100, [world]: 0, [parent]: entities.root }); entities.child = commands.spawn([label, local, world, parent])({ [label]: "child", [local]: 10, [world]: 0, [parent]: entities.root }); entities.other = commands.spawn([label, local, world])({ [label]: "other", [local]: 1000, [world]: 0 }); }); -const nest = System.create({ name: "upTest/nest" }, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); -const bump = System.create({ name: "upTest/bump" }, ({ commands }, input: { readonly entity: Entity }) => { +const nest = System.create({}, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); +const bump = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, local, 2); }); -const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { +const loop = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); commands.spawn([label, local, world, parent])({ [label]: "under", [local]: 5, [world]: 0, [parent]: entities.child }); }); /** Puts the root under the grandchild, two levels below it, then under `other`. */ -const close = System.create({ name: "upTest/close" }, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); -const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); -const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); +const close = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); +const open = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); +const orphan = System.create({}, ({ commands }) => { commands.remove(entities.grandchild, parent); }); /** Spawns `count` leaves under the grandchild, more than the journal keeps for a world this small. */ const bulk: Entity[] = []; -const bulkUnder = System.create({ name: "upTest/bulkUnder" }, ({ commands }, input: { readonly count: number }) => { +const bulkUnder = System.create({}, ({ commands }, input: { readonly count: number }) => { bulk.length = 0; for (let i = 0; i < input.count; i++) bulk.push(commands.spawn([label, local, world, parent])({ [label]: "bulk", [local]: 1, [world]: 0, [parent]: entities.grandchild })); }); @@ -104,7 +99,6 @@ const worlds = async (value: (entity: Entity) => Promise): Promis Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); const show = System.create({ - name: "upTest/show", kernel: "cpu", components: { read: [visible], write: [shown] }, up: { through: parent, read: [shown] }, @@ -114,7 +108,7 @@ const show = System.create({ /** Children of one archetype whose parents are in three: a matched one, one the query doesn't match, and one without `world`. */ const mixed: { [name: string]: Entity } = {}; -const spawnMixed = System.create({ name: "upTest/spawnMixed" }, ({ commands }) => { +const spawnMixed = System.create({}, ({ commands }) => { mixed.root = commands.spawn([local, world])({ [local]: 1, [world]: 0 }); mixed.anchored = commands.spawn([local, world, anchor])({ [local]: 2, [world]: 0, [anchor]: true }); mixed.unmatched = commands.spawn([world])({ [world]: 50 }); @@ -124,10 +118,10 @@ const spawnMixed = System.create({ name: "upTest/spawnMixed" }, ({ commands }) = mixed.underAnchored = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.anchored }); mixed.underBare = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.bare }); }); -const growParents = System.create({ name: "upTest/growParents" }, ({ commands }) => { +const growParents = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([local, world, anchor])({ [local]: 0, [world]: 0, [anchor]: true }); }); -const shift = System.create({ name: "upTest/shift", components: { write: [local], has: [anchor] } }, row => { row[local] += 1; }); +const shift = System.create({ components: { write: [local], has: [anchor] } }, row => { row[local] += 1; }); describe("up", () => { for (const [kind, system] of [["main-thread", propagate], ["kernel", propagateKernel]] as const) { @@ -262,7 +256,7 @@ describe("up", () => { await using ecs = createWorld({ [workers]: 0 }); await ecs.run(spawnMixed); await ecs.run(propagateKernel); - await ecs.run(System.create({ name: "upTest/moveUnmatched" }, ({ commands }) => { commands.add(mixed.unmatched, [anchor, world], { [anchor]: true, [world]: 80 }); })); + await ecs.run(System.create({}, ({ commands }) => { commands.add(mixed.unmatched, [anchor, world], { [anchor]: true, [world]: 80 }); })); await ecs.run(propagateKernel); expect(await read(ecs, mixed.underUnmatched, world)).toBe(90); }); @@ -270,7 +264,7 @@ describe("up", () => { it("reads booleans through up", async () => { await using ecs = createWorld({ [workers]: 0 }); const tree: Entity[] = []; - await ecs.run(System.create({ name: "upTest/spawnShown" }, ({ commands }) => { + await ecs.run(System.create({}, ({ commands }) => { const top = commands.spawn([visible, shown])({ [visible]: false, [shown]: true }); tree.push(top, commands.spawn([visible, shown, parent])({ [visible]: true, [shown]: true, [parent]: top })); const other = commands.spawn([visible, shown])({ [visible]: true, [shown]: false }); @@ -299,6 +293,6 @@ describe("up", () => { it("rejects a through that isn't a reference, for untyped declarations", async () => { // @ts-expect-error through is a reference component - expect(() => System.create({ name: "upTest/untyped", components: { write: [world] }, up: { through: local, read: [world] } }, row => { row[world] = 0; })).toThrow('System "upTest/untyped": up.through "upTest/local" isn\'t a reference component.'); + expect(() => System.create({ components: { write: [world] }, up: { through: local, read: [world] } }, row => { row[world] = 0; })).toThrow('up.through "upTest/local" isn\'t a reference component.'); }); }); diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 214fb44b..61765631 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -43,7 +43,7 @@ declare module "@adobe/ecs" { const secondsStart = 1 / 60; -const seed = System.create({ name: "wasmTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = Entity.none; for (let i = 0; i < input.count; i++) { previous = commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: [i * 0.37, (i % 13) * 0.71], [velocity]: [Math.sin(i) * 3, Math.cos(i) * 5], [bounces]: 0, [flipped]: false, [score]: 0, [target]: previous }); @@ -51,7 +51,7 @@ const seed = System.create({ name: "wasmTest/seed" }, ({ commands }, input: { re }); /** Despawns every seventh entity and spawns replacements, so some targets go stale and their indexes are reused. */ -const cull = System.create({ name: "wasmTest/cull", components: { read: [position], has: [bounces] } }, (row, { commands }, input: { readonly frame: number }) => { +const cull = System.create({ components: { read: [position], has: [bounces] } }, (row, { commands }, input: { readonly frame: number }) => { if ((row.entity >>> 0) % 7 !== input.frame % 7) return; commands.despawn(row.entity); commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: row[position], [velocity]: [1, 1], [bounces]: 0, [flipped]: true, [score]: 0, [target]: row.entity }); @@ -60,7 +60,6 @@ const cull = System.create({ name: "wasmTest/cull", components: { read: [positio const halve = (value: number) => value * 0.5; const integrate = System.create({ - name: "wasmTest/integrate", kernel: "cpu", components: { write: [position, velocity] }, resources: { read: [seconds] }, @@ -73,7 +72,6 @@ const integrate = System.create({ }); const bounce = System.create({ - name: "wasmTest/bounce", kernel: "cpu", components: { write: [position, velocity, bounces, flipped] }, resources: { read: [floor] }, @@ -93,7 +91,6 @@ const bounce = System.create({ }); const tally = System.create({ - name: "wasmTest/tally", kernel: "cpu", components: { read: [bounces, flipped, position], write: [score] }, after: [bounce], @@ -109,7 +106,6 @@ const tally = System.create({ }); const scale = System.create({ - name: "wasmTest/scale", kernel: "cpu", components: { write: [score] }, }, (row, _context, input: { readonly factor: number }) => { @@ -118,7 +114,6 @@ const scale = System.create({ /** Component-wise vector math: one f32x4 operation per row. */ const drift = System.create({ - name: "wasmTest/drift", kernel: "cpu", components: { read: [velocity], write: [position] }, resources: { read: [seconds] }, @@ -132,7 +127,6 @@ const drift = System.create({ /** Runs only for rows whose bounces changed since it last ran. */ const react = System.create({ - name: "wasmTest/react", kernel: "cpu", components: { write: [score], changed: [bounces] }, after: [tally], @@ -142,7 +136,6 @@ const react = System.create({ /** A lookup per row: found targets pull, missing or stale ones cost a point. */ const chase = System.create({ - name: "wasmTest/chase", kernel: "cpu", components: { read: [target], write: [score] }, lookup: { targets: { read: [position, flipped] } }, @@ -157,7 +150,7 @@ const chase = System.create({ row[score] = row[score] + (other[flipped] ? x : y); }); -const main = System.create({ name: "wasmTest/main", systems: [integrate, bounce, tally, drift, react, chase] }); +const main = System.create({ systems: [integrate, bounce, tally, drift, react, chase] }); const snapshotted = [position, velocity, bounces, flipped, score] as const; @@ -183,21 +176,21 @@ const simulate = async (wasm: boolean, simd = false): Promise => { }; /** Counts the runs of rows whose score changed. */ -const recount = System.create({ name: "wasmTest/recount", kernel: "cpu", components: { write: [bounces], changed: [score] } }, row => { +const recount = System.create({ kernel: "cpu", components: { write: [bounces], changed: [score] } }, row => { row[bounces] += 1; }); const chosen = new Set(); -const rescore = System.create({ name: "wasmTest/rescore", components: { write: [score] } }, row => { +const rescore = System.create({ components: { write: [score] } }, row => { if (chosen.has(row.entity)) row[score] += 1; }); /** Keeps ticks of `score` without observing its changes, so kernels writing it mute their stamps. */ -const scoreAdded = System.create({ name: "wasmTest/scoreAdded", components: { added: [score] } }, () => {}); -const raise = System.create({ name: "wasmTest/raise", kernel: "cpu", components: { write: [score] } }, row => { +const scoreAdded = System.create({ components: { added: [score] } }, () => {}); +const raise = System.create({ kernel: "cpu", components: { write: [score] } }, row => { row[score] += 1; }); let raised = 0; -const countRaised = System.create({ name: "wasmTest/countRaised", components: { changed: [score] } }, () => { raised++; }); +const countRaised = System.create({ components: { changed: [score] } }, () => { raised++; }); describe("WASM kernels", () => { it("stamp what they write once a change filter observes a component that already kept ticks", async () => { diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index ca3a48f8..8a76370a 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -56,7 +56,7 @@ declare module "@adobe/ecs" { /** 64 entities in each of the 512 archetypes the tags combine into. */ -const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => { +const scatter = System.create({}, ({ commands }) => { for (let i = 0; i < 512 * 64; i++) { const components: (typeof position | typeof velocity | (typeof tags)[number])[] = [position, velocity]; tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); @@ -65,7 +65,7 @@ const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => }); /** Spawns `count` moving entities, each following the one before it. */ -const seed = System.create({ name: "workersTest/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn([position, velocity])({ [position]: [0, 0], [velocity]: [1, 0] }); for (let i = 1; i < input.count; i++) { previous = commands.spawn([position, velocity, target])({ [position]: [i, 0], [velocity]: [1, i % 7], [target]: previous }); @@ -73,7 +73,6 @@ const seed = System.create({ name: "workersTest/seed" }, ({ commands }, input: { }); const integrate = System.create({ - name: "workersTest/integrate", kernel: "cpu", components: { read: [velocity], write: [position] }, resources: { read: [seconds] }, @@ -85,7 +84,6 @@ const integrate = System.create({ /** Structural commands from workers: rows past a threshold spawn a marker and lose their velocity. */ const split = System.create({ - name: "workersTest/split", kernel: "cpu", components: { read: [position], has: [velocity] }, after: [integrate], @@ -99,7 +97,6 @@ const split = System.create({ /** Lookups from workers: offset by the distance to the target. */ const follow = System.create({ - name: "workersTest/follow", kernel: "cpu", components: { read: [position, target], write: [offset] }, lookup: { targets: { read: [position] } }, @@ -110,11 +107,10 @@ const follow = System.create({ row[offset] = other === null ? -1 : x - other[position][0]; }, ); -const main = System.create({ name: "workersTest/main", systems: [integrate, split, follow] }); +const main = System.create({ systems: [integrate, split, follow] }); /** Every row writes the same entity, so only merge order decides which value survives. */ const stamp = System.create({ - name: "workersTest/stamp", kernel: "cpu", components: { read: [position] }, }, (row, { commands }, input: { readonly entity: Entity }) => { @@ -124,7 +120,6 @@ const stamp = System.create({ /** Despawns entity 1, the resource entity, since index 0 is never an entity and registration reserves the next first. */ const unlink = System.create({ - name: "workersTest/unlink", kernel: "cpu", components: { has: [target] }, }, (_, { commands }) => { @@ -133,7 +128,7 @@ const unlink = System.create({ }); /** Archetypes in this order: 5 rows the main thread runs, 1,000 that workers run, then 10 more on the main thread. */ -const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => { +const sources = System.create({}, ({ commands }) => { for (let i = 0; i < 5; i++) commands.spawn([offset, tagD])({ [offset]: i }); for (let i = 5; i < 1_005; i++) commands.spawn([offset, tagA])({ [offset]: i }); for (let i = 1_005; i < 1_015; i++) commands.spawn([offset, tagB])({ [offset]: i }); @@ -141,7 +136,6 @@ const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => /** A spawn per row, from wherever its chunk runs; with `edit`, each spawn is also added to and removed from. */ const emit = System.create({ - name: "workersTest/emit", kernel: "cpu", components: { read: [offset], not: [spawned] }, }, (row, { commands }, input: { readonly edit: boolean }) => { @@ -153,11 +147,11 @@ const emit = System.create({ }); const emitted = { seen: 0 }; -const countEmitted = System.create({ name: "workersTest/countEmitted", components: { added: [position], has: [spawned] } }, () => { emitted.seen++; }); +const countEmitted = System.create({ components: { added: [position], has: [spawned] } }, () => { emitted.seen++; }); const gathered: Entity[] = []; -const gatherPositioned = System.create({ name: "workersTest/gatherPositioned", observer: true, components: { has: [position] } }, row => { gathered.push(row.entity); }); -const gatherSpawned = System.create({ name: "workersTest/gatherSpawned", observer: true, components: { has: [spawned] } }, row => { gathered.push(row.entity); }); +const gatherPositioned = System.create({ observer: true, components: { has: [position] } }, row => { gathered.push(row.entity); }); +const gatherSpawned = System.create({ observer: true, components: { has: [spawned] } }, row => { gathered.push(row.entity); }); /** The entities an observer row system passes, in storage order. */ const storageOrder = async (world: World, gather: System): Promise => { diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts index 395e60bf..39961565 100644 --- a/packages/ecs/src/interface/archetype/archetype.type-test.ts +++ b/packages/ecs/src/interface/archetype/archetype.type-test.ts @@ -25,11 +25,11 @@ expectType>( export const one: readonly Component.Key[] = particle; // A plain readonly list of components is an archetype too. export const plain = [position] as const; -export const moveOver = System.create({ name: "archetypeTypeTest/moveOver", components: { write: plain } }, row => { row[position] += 1; }); +export const moveOver = System.create({ components: { write: plain } }, row => { row[position] += 1; }); // @ts-expect-error a resource is never in an archetype Archetype.create([position, scale]); // @ts-expect-error nor is a string Archetype.create(["position"]); // Archetypes go wherever component lists do. -export const move = System.create({ name: "archetypeTypeTest/move", components: { write: particle } }, row => { row[position] += row[velocity]; }); -export const read = System.create({ name: "archetypeTypeTest/read", observer: true, components: { read: particle } }, row => { void row[velocity]; }); +export const move = System.create({ components: { write: particle } }, row => { row[position] += row[velocity]; }); +export const read = System.create({ observer: true, components: { read: particle } }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index baf7d825..b6ac59ff 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -62,7 +62,7 @@ describe("registering a component", () => { }); it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { expect(() => register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); - expect(() => register(Symbol.for("typesComponentTest/shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); + expect(() => register(Symbol.for("shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); expect(() => register(Symbol.asyncIterator, { type: { const: true } })).toThrow("Symbol.asyncIterator is a well-known symbol."); expect(kindOf("derived")).toBe(undefined); }); diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index b26b4fbd..1c0b5c4b 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { identify } from "../registry/ids.test-helper.js"; -import { labelOfItem, programFinalizers, programRules } from "../registry/index.js"; +import { programFinalizers, programRules } from "../registry/index.js"; import { Rule } from "../rule/rule.js"; import { clock } from "../resources/index.js"; import { Finalizer } from "./finalizer.js"; @@ -38,101 +38,101 @@ const untyped = (constructor: (...parameters: never[]) => unknown, declaration: describe("Finalizer.create and Finalizer.changes", () => { it("record finalizers in creation order, keep the place of one declared again under its id with its new body, and reject its id with another declaration", () => { - const release = Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), () => {}); - const save = Finalizer.changes({ name: "typesFinalizerTest/save", watch: [label] }, () => {}); + const release = Finalizer.create(identify({ components: { read: [node] } }, "f.ts#release"), () => {}); + const save = Finalizer.changes({ watch: [label] }, () => {}); expect(programFinalizers().map(finalizer => finalizer.name)).toEqual([release.name, save.name]); const body = (): void => {}; - expect(Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), body)).toBe(release); + expect(Finalizer.create(identify({ components: { read: [node] } }, "f.ts#release"), body)).toBe(release); expect(programFinalizers()[0]?.kind === "finalizer" ? programFinalizers()[0]?.body : null).toBe(body); - expect(() => Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); - expect(() => Finalizer.changes(identify({ name: "typesFinalizerTest/release" }, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); + expect(() => Finalizer.create(identify({ components: { read: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.changes(identify({}, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); expect(programFinalizers()).toHaveLength(2); }); - it("keep finalizers of one name apart, labeled apart", () => { - const first = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); - const second = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); + it("keep finalizers of one label apart", () => { + const twin = (): Finalizer => Finalizer.changes({ watch: [label] }, () => {}); + const [first, second] = [twin(), twin()]; expect(second).not.toBe(first); - expect([first, second].map(labelOfItem)).toEqual(["typesFinalizerTest/twin", "typesFinalizerTest/twin#2"]); - const third = Finalizer.changes({ name: "typesFinalizerTest/ordered", watch: [label], after: [second], before: [first] }, () => {}); + expect([first.name, second.name]).toEqual(["twin", "twin"]); + const third = Finalizer.changes({ watch: [label], after: [second], before: [first] }, () => {}); expect(programFinalizers().slice(-3)).toEqual([second, third, first]); }); it("order finalizers by before and after, and reject a cycle or a resource conflict with no order", () => { - const late = Finalizer.create({ name: "typesFinalizerTest/late", components: { read: [label] }, resources: { read: [time] } }, () => {}); - const early = Finalizer.create({ name: "typesFinalizerTest/early", components: { read: [label] }, resources: { write: [time] }, before: [late] }, () => {}); + const late = Finalizer.create({ components: { read: [label] }, resources: { read: [time] } }, () => {}); + const early = Finalizer.create({ components: { read: [label] }, resources: { write: [time] }, before: [late] }, () => {}); expect(programFinalizers().map(finalizer => finalizer.name).filter(name => name === early.name || name === late.name)).toEqual([early.name, late.name]); - expect(() => Finalizer.create({ name: "typesFinalizerTest/conflict", components: { read: [label] }, resources: { read: [time] } }, () => {})).toThrow('both access "typesFinalizerTest/time", one writing it'); - expect(() => Finalizer.create({ name: "typesFinalizerTest/cycle", components: { read: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/clock", components: {}, resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(() => Finalizer.create({ components: { read: [label] }, resources: { read: [time] } }, () => {})).toThrow('both access "typesFinalizerTest/time", one writing it'); + expect(() => Finalizer.create({ components: { read: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); + expect(() => untyped(Finalizer.create, { name: "clock", components: {}, resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); expect(programFinalizers()).toHaveLength(7); }); it("reject what types reject, for untyped callers", () => { - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/both", components: {}, resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/reduced", components: {}, resources: { write: [total] } })).toThrow("which is reduced"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/span", components: { read: [node, tags] } })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/derived", watch: [slot] })).toThrow("which is derived"); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/resource", watch: [time] })).toThrow("which isn't a registered component"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/hasResource", components: { has: [time] } })).toThrow(`Finalizer "typesFinalizerTest/hasResource" uses component "typesFinalizerTest/time", which isn't a registered component`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/lookupNot", components: { read: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { name: "both", components: {}, resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); + expect(() => untyped(Finalizer.create, { name: "reduced", components: {}, resources: { write: [total] } })).toThrow("which is reduced"); + expect(() => untyped(Finalizer.create, { name: "span", components: { read: [node, tags] } })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); + expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived"); + expect(() => untyped(Finalizer.changes, { name: "resource", watch: [time] })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { name: "hasResource", components: { has: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`); + expect(() => untyped(Finalizer.create, { name: "lookupNot", components: { read: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } })).toThrow("which isn't a registered component"); expect(programFinalizers()).toHaveLength(7); }); it("reject resource writes and lookups in a finalizer on another thread than the world's, and a changes finalizer watching or reading another thread's values", () => { - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachWriting", components: { read: [node] }, resources: { write: [time] } })) - .toThrow(`Finalizer "typesFinalizerTest/detachWriting" runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/pageWriting", components: { read: [tags] }, resources: { write: [page] } })) + expect(() => untyped(Finalizer.create, { name: "detachWriting", components: { read: [node] }, resources: { write: [time] } })) + .toThrow(`runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); + expect(() => untyped(Finalizer.create, { name: "pageWriting", components: { read: [tags] }, resources: { write: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachLooking", components: { read: [node] }, lookup: { labels: { read: [mark] } } })) - .toThrow(`Finalizer "typesFinalizerTest/detachLooking" runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveNodes", watch: [node] })) - .toThrow(`Finalizer "typesFinalizerTest/saveNodes" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePage", watch: [tags], resources: { read: [page] } })) + expect(() => untyped(Finalizer.create, { name: "detachLooking", components: { read: [node] }, lookup: { labels: { read: [mark] } } })) + .toThrow(`runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); + expect(() => untyped(Finalizer.changes, { name: "saveNodes", watch: [node] })) + .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + expect(() => untyped(Finalizer.changes, { name: "savePage", watch: [tags], resources: { read: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePageOnly", resources: { read: [page] } })) - .toThrow(`Finalizer "typesFinalizerTest/savePageOnly" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + expect(() => untyped(Finalizer.changes, { name: "savePageOnly", resources: { read: [page] } })) + .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); // Reading another thread's values, without writing or looking up, it's created. - const detach = Finalizer.create({ name: "typesFinalizerTest/detach", components: { read: [node] }, resources: { read: [page] } }, () => {}); + const detach = Finalizer.create({ components: { read: [node] }, resources: { read: [page] } }, () => {}); expect(programFinalizers()).toHaveLength(8); // Finalizers on different threads run at different times, so neither is ordered before the other. - const late = programFinalizers().find(finalizer => finalizer.name === "typesFinalizerTest/late"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/afterDetach", components: { read: [label] }, after: [detach] })) - .toThrow(`Finalizer "typesFinalizerTest/afterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/beforeLate", components: { read: [node] }, before: [late] })) - .toThrow(`Finalizer "typesFinalizerTest/beforeLate" runs on thread "main" and is ordered with "typesFinalizerTest/late", which runs on thread "world"`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveAfterDetach", watch: [label], after: [detach] })) - .toThrow(`Finalizer "typesFinalizerTest/saveAfterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach"`); - Finalizer.create({ name: "typesFinalizerTest/detachAgain", components: { read: [node] }, after: [detach] }, () => {}); + const late = programFinalizers().find(finalizer => finalizer.name === "late"); + expect(() => untyped(Finalizer.create, { name: "afterDetach", components: { read: [label] }, after: [detach] })) + .toThrow(`runs on thread "world" and is ordered with "detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); + expect(() => untyped(Finalizer.create, { name: "beforeLate", components: { read: [node] }, before: [late] })) + .toThrow(`runs on thread "main" and is ordered with "late", which runs on thread "world"`); + expect(() => untyped(Finalizer.changes, { name: "saveAfterDetach", watch: [label], after: [detach] })) + .toThrow(`runs on thread "world" and is ordered with "detach"`); + Finalizer.create({ components: { read: [node] }, after: [detach] }, () => {}); expect(programFinalizers()).toHaveLength(9); }); }); describe("Rule.create", () => { it("records a rule and returns it, the one already recorded when declared again under its id, and rejects its id with another declaration", () => { - const slots = Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots")); + const slots = Rule.create(identify({ components: { has: [label] }, derive: [slot] }, "r.ts#slots")); expect(programRules()).toEqual([slots]); - expect(Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); - expect(() => Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); - const twin = Rule.create({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }); + expect(Rule.create(identify({ components: { has: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); + expect(() => Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); + const twin = Rule.create({ components: { has: [node] }, derive: [slot] }); expect(programRules()).toEqual([slots, twin]); - expect([slots, twin].map(labelOfItem)).toEqual(["typesFinalizerTest/slots (r.ts#slots)", "typesFinalizerTest/slots#2"]); + expect([slots, twin].map(rule => rule.name)).toEqual(["slots", "twin"]); }); it("rejects rules that derive in a cycle through a negation", () => { - Rule.create({ name: "typesFinalizerTest/marks", components: { not: [mark] }, derive: [other] }); - expect(() => Rule.create({ name: "typesFinalizerTest/others", components: { has: [other] }, derive: [mark] })).toThrow("cycle through a negation"); + Rule.create({ components: { not: [mark] }, derive: [other] }); + expect(() => Rule.create({ components: { has: [other] }, derive: [mark] })).toThrow("cycle through a negation"); expect(programRules()).toHaveLength(3); }); it("rejects a rule deriving a component that isn't derived, or matching on a resource", () => { const create = (declaration: object): unknown => Reflect.apply(Rule.create, undefined, [declaration]); - expect(() => create({ name: "typesFinalizerTest/essential", components: {}, derive: [label] })).toThrow("which isn't declared derived"); - expect(() => create({ name: "typesFinalizerTest/onResource", components: { has: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); + expect(() => create({ components: {}, derive: [label] })).toThrow("which isn't declared derived"); + expect(() => create({ components: { has: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); expect(programRules()).toHaveLength(3); }); }); -describe("rules and finalizers without a name", () => { - it("are named by their binding and id, or else by their kind numbered", () => { - const rule = Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "rules.ts#slotted")); - const release = Finalizer.create(identify({ components: { read: [node] } }, "finalizers.ts#release"), () => {}); - expect([rule, release].map(labelOfItem)).toEqual(["slotted (rules.ts#slotted)", "release (finalizers.ts#release)"]); - const labels = [Rule.create({ components: { has: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})].map(labelOfItem); - expect(labels.map(found => found.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); +describe("rules' and finalizers' labels", () => { + it("are the bindings of their ids, or else their kinds numbered", () => { + const [rule, release] = [Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "rules.ts#slotted")), Finalizer.create(identify({ components: { read: [node] } }, "finalizers.ts#release"), () => {})]; + expect([rule?.name, release?.name]).toEqual(["slotted", "release"]); + // Made in an expression no binding names, so the build labels them by nothing. + const [rule2, finalizer2] = [Rule.create({ components: { has: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})]; + expect([rule2, finalizer2].map(item => item.name.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); }); }); diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index 170a79c6..dcef0612 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { declarationOf, kindOf, labelOf } from '../registry/names.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; -import { firstItemOf, labelOfItem, nameOf } from '../registry/items.js'; +import { firstItemOf, nameOf } from '../registry/items.js'; import { addFinalizer, programFinalizers } from '../registry/program.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; @@ -59,7 +59,7 @@ const checkOrder = (name: string, thread: string, declaration: { readonly before if (found === undefined) continue; const theirs = runsOn(found); if (theirs !== thread) { - throw new Error(`Finalizer "${name}" runs on thread "${thread}" and is ordered with "${labelOfItem(found)}", which runs on thread "${theirs}"; finalizers on different threads run at different times, so neither is ordered before the other.`); + throw new Error(`Finalizer "${name}" runs on thread "${thread}" and is ordered with "${found.name}", which runs on thread "${theirs}"; finalizers on different threads run at different times, so neither is ordered before the other.`); } } }; @@ -77,7 +77,7 @@ export const create = ( body: (row: Row, context: Context) => void, ): Finalizer => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "finalizer"); + const name = nameOf(declaration, id, "finalizer"); const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; checkResources(name, declaration.resources); const thread = threadOf(declaration, "Finalizer", name); @@ -108,7 +108,7 @@ export const changes = ( body: (changes: ChangeSet>, context: Context) => void, ): Finalizer => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "finalizer"); + const name = nameOf(declaration, id, "finalizer"); const finalizer: Changes = { kind: "changes", name, declaration, body }; checkResources(name, declaration.resources); requireRegistered("Finalizer", name, "resource", declaration.resources?.read); diff --git a/packages/ecs/src/interface/finalizer/declaration.ts b/packages/ecs/src/interface/finalizer/declaration.ts index ded9fea9..db4158e1 100644 --- a/packages/ecs/src/interface/finalizer/declaration.ts +++ b/packages/ecs/src/interface/finalizer/declaration.ts @@ -3,13 +3,11 @@ import type { Component } from '../component/component.js'; import type { Resource } from '../resource/resource.js'; import type { Lookup } from '../system/access.js'; import type { Finalizer } from './finalizer.js'; -import type { Name } from './name.js'; import type { Essential } from '../component/value.js'; import type { Reduced } from '../resource/reduction.js'; import type { WorldWritten } from '../resource/written.js'; type Shared = { - readonly name?: Name; /** Read-only access to other entities, as they are before the sync point applies. */ readonly lookup?: { readonly [name: string]: Lookup }; /** Writes take the sync point's tick, so each system sees them once. A resource in `write` is also readable. */ diff --git a/packages/ecs/src/interface/finalizer/finalizer.ts b/packages/ecs/src/interface/finalizer/finalizer.ts index 642ca355..da8250bf 100644 --- a/packages/ecs/src/interface/finalizer/finalizer.ts +++ b/packages/ecs/src/interface/finalizer/finalizer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * A finalizer, the value `Finalizer.create` or `Finalizer.changes` returned: identified by that value, labeled by its - * name, and listed in other finalizers' `before` and `after`. + * A finalizer, the value `Finalizer.create` or `Finalizer.changes` returned: identified by that value, labeled by + * `name`, the binding it's declared at, and listed in other finalizers' `before` and `after`. */ export type Finalizer = { readonly name: string }; diff --git a/packages/ecs/src/interface/finalizer/name.ts b/packages/ecs/src/interface/finalizer/name.ts deleted file mode 100644 index ef87e3b5..00000000 --- a/packages/ecs/src/interface/finalizer/name.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/gpu/gpu.type-test.ts b/packages/ecs/src/interface/gpu/gpu.type-test.ts index 63334575..088dcaa2 100644 --- a/packages/ecs/src/interface/gpu/gpu.type-test.ts +++ b/packages/ecs/src/interface/gpu/gpu.type-test.ts @@ -21,7 +21,7 @@ export type Wgsl = Gpu.Wgsl; export type Layout = Gpu.Layout; check) => string>>(); declare const device: GPUDevice; -const draw = System.render({ name: "gpuTypeTest/draw", shader: { accessors: { tint } } }, () => {}); +const draw = System.render({ shader: { accessors: { tint } } }, () => {}); const code: string = wgsl(draw); check GPUBindGroupLayout>>(); export const used = [code, layout(device)]; diff --git a/packages/ecs/src/interface/gpu/wgsl.ts b/packages/ecs/src/interface/gpu/wgsl.ts index 8def4790..3b13c939 100644 --- a/packages/ecs/src/interface/gpu/wgsl.ts +++ b/packages/ecs/src/interface/gpu/wgsl.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { definitionOf } from '../registry/index.js'; -import type { Name } from '../system/name.js'; import type { System } from '../system/system.js'; import { accessorsOf, declarationsOf } from './accessors.js'; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts index 74141d22..ebdcf691 100644 --- a/packages/ecs/src/interface/index.type-test.ts +++ b/packages/ecs/src/interface/index.type-test.ts @@ -18,16 +18,16 @@ declare module "@adobe/ecs" { [scale]: { type: number }; } } -export const move = System.create({ name: "indexTypeTest/move", components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); -export const show = System.create({ name: "indexTypeTest/show", components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); -export const all = System.create({ name: "indexTypeTest/all", systems: [move, show] }); -export const draw = System.render({ name: "indexTypeTest/draw", shader: { accessors: { place: position } } }, () => {}); -export const release = Finalizer.create({ name: "indexTypeTest/release", components: { read: [element] } }, row => { row[element].remove(); }); -Rule.create({ name: "indexTypeTest/slots", components: { has: [position] }, derive: [slot] }); +export const move = System.create({ components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); +export const show = System.create({ components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); +export const all = System.create({ systems: [move, show] }); +export const draw = System.render({ shader: { accessors: { place: position } } }, () => {}); +export const release = Finalizer.create({ components: { read: [element] } }, row => { row[element].remove(); }); +Rule.create({ components: { has: [position] }, derive: [slot] }); declare const create: Create; declare const wgsl: typeof Gpu.wgsl; export const world = create({ [workers]: 4, [gpuDevice]: null }); -export const read = System.create({ name: "indexTypeTest/read", observer: true, components: { read: [position, slot] }, resources: { read: [gpuDevice, workers] } }, () => {}); +export const read = System.create({ observer: true, components: { read: [position, slot] }, resources: { read: [gpuDevice, workers] } }, () => {}); export const readOne = System.create({ observer: true, lookup: { at: { read: [position], has: [slot] } } }, () => {}); export const report = world.run(all); export const shader = wgsl(draw); diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index 4a77b2b9..e9c3606c 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; import { Finalizer } from "../finalizer/finalizer.js"; import { identify } from "../registry/ids.test-helper.js"; -import { isLeaf, labelOfItem, requireDefinition } from "../registry/index.js"; +import { isLeaf, requireDefinition } from "../registry/index.js"; import { accessOf } from "../registry/schedule.js"; import { threadOf } from "../registry/threads.js"; import { System } from "../system/system.js"; @@ -42,22 +42,22 @@ const untyped = (declaration: object): unknown => Reflect.apply(Index.create, un describe("Index.create", () => { it("returns an index labeled by its name, its binding and id, or its kind numbered", () => { - const named = Index.create({ name: "indexCreateTest/named", key: [amount] }); - expect(named.name).toBe("indexCreateTest/named"); + const named = Index.create({ key: [amount] }); + expect(named.name).toBe("named"); const bound = Index.create(identify({ key: [amount], sorted: true }, "i.ts#bound")); - expect(bound.name).toBe("bound (i.ts#bound)"); + expect(bound.name).toBe("bound"); expect(Index.create({ key: [amount] }).name).toMatch(/^index #\d+$/); }); it("is the index already recorded when declared again under its id the same, and throws for another declaration", () => { - const first = Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again")); - expect(Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again"))).toBe(first); - expect(() => Index.create(identify({ name: "indexCreateTest/again", key: [amount] }, "i.ts#again"))).toThrow("reload the page"); + const first = Index.create(identify({ key: [cell], components: { has: [shown] } }, "i.ts#again")); + expect(Index.create(identify({ key: [cell], components: { has: [shown] } }, "i.ts#again"))).toBe(first); + expect(() => Index.create(identify({ key: [amount] }, "i.ts#again"))).toThrow("reload the page"); // Another value is another index. - expect(Index.create({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } })).not.toBe(first); + expect(Index.create({ key: [cell], components: { has: [shown] } })).not.toBe(first); }); it("throws for key parts that aren't components of data, are listed twice, or are sets", () => { - expect(() => untyped({ name: "bad" })).toThrow(`Index "bad" needs a key`); - expect(() => untyped({ name: "bad", key: amount })).toThrow(`Index "bad" needs a key`); + expect(() => untyped({ name: "bad" })).toThrow(`needs a key`); + expect(() => untyped({ name: "bad", key: amount })).toThrow(`needs a key`); expect(() => untyped({ name: "bad", key: [unregistered] })).toThrow(`key part "indexCreateTest/unregistered", which isn't a registered component of data`); expect(() => untyped({ name: "bad", key: [handle] })).toThrow("isn't a registered component of data"); expect(() => untyped({ name: "bad", key: [amount, amount] })).toThrow(`lists key part "indexCreateTest/amount" twice`); @@ -66,7 +66,7 @@ describe("Index.create", () => { it("takes an empty key, holding the entities with its has and not, and isn't unique then", () => { expect(() => Index.create({ key: [] })).not.toThrow(); expect(() => Index.create({ key: [], sorted: true, components: { has: [amount], not: [shown] } })).not.toThrow(); - expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`Index "bad" is unique without a key`); + expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`is unique without a key`); expect(() => untyped({ name: "bad", key: [], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); expect(() => untyped({ name: "bad", key: [], components: { has: [unregistered] } })).toThrow("isn't a registered component"); }); @@ -88,13 +88,13 @@ describe("Index.create", () => { }); describe("systems and finalizers looking up through an index", () => { - const grid = Index.create({ name: "indexCreateTest/grid", key: [cell], components: { has: [amount] } }); + const grid = Index.create({ key: [cell], components: { has: [amount] } }); const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const reader = System.create({ name: "indexCreateTest/reader", lookup: { near: { index: grid, read: [amount] } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); + const reader = System.create({ lookup: { near: { index: grid, read: [amount] } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); const leaf = requireDefinition(reader); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([amount, cell]); - const writer = System.create({ name: "indexCreateTest/writer", components: { write: [cell] } }, row => { row[cell] = [0, 0]; }); + const writer = System.create({ components: { write: [cell] } }, row => { row[cell] = [0, 0]; }); expect(() => System.create([reader, writer])).toThrow(`both access "indexCreateTest/cell" and one writes it`); expect(() => System.create({ systems: [reader, writer], before: [] })).toThrow("order them"); const names = Index.create({ key: [view] }); @@ -102,31 +102,31 @@ describe("systems and finalizers looking up through an index", () => { }); it("throw for a lookup through what isn't an index, reading or watching what not every entity it holds has, or a system writing its key or reads", () => { expect(() => untypedSystem({ name: "bad", lookup: { near: { index: { name: "fake" } } } })).toThrow(`lookup "near" goes through a value Index.create didn't return`); - expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, read: [name] } } })).toThrow(`lookup "near" reads "indexCreateTest/name", which not every entity index "indexCreateTest/grid" holds has`); + expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, read: [name] } } })).toThrow(`lookup "near" reads "indexCreateTest/name", which not every entity index "grid" holds has`); expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, changed: [name] } } })).toThrow(`lookup "near" watches "indexCreateTest/name"`); expect(() => untypedSystem({ name: "bad", components: { write: [cell] }, lookup: { near: { index: grid } } })).toThrow(`goes through an index keyed by "indexCreateTest/cell", which the system writes`); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, lookup: { near: { index: grid, read: [amount] } } })).toThrow(`reads "indexCreateTest/amount", which the system writes`); expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); - expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`Finalizer "bad": lookup "near" reads`); - expect(labelOfItem(grid)).toBe("indexCreateTest/grid"); + expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`lookup "near" reads`); + expect(grid.name).toBe("grid"); }); }); describe("systems ordering their rows by an index", () => { - const ranked = Index.create({ name: "indexCreateTest/ranked", key: [amount], sorted: true, components: { has: [cell] } }); + const ranked = Index.create({ key: [amount], sorted: true, components: { has: [cell] } }); const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const ordered = System.create({ name: "indexCreateTest/ordered", components: { read: [cell] }, order: ranked }, row => { void row[cell]; }); + const ordered = System.create({ components: { read: [cell] }, order: ranked }, row => { void row[cell]; }); const leaf = requireDefinition(ordered); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([cell, amount]); - const writer = System.create({ name: "indexCreateTest/rankWriter", components: { write: [amount] } }, row => { row[amount] = 0; }); + const writer = System.create({ components: { write: [amount] } }, row => { row[amount] = 0; }); expect(() => System.create([ordered, writer])).toThrow(`both access "indexCreateTest/amount" and one writes it`); // Every value a sorted index orders lives in shared memory, strings included, so its key places nothing. expect(threadOf({ order: Index.create({ key: [name], sorted: true }) }, "System", "unplaced")).toBe(null); }); it("throw for an order by what isn't a sorted index, without components, with up, on the GPU, or writing its key", () => { - expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: { name: "fake" } })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); - expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: Index.create({ name: "indexCreateTest/hashed", key: [amount] }) })).toThrow(`orders its rows by index "indexCreateTest/hashed", which isn't sorted`); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: { name: "fake" } })).toThrow(`orders its rows by a value Index.create didn't return`); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: Index.create({ key: [amount] }) })).toThrow(`orders its rows by index "order", which isn't sorted`); expect(() => untypedSystem({ name: "bad", order: ranked })).toThrow("orders its rows without components"); expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, up: { through: parent }, order: ranked })).toThrow("uses both up and order"); expect(() => untypedSystem({ name: "bad", kernel: "gpu", components: { write: [cell] }, order: ranked })).toThrow("GPU kernels have no order yet"); @@ -135,7 +135,7 @@ describe("systems ordering their rows by an index", () => { }); it("throw for a render system's order by what isn't a sorted index", () => { const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [{ name: "bad", order, shader: { accessors: { amount } } }, () => {}]); - expect(() => untypedRender({ name: "fake" })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); - expect(() => untypedRender(Index.create({ name: "indexCreateTest/hashedForRender", key: [amount] }))).toThrow(`orders its rows by index "indexCreateTest/hashedForRender", which isn't sorted`); + expect(() => untypedRender({ name: "fake" })).toThrow(`orders its rows by a value Index.create didn't return`); + expect(() => untypedRender(Index.create({ key: [amount] }))).toThrow(/orders its rows by index "index #\d+", which isn't sorted/); }); }); diff --git a/packages/ecs/src/interface/indexes/create.ts b/packages/ecs/src/interface/indexes/create.ts index d1105a54..2748308c 100644 --- a/packages/ecs/src/interface/indexes/create.ts +++ b/packages/ecs/src/interface/indexes/create.ts @@ -45,7 +45,7 @@ const collection = (schema: Schema): boolean => "type" in schema && (schema.type */ export const create = (declaration: D & Validate): Index => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "index"); + const name = nameOf(declaration, id, "index"); const fail = (message: string): never => { throw new Error(`Index "${name}" ${message}.`); }; const { key, components, sorted } = declaration; if (!Array.isArray(key)) fail("needs a key: a list of components"); diff --git a/packages/ecs/src/interface/indexes/declaration.ts b/packages/ecs/src/interface/indexes/declaration.ts index 3408b13d..0e2fbf0e 100644 --- a/packages/ecs/src/interface/indexes/declaration.ts +++ b/packages/ecs/src/interface/indexes/declaration.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Name } from './name.js'; /** What `Index.create` takes, its fields documented there; not exported from the package. */ export type Declaration = { - readonly name?: Name; readonly key: readonly Component.Key[]; readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; readonly sorted?: boolean; diff --git a/packages/ecs/src/interface/indexes/index-type.ts b/packages/ecs/src/interface/indexes/index-type.ts index 39ad05d4..817139b6 100644 --- a/packages/ecs/src/interface/indexes/index-type.ts +++ b/packages/ecs/src/interface/indexes/index-type.ts @@ -4,9 +4,9 @@ import type { Declaration } from './declaration.js'; declare const declared: unique symbol; /** - * An index, the value `Index.create` returned: identified by that value, labeled by its name. `D` is the type of the - * declaration it was created from, which its lookups take their key and rows from; code accepting any sorted index - * takes `Index<{ readonly sorted: true }>`. + * An index, the value `Index.create` returned: identified by that value, labeled by the binding it's declared at. `D` + * is the type of the declaration it was created from, which its lookups take their key and rows from; code accepting + * any sorted index takes `Index<{ readonly sorted: true }>`. */ export type Index = { readonly name: string; readonly [declared]?: D }; diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index ab857ee6..58a36f2c 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -40,8 +40,8 @@ declare module '@adobe/ecs' { } // An index is typed by its declaration, as written. -const grid = Index.create({ name: "indexTest/grid", key: [cell], components: { has: [position] } }); -check>>(); +const grid = Index.create({ key: [cell], components: { has: [position] } }); +check>>(); const byServerId = Index.create({ key: [serverId], unique: true, components: { has: [health] } }); const clips = Index.create({ key: [track, start], sorted: true, components: { has: [clip] } }); const tasksOf = Index.create({ key: [owner], components: { not: [archived] } }); diff --git a/packages/ecs/src/interface/indexes/name.ts b/packages/ecs/src/interface/indexes/name.ts deleted file mode 100644 index 2c27dcc7..00000000 --- a/packages/ecs/src/interface/indexes/name.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value `Index.create` returns is its identity. - * Without one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/registry/finalizer-order.ts b/packages/ecs/src/interface/registry/finalizer-order.ts index e400dab6..26285974 100644 --- a/packages/ecs/src/interface/registry/finalizer-order.ts +++ b/packages/ecs/src/interface/registry/finalizer-order.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Finalizer } from '../finalizer/created.js'; -import { firstItemOf, labelOfItem } from './items.js'; +import { firstItemOf } from './items.js'; import { firstKeyOf, labelOf } from './names.js'; /** @@ -26,7 +26,7 @@ export const orderFinalizers = (runners: readonly Finalizer[]): Finalizer[] => { for (const other of successors[index]) if (--incoming[other] === 0) ready.push(other); } if (order.length < runners.length) { - const cycle = runners.filter((_, index) => !order.includes(index)).map(runner => `"${labelOfItem(runner)}"`).join(", "); + const cycle = runners.filter((_, index) => !order.includes(index)).map(runner => `"${runner.name}"`).join(", "); throw new Error(`Finalizers ${cycle} are ordered in a cycle.`); } const reaches = (from: number, to: number): boolean => { @@ -46,7 +46,7 @@ export const orderFinalizers = (runners: readonly Finalizer[]): Finalizer[] => { for (let a = 0; a < runners.length; a++) for (let b = a + 1; b < runners.length; b++) { const conflict = [...writes[a]].find(key => writes[b].has(key) || reads[b].has(key)) ?? [...writes[b]].find(key => reads[a].has(key)); if (conflict !== undefined && !reaches(a, b) && !reaches(b, a)) { - throw new Error(`Finalizers "${labelOfItem(runners[a])}" and "${labelOfItem(runners[b])}" both access "${labelOf(conflict)}", one writing it; order them with before or after.`); + throw new Error(`Finalizers "${runners[a].name}" and "${runners[b].name}" both access "${labelOf(conflict)}", one writing it; order them with before or after.`); } } return order.map(index => runners[index]); diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index 0a625270..b13213fe 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -53,10 +53,10 @@ const finalizer = untyped(Finalizer.create); type Row = { [key: symbol]: number }; describe("keys with ids", () => { - it("labels a key by its description, and by its id once another registered key has its description", () => { + it("labels a key by its description, however many keys share it", () => { expect(labelOf(score)).toBe("score"); declareComponent$(unnamed, { type: { type: "number" } }); - expect([labelOf(score), labelOf(unnamed)]).toEqual(["idsTest/keys.ts#score", "idsTest/other.ts#score"]); + expect([labelOf(score), labelOf(unnamed)]).toEqual(["score", "score"]); expect(labelOf(Symbol("score"))).toBe("score"); }); it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { @@ -66,45 +66,45 @@ describe("keys with ids", () => { declareTrait$(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); - expect(labelOf(scoreAgain)).toBe("idsTest/keys.ts#score"); + expect(labelOf(scoreAgain)).toBe("score"); }); it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { expect(() => declareComponent$(changedAgain, { type: { type: "string" } })).toThrow( - `Cannot register component "idsTest/keys.ts#changed": its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + `Cannot register component "changed" (idsTest/keys.ts#changed): its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); expect(() => declareResource$(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( - `Cannot register resource "idsTest/keys.ts#crossed": its id is a component's.`); + `Cannot register resource "crossed" (idsTest/keys.ts#crossed): its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); it("keeps keys of different ids apart, though their descriptions match", () => { declareComponent$(unnamed, { type: { type: "number" } }); - system(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[score] = 1; }); - expect(() => system(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); + system(identify({ components: { write: [score] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[score] = 1; }); + expect(() => system(identify({ components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); }); }); describe("declarations evaluated again", () => { it("accept a system whose keys match by id, as a new system", () => { - const first = system(identify({ name: "idsTest/system", components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); - const again = system(identify({ name: "idsTest/system", components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); + const first = system(identify({ components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); + const again = system(identify({ components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); expect(again).not.toBe(first); expect(sameItem(again, first)).toBe(true); }); it("accept a rule whose keys match by id, which is already recorded", () => { - const created = rule(identify({ name: "idsTest/rule", components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); + const created = rule(identify({ components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); const rules = programRules().length; - expect(rule(identify({ name: "idsTest/rule", components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); + expect(rule(identify({ components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); expect(programRules()).toHaveLength(rules); }); it("accept a finalizer whose keys match by id, keeping its place with the new body", () => { - const first = finalizer(identify({ name: "idsTest/finalizer", components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); + const first = finalizer(identify({ components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); const body = (): void => {}; - expect(finalizer(identify({ name: "idsTest/finalizer", components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); - const found = programFinalizers().find(finalizer => finalizer.name === "idsTest/finalizer"); + expect(finalizer(identify({ components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); + const found = programFinalizers().find(finalizer => finalizer.name === "finalizer"); expect(found?.kind === "finalizer" ? found.body : null).toBe(body); }); it("schedule symbols of one id as one key", () => { - const write = system({ name: "idsTest/write", components: { write: [score] } }, (row: Row) => { row[score] = 1; }); - const read = system({ name: "idsTest/read", components: { read: [scoreAgain] } }, (row: Row) => { void row[scoreAgain]; }); - expect(() => scheduleOf(requireDefinition(system({ name: "idsTest/unordered", systems: [write, read] })))).toThrow("both access"); + const write = system({ name: "write", components: { write: [score] } }, (row: Row) => { row[score] = 1; }); + const read = system({ name: "read", components: { read: [scoreAgain] } }, (row: Row) => { void row[scoreAgain]; }); + expect(() => scheduleOf(requireDefinition(system({ name: "unordered", systems: [write, read] })))).toThrow("both access"); }); }); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts index 0c2cafe6..c536577a 100644 --- a/packages/ecs/src/interface/registry/ids.ts +++ b/packages/ecs/src/interface/registry/ids.ts @@ -29,3 +29,21 @@ export const takeIdOfDeclaration = (declaration: object): string | undefined => map?.delete(declaration); return typeof id === "string" ? id : undefined; }; + +/** + * The `Symbol.for` name of the global `Map` from a declaration to the label the plugin gave a constructor call that + * isn't a top-level binding's, by the binding or function it's in, or an internal factory gave its declaration. + */ +export const labelsName = "@adobe/ecs/labels@1"; + +const labels = Symbol.for(labelsName); + +/** The label given the declaration a constructor call passes, if any, taken out of the map. */ +export const takeLabelOfDeclaration = (declaration: object): string | undefined => { + const map: unknown = Reflect.get(globalThis, labels); + if (!(map instanceof Map)) return undefined; + const label: unknown = map.get(declaration); + if (label === undefined) return undefined; + map.delete(declaration); + return typeof label === "string" ? label : undefined; +}; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts index 48b62f2a..35b44dfc 100644 --- a/packages/ecs/src/interface/registry/index.ts +++ b/packages/ecs/src/interface/registry/index.ts @@ -16,7 +16,7 @@ export { isTrait } from './traits.js'; export { idOfKey } from './ids.js'; export { isWorldWritten } from './written.js'; export { sameDeclaration } from './names.js'; -export { firstItemOf, identifiedItems, idOfItem, itemOfId, labelOfItem, sameItem } from './items.js'; +export { firstItemOf, identifiedItems, idOfItem, idOrNull, itemOfId, sameItem } from './items.js'; export { allowsNull, conditional, isStruct } from './schemas.js'; export { isReduction, reductionOperators } from './reduction.js'; export type { Definition } from '../system/definition.js'; diff --git a/packages/ecs/src/interface/registry/indexes.ts b/packages/ecs/src/interface/registry/indexes.ts index 7a040a98..be05509e 100644 --- a/packages/ecs/src/interface/registry/indexes.ts +++ b/packages/ecs/src/interface/registry/indexes.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Created } from '../indexes/created.js'; -import { labelOfItem, recordItem, registeredAs } from './items.js'; +import { recordItem, registeredAs } from './items.js'; import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; /** @@ -63,7 +63,7 @@ export const checkLookup = (what: "System" | "Finalizer", name: string, lookupNa const held = new Set([...index.declaration.key, ...index.declaration.components?.has ?? []].map(firstKeyOf)); const check = (verb: string, keys: readonly Key[] | undefined): void => { for (const key of keys ?? []) { - if (!held.has(firstKeyOf(key))) throw new Error(`${what} "${name}": lookup "${lookupName}" ${verb} "${labelOf(key)}", which not every entity index "${labelOfItem(index)}" holds has; name its key parts or its has.`); + if (!held.has(firstKeyOf(key))) throw new Error(`${what} "${name}": lookup "${lookupName}" ${verb} "${labelOf(key)}", which not every entity index "${index.name}" holds has; name its key parts or its has.`); } }; check("reads", lookup.read); diff --git a/packages/ecs/src/interface/registry/items.ts b/packages/ecs/src/interface/registry/items.ts index 2a4bed70..9ec4d4eb 100644 --- a/packages/ecs/src/interface/registry/items.ts +++ b/packages/ecs/src/interface/registry/items.ts @@ -1,18 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { takeLabelOfDeclaration } from './ids.js'; /** A registered system, rule or finalizer: what each constructor records, told apart by `kind`. */ type Registered = { readonly kind: string; readonly name: string; readonly declaration: object }; /** - * A registered item: its kind and declaration, those of the first value registered under its id, its name, its id if - * the plugin gave one, and the label it has without one, numbered when another item had its name; shared by every - * value registered under its id. `first` is the value registered first, which stands for it wherever items compare. + * A registered item: its kind and declaration, those of the first value registered under its id, and its id if the + * plugin gave one; shared by every value registered under its id. `first` is the value registered first, which stands + * for it wherever items compare. */ type Entry = { readonly kind: string; readonly declaration: object; - readonly name: string; readonly id: string | undefined; - readonly numbered: string; readonly first: Registered; }; @@ -22,12 +21,6 @@ const entries = new WeakMap(); /** Entries by the ids the plugin gave their declarations. */ const identified = new Map(); -/** Numbered labels given, so each item's is unique. */ -const labels = new Set(); - -/** How many registered items have each name. */ -const named = new Map(); - /** Whether two values are one item: the same value, or values registered under one id. */ export const sameItem = (a: object, b: object): boolean => { if (a === b) return true; @@ -38,17 +31,6 @@ export const sameItem = (a: object, b: object): boolean => { /** The value first registered as this item, which stands for it wherever items compare; any other value as it is. */ export const firstItemOf = (item: object): object => entries.get(item)?.first ?? item; -/** - * How errors and reports name a system, rule or finalizer: its name while no other registered item has it, else its - * name with its id when the plugin gave one, and without one its name numbered by registration order. An item not - * registered is named by its name. - */ -export const labelOfItem = (item: { readonly name: string }): string => { - const entry = entries.get(item); - if (entry === undefined || (named.get(entry.name) ?? 0) <= 1) return item.name; - return entry.id === undefined ? entry.numbered : `${entry.name} (${entry.id})`; -}; - /** * The item already registered under `id` that `item` is: one of its kind with the same declaration, as a module * evaluated again declares it; undefined without an id or for an id not registered. Throws for an id registered as @@ -65,22 +47,6 @@ export const registeredAs = (item: T, id: string | undefin return entry.first; }; -/** A label for an item registered now without an id, its name numbered when another item's has it. */ -const numberedFor = (name: string): string => { - let label = name; - for (let n = 2; labels.has(label); n++) label = `${name}#${n}`; - labels.add(label); - return label; -}; - -/** An item without an id is forgotten once collected: its name's count and numbered label go with it. */ -const forgotten = new FinalizationRegistry<{ readonly name: string; readonly numbered: string }>(({ name, numbered }) => { - labels.delete(numbered); - const count = (named.get(name) ?? 0) - 1; - if (count > 0) named.set(name, count); - else named.delete(name); -}); - /** * Records an item under its id: a value of an item `registeredAs` found shares its entry, and any other is a new item. */ @@ -90,28 +56,31 @@ export const recordItem = (item: Registered, id: string | undefined, existing: o entries.set(item, shared); return; } - const entry: Entry = { kind: item.kind, declaration: item.declaration, name: item.name, id, numbered: numberedFor(item.name), first: item }; - named.set(item.name, (named.get(item.name) ?? 0) + 1); + const entry: Entry = { kind: item.kind, declaration: item.declaration, id, first: item }; if (id !== undefined) identified.set(id, entry); - else forgotten.register(item, { name: item.name, numbered: entry.numbered }); entries.set(item, entry); }; -/** How many items of each kind were declared without a name or an id, to number their labels. */ +/** How many items of each kind were labeled by number, to number the next. */ const anonymous = new Map(); /** - * An item's name: the one its declaration gives; without one, its binding with the id the plugin gave it - * (`spawn (src/setup.ts#spawn)`), and without an id, its kind numbered by creation (`system #3`). + * An item's label: the one the plugin gave its constructor call's first argument, `value`, by the binding or function + * it's in, or an internal factory gave it; else its binding, from its id (`spawn` for `src/setup.ts#spawn`); else its + * kind numbered by creation (`system #3`). */ -export const nameOf = (name: string | undefined, id: string | undefined, kind: "system" | "rule" | "finalizer" | "index"): string => { - if (name !== undefined) return name; - if (id !== undefined) return `${id.slice(id.lastIndexOf("#") + 1)} (${id})`; +export const nameOf = (value: object, id: string | undefined, kind: "system" | "rule" | "finalizer" | "index"): string => { + const label = takeLabelOfDeclaration(value); + if (label !== undefined) return label; + if (id !== undefined) return id.slice(id.lastIndexOf("#") + 1); const count = (anonymous.get(kind) ?? 0) + 1; anonymous.set(kind, count); return `${kind} #${count}`; }; +/** The id the plugin gave an item, or null without one, as reports give it beside its label. */ +export const idOrNull = (item: object): string | null => entries.get(item)?.id ?? null; + /** The id the plugin gave a system, rule or finalizer, by which other threads name it, if it gave one. */ export const idOfItem = (item: object): string | undefined => entries.get(item)?.id; diff --git a/packages/ecs/src/interface/registry/names.test.ts b/packages/ecs/src/interface/registry/names.test.ts index dd15eb94..fd5ad73d 100644 --- a/packages/ecs/src/interface/registry/names.test.ts +++ b/packages/ecs/src/interface/registry/names.test.ts @@ -42,46 +42,32 @@ describe("claimKey", () => { }); describe("labelOf", () => { - it("labels a key by its description while no other registered key has it, else by its id", () => { + it("labels a key by its description, whatever other keys have it", () => { const first = Symbol("labelOf/shared"); const second = Symbol("labelOf/shared"); - const lone = Symbol("labelOf/lone"); identify(first, "a.ts#first"); identify(second, "b.ts#second"); - identify(lone, "c.ts#lone"); - expect([labelOf(first), labelOf(lone)]).toEqual(["labelOf/shared", "labelOf/lone"]); claimKey(first, "component", number); - claimKey(lone, "component", number); - expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["labelOf/shared", "b.ts#second", "labelOf/lone"]); claimKey(second, "resource", number); - expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["a.ts#first", "b.ts#second", "labelOf/lone"]); + expect([labelOf(first), labelOf(second)]).toEqual(["labelOf/shared", "labelOf/shared"]); + const twin = Symbol("labelOf/shared"); + claimKey(twin, "trait"); + expect(labelOf(twin)).toBe("labelOf/shared"); }); - it("labels a key without an id by its description, numbered by registration when another key has it", () => { - const first = Symbol("labelOf/twin"); - const second = Symbol("labelOf/twin"); - const third = Symbol("labelOf/twin"); - expect(labelOf(first)).toBe("labelOf/twin"); - claimKey(second, "component", { type: { type: "number" } }); - claimKey(first, "component", { type: { type: "number" } }); - claimKey(third, "trait"); - expect([labelOf(first), labelOf(second), labelOf(third)]).toEqual(["labelOf/twin#2", "labelOf/twin", "labelOf/twin#3"]); - }); - it("labels a key without a description by its binding, which the plugin's id names, else by its id", () => { + it("labels a key without a description by its binding, which the plugin's id names", () => { const speed = Symbol(); const other = Symbol(); identify(speed, "app@1.0.0/keys.ts#labelOfSpeed"); identify(other, "app@1.0.0/other.ts#labelOfSpeed"); - expect(labelOf(speed)).toBe("labelOfSpeed"); claimKey(speed, "resource", number); - expect(labelOf(speed)).toBe("labelOfSpeed"); claimKey(other, "component", number); - expect([labelOf(speed), labelOf(other)]).toEqual(["app@1.0.0/keys.ts#labelOfSpeed", "app@1.0.0/other.ts#labelOfSpeed"]); + expect([labelOf(speed), labelOf(other)]).toEqual(["labelOfSpeed", "labelOfSpeed"]); }); it("gives a key without a description or an id a numbered label, and names other values as text", () => { const anonymous = Symbol(); expect(labelOf(anonymous)).toBe("Symbol()"); claimKey(anonymous, "trait"); - expect(labelOf(anonymous)).toMatch(/^Symbol\(\)(#\d+)?$/); + expect(labelOf(anonymous)).toBe("Symbol()"); expect([labelOf("text"), labelOf(1)]).toEqual(["text", "1"]); }); }); diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts index 54e9c863..6c6f5b20 100644 --- a/packages/ecs/src/interface/registry/names.ts +++ b/packages/ecs/src/interface/registry/names.ts @@ -7,16 +7,14 @@ import { sameItem } from './items.js'; export type Kind = "component" | "resource" | "trait"; /** - * A registered key: its kind, its declaration (none for a trait), its description, its id if the plugin gave one, and - * the label it has without one, numbered when another key had its description; shared by every symbol registered under - * its id. `first` is the symbol registered first, `newest` the one registered last. + * A registered key: its kind, its declaration (none for a trait), its label and its id if the plugin gave one; shared + * by every symbol registered under its id. `first` is the symbol registered first, `newest` the one registered last. */ type Entry = { readonly kind: Kind; readonly declaration: AnyDeclaration | undefined; readonly description: string; readonly id: string | undefined; - readonly numbered: string; readonly first: symbol; newest: symbol; }; @@ -27,12 +25,6 @@ const entries = new Map(); /** Entries by the ids the plugin gave their keys. */ const identified = new Map(); -/** Numbered labels given, so each key's is unique. */ -const labels = new Set(); - -/** How many registered keys have each description. */ -const described = new Map(); - /** A symbol's description; without one, its binding, which the plugin's id names, or `Symbol()` without an id. */ const descriptionOf = (key: symbol): string => { if (key.description !== undefined && key.description !== "") return key.description; @@ -68,31 +60,17 @@ export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? e export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.newest ?? key : key; /** - * How errors and reports name a key: its description, or without one its binding from its id, while no other - * registered key has it; else its id when the plugin gave it one, and without one, for a registered key, its - * description numbered by registration order. Any other value is named as text. + * How errors and reports name a key: its description, or without one its binding from its id, whatever other keys + * have; for display only, with its id (`idOfKey`) to tell keys of one label apart. Any other value is named as text. */ export const labelOf = (key: unknown): string => { if (typeof key !== "symbol") return String(key); - const known = wellKnown.get(key); - if (known !== undefined) return known; - const entry = entries.get(key); - const description = entry?.description ?? descriptionOf(key); - if ((described.get(description) ?? 0) <= (entry === undefined ? 0 : 1)) return description; - return entry === undefined ? idOfKey(key) ?? description : entry.id ?? entry.numbered; -}; - -/** A label for a key registered now without an id, its description numbered when another key's has it. */ -const numberedFor = (description: string): string => { - let label = description; - for (let n = 2; labels.has(label); n++) label = `${description}#${n}`; - labels.add(label); - return label; + return wellKnown.get(key) ?? entries.get(key)?.description ?? descriptionOf(key); }; -/** Throws, naming the key, for a key registration can't take. */ -export const failRegistration = (kind: Kind, key: unknown, message: string): never => { - throw new Error(`Cannot register ${kind} "${labelOf(key)}": ${message}`); +/** Throws, naming the key by its label, and by `id` too where the failure is its id's, for a key registration can't take. */ +export const failRegistration = (kind: Kind, key: unknown, message: string, id?: string): never => { + throw new Error(`Cannot register ${kind} "${labelOf(key)}"${id === undefined ? "" : ` (${id})`}: ${message}`); }; /** The key, or a throw for one that isn't a plain `Symbol(...)`: a `Symbol.for` or well-known symbol is shared. */ @@ -141,17 +119,16 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration) const id = idOfKey(symbol); const named = id === undefined ? undefined : identified.get(id); if (named !== undefined) { - if (named.kind !== kind) failRegistration(kind, symbol, `its id is a ${named.kind}'s.`); + if (named.kind !== kind) failRegistration(kind, symbol, `its id is a ${named.kind}'s.`, id); if (!sameDeclaration(named.declaration, declaration)) { - failRegistration(kind, symbol, "its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page."); + failRegistration(kind, symbol, "its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.", id); } named.newest = symbol; entries.set(symbol, named); return; } const description = descriptionOf(symbol); - const entry: Entry = { kind, declaration, description, id, numbered: numberedFor(description), first: symbol, newest: symbol }; - described.set(description, (described.get(description) ?? 0) + 1); + const entry: Entry = { kind, declaration, description, id, first: symbol, newest: symbol }; if (id !== undefined) identified.set(id, entry); entries.set(symbol, entry); }; diff --git a/packages/ecs/src/interface/registry/schedule.ts b/packages/ecs/src/interface/registry/schedule.ts index 0b012995..894d3cc6 100644 --- a/packages/ecs/src/interface/registry/schedule.ts +++ b/packages/ecs/src/interface/registry/schedule.ts @@ -2,7 +2,7 @@ import type { Schema } from '../schema/schema.js'; import type { Definition } from '../system/definition.js'; import type { Leaf } from '../system/leaf.js'; -import { firstItemOf, labelOfItem } from './items.js'; +import { firstItemOf } from './items.js'; import { requireDefinition } from './program.js'; import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; import { lookupKeysOf, orderKeysOf } from './indexes.js'; @@ -14,7 +14,7 @@ const checkInputs = (leaves: readonly Leaf[]): void => { const fields = new Map(); for (const leaf of leaves) { const { input } = leaf; - const name = labelOfItem(leaf); + const name = leaf.name; if (input === undefined) continue; for (const [field, property] of Object.entries(input.properties)) { const seen = fields.get(field); @@ -36,7 +36,7 @@ export type Schedule = { readonly tiers: readonly (readonly Leaf[])[]; readonly export const isLeaf = (system: Definition): system is Leaf => system.kind !== "composite"; /** Whether a system is an observer leaf, which runs after a sync point rather than in a tier. */ -export const isObserver = (system: Definition): system is Leaf => system.kind !== "composite" && system.declaration.observer === true; +export const isObserver = (system: Definition): system is Leaf & { readonly declaration: { readonly observer: true } } => system.kind !== "composite" && system.declaration.observer === true; /** Each system is an interval, from its start node to its end node. */ const start = (unit: number): number => 2 * unit; @@ -101,7 +101,7 @@ const topologicalOrder = ({ units, successors }: Graph): number[] => { for (const next of successors[node]) if (--incoming[next] === 0) ready.push(next); } if (order.length < successors.length) { - const cyclic = units.filter((_, unit) => incoming[start(unit)] > 0 || incoming[end(unit)] > 0).map(system => `"${labelOfItem(system)}"`); + const cyclic = units.filter((_, unit) => incoming[start(unit)] > 0 || incoming[end(unit)] > 0).map(system => `"${system.name}"`); throw new Error(`Ordering cycle between ${cyclic.join(", ")}.`); } return order; @@ -140,8 +140,7 @@ type Access = { readonly reads: ReadonlySet; readonly writes: ReadonlySet { - const { reduce } = leaf.declaration; - return reduce === undefined ? [] : Object.getOwnPropertySymbols(reduce).filter(key => Reflect.get(reduce, key) !== undefined); + return leaf.declaration.resources?.reduce ?? []; }; /** @@ -201,7 +200,7 @@ const createSchedule = (system: Definition): Schedule => { for (let b = a + 1; b < leaves.length; b++) { const key = conflictOf(accesses[a], accesses[b]); if (key !== undefined && !ordered(reach, leaves[a].unit, leaves[b].unit)) { - throw new Error(`Systems "${labelOfItem(leaves[a].leaf)}" and "${labelOfItem(leaves[b].leaf)}" both access "${labelOf(key)}" and one writes it; order them with before or after.`); + throw new Error(`Systems "${leaves[a].leaf.name}" and "${leaves[b].leaf.name}" both access "${labelOf(key)}" and one writes it; order them with before or after.`); } } } diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index 75ce4b21..b5447340 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -58,13 +58,14 @@ declareComponent$(errors, { properties: { kind: { anyOf: [{ const: "system" }, { const: "finalizer" }, { const: "gpu" }, { const: "thread" }, { const: "index" }] }, name: text, + id: { anyOf: [text, { type: "null" }] }, message: text, stack: { anyOf: [text, { type: "null" }] }, }, }, }, skipped: { type: "array", items: text }, - warnings: { type: "array", items: { type: "object", properties: { kind: { const: "blocking" }, name: text, message: text } } }, + warnings: { type: "array", items: { type: "object", properties: { kind: { const: "blocking" }, name: text, id: { anyOf: [text, { type: "null" }] }, message: text } } }, }, }, }); diff --git a/packages/ecs/src/interface/report/failure.ts b/packages/ecs/src/interface/report/failure.ts index e4e3a7c7..70c0de07 100644 --- a/packages/ecs/src/interface/report/failure.ts +++ b/packages/ecs/src/interface/report/failure.ts @@ -9,11 +9,12 @@ export type Failure = { readonly kind: "system" | "finalizer" | "gpu" | "thread" | "index"; /** * The label of the system or finalizer that failed; for GPU work, of the system whose run submitted it; for an - * index, the index's, with the key and both entities in `message`. Labels are for display: a system's, - * finalizer's or index's is its name while no other registered system, rule, finalizer or index has it, else its - * name with its id, or numbered without one. + * index, the index's, with the key and both entities in `message`. Labels are for display and needn't be unique: + * its binding, or for one made in a function the binding or function it's in, or its kind numbered. */ readonly name: string; + /** The id the plugin gave it, its package, module and binding (`@app/game@1.0.0/src/move.ts#move`); null without one. */ + readonly id: string | null; /** An Error's message, or String() of anything else thrown. */ readonly message: string; /** The thrown Error's `stack`, as the JavaScript engine formats it, for logs and display; null when it had none. */ @@ -24,4 +25,4 @@ export type Failure = { * What ran but slowed a run. `"blocking"`: a system that runs on a thread a joined thread fills and isn't terminal, so * its tier waited for that thread, by the system's label. */ -export type Warning = { readonly kind: "blocking"; readonly name: string; readonly message: string }; +export type Warning = { readonly kind: "blocking"; readonly name: string; readonly id: string | null; readonly message: string }; diff --git a/packages/ecs/src/interface/report/report.type-test.ts b/packages/ecs/src/interface/report/report.type-test.ts index caedfdfd..1aec9474 100644 --- a/packages/ecs/src/interface/report/report.type-test.ts +++ b/packages/ecs/src/interface/report/report.type-test.ts @@ -31,7 +31,8 @@ check>(); declare const failure: Report.Failure; check>(); check>(); -check>(); +check>(); +check>(); // A report's values: errors only when something went wrong. check>(); diff --git a/packages/ecs/src/interface/rule/create.ts b/packages/ecs/src/interface/rule/create.ts index 42075f6c..6d2b3407 100644 --- a/packages/ecs/src/interface/rule/create.ts +++ b/packages/ecs/src/interface/rule/create.ts @@ -7,13 +7,13 @@ import type { Rule as Created } from './created.js'; import type { Declaration } from './declaration.js'; /** - * Declares a rule, which applies in every world, and returns it: identified by that value, labeled by its name. Throws + * Declares a rule, which applies in every world, and returns it: identified by that value, labeled by its binding. Throws * if it matches on a key that isn't a registered component, derives a component that isn't derived, or derives in a * cycle through a negation. */ export const create = (declaration: D): { readonly name: string } => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "rule"); + const name = nameOf(declaration, id, "rule"); const { components, derive } = declaration; for (const key of [...components.has ?? [], ...components.not ?? [], ...derive]) { if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component.`); diff --git a/packages/ecs/src/interface/rule/declaration.ts b/packages/ecs/src/interface/rule/declaration.ts index d48bf589..8eda2d41 100644 --- a/packages/ecs/src/interface/rule/declaration.ts +++ b/packages/ecs/src/interface/rule/declaration.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Name } from './name.js'; import type { Derived } from '../component/value.js'; export type Declaration = { - readonly name?: Name; /** Which entities the rule matches, by the components they have and don't have. */ readonly components: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** Components present on matching entities, while any rule deriving them matches. */ diff --git a/packages/ecs/src/interface/rule/name.ts b/packages/ecs/src/interface/rule/name.ts deleted file mode 100644 index ef87e3b5..00000000 --- a/packages/ecs/src/interface/rule/name.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/rule/rule.type-test.ts b/packages/ecs/src/interface/rule/rule.type-test.ts index 8cb3f221..54db9b59 100644 --- a/packages/ecs/src/interface/rule/rule.type-test.ts +++ b/packages/ecs/src/interface/rule/rule.type-test.ts @@ -32,12 +32,12 @@ declare module '@adobe/ecs' { } // Rules derive only derived components. -const slots = Rule.create({ name: "ruleTypeTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }); +const slots = Rule.create({ components: { has: [source], not: [hidden] }, derive: [slot] }); // @ts-expect-error a rule derives only derived components -Rule.create({ name: "ruleTypeTest/slots", components: { has: [source] }, derive: [source] }); +Rule.create({ components: { has: [source] }, derive: [source] }); // A finalizer's context holds only what it declares, and its commands. -const other = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, (_, context) => { +const other = Finalizer.create({ components: { read: [source] } }, (_, context) => { // @ts-expect-error no resources declared void context.resources; // @ts-expect-error no lookups declared @@ -47,7 +47,6 @@ const other = Finalizer.create({ name: "ruleTypeTest/other", components: { read: // Per-query finalizers read the stopping row; it's read-only. Finalizer.create({ - name: "ruleTypeTest/release", components: { read: [slot, parent], not: [hidden] }, resources: { read: [time] }, after: [other], @@ -65,14 +64,13 @@ Finalizer.create({ void value; void now; }); Finalizer.create({ - name: "ruleTypeTest/other", components: {}, // @ts-expect-error write already makes a resource readable resources: { read: [time], write: [time] }, }, () => {}); // Changes finalizers see only the essential components they watch. -Finalizer.changes({ name: "ruleTypeTest/save", watch: [source, parent] }, (changes, { commands }) => { +Finalizer.changes({ watch: [source, parent] }, (changes, { commands }) => { check>>(); void commands; for (const group of changes.groups) { @@ -87,19 +85,19 @@ Finalizer.changes({ name: "ruleTypeTest/save", watch: [source, parent] }, (chang } }); // @ts-expect-error changes finalizers don't watch derived components -Finalizer.changes({ name: "ruleTypeTest/save", watch: [slot] }, () => {}); +Finalizer.changes({ watch: [slot] }, () => {}); // References change only through commands, so systems can't write them. -System.create({ name: "ruleTypeTest/move", components: { read: [parent], write: [source] } }, row => { row[source] = row[parent]; }); +System.create({ components: { read: [parent], write: [source] } }, row => { row[source] = row[parent]; }); // @ts-expect-error a reference changes only through commands -System.create({ name: "ruleTypeTest/move", components: { write: [parent] } }, () => {}); +System.create({ components: { write: [parent] } }, () => {}); check, typeof source>>(); // A rule and a finalizer are the values their constructors return, labeled by their names, which needn't be unique. check>>(); check>(); check>(); -const sameName: Finalizer = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, () => {}); +const sameName: Finalizer = Finalizer.create({ components: { read: [source] } }, () => {}); void sameName; // Rules and finalizers apply in every world. diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts index 73cf534e..a305f081 100644 --- a/packages/ecs/src/interface/system/access.ts +++ b/packages/ecs/src/interface/system/access.ts @@ -59,6 +59,8 @@ export type Up = { readonly through: Component.With<"reference">; readonly read? export type ResourceAccess = { readonly read?: readonly Resource.Key[]; readonly write?: readonly Exclude[]; + /** The resources its body contributes to, each folded with the operator its `reduce` trait declares. */ + readonly reduce?: readonly Reduced[]; /** Passes every row when any of these changed since the system last ran. */ readonly changed?: readonly Resource.Key[]; /** Runs only while each of these is neither `null` nor `false`, and reads it as such. Otherwise it's skipped, successfully. */ diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts index 135134d2..26108854 100644 --- a/packages/ecs/src/interface/system/body.ts +++ b/packages/ecs/src/interface/system/body.ts @@ -16,7 +16,7 @@ export type Row = type Resources = Listed, "read"> | Listed, "write"> | Is; type Is = Listed, "is">; -type Reduced = keyof Property & Resource.Key; +type Reduced = Listed, "reduce"> & Resource.Key; /** * What a body uses besides its row, by kind, under the declaration's own keys: each is present only when the diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts index 2267dbfa..ed107284 100644 --- a/packages/ecs/src/interface/system/check.ts +++ b/packages/ecs/src/interface/system/check.ts @@ -7,7 +7,6 @@ import { conditional } from '../registry/schemas.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; import { checkLookup, indexDefinitionOf, lookupKeysOf, orderKeysOf } from '../registry/indexes.js'; -import { labelOfItem } from '../registry/items.js'; import type { Schema } from '../schema/schema.js'; import type { Composite } from './composite.js'; import type { Definition } from './definition.js'; @@ -23,18 +22,15 @@ const checkLists = (name: string, what: string, lists: { readonly read?: readonl }; /** - * Throws if a leaf reduces into a resource with an operator its `reduce` trait doesn't declare, or also reads, writes - * or filters on it, or writes a resource that's reduced, which only its contributors' fold sets. + * Throws if a leaf reduces into a resource without a `reduce` trait, which gives the operator it folds with, or also + * reads, writes or filters on it, or writes a resource that's reduced, which only its contributors' fold sets. */ const checkReductions = (leaf: Leaf): void => { const { name } = leaf; - const { reduce, resources } = leaf.declaration; + const { resources } = leaf.declaration; const others = new Set([...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? []]); for (const key of reducedBy(leaf)) { - const declared = declarationOf(key)?.reduce; - const operator: unknown = Reflect.get(reduce ?? {}, key); - if (declared === undefined) throw new Error(`System "${name}" reduces "${labelOf(key)}", which has no reduce trait.`); - if (declared !== operator) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", but it's declared reduce: "${labelOf(declared)}".`); + if (declarationOf(key)?.reduce === undefined) throw new Error(`System "${name}" reduces "${labelOf(key)}", which has no reduce trait.`); if (others.has(key)) throw new Error(`System "${name}" reduces "${labelOf(key)}", so it can't also read, write or filter on it.`); } for (const key of resources?.write ?? []) { @@ -83,7 +79,7 @@ const checkPlacement = (leaf: Leaf): void => { if (order !== undefined) { const index = indexDefinitionOf(order); if (index === undefined) fail("orders its rows by a value Index.create didn't return"); - else if (index.declaration.sorted !== true) fail(`orders its rows by index "${labelOfItem(index)}", which isn't sorted`); + else if (index.declaration.sorted !== true) fail(`orders its rows by index "${index.name}", which isn't sorted`); if (components === undefined) fail("orders its rows without components, so it runs once"); if (up !== undefined) fail("uses both up and order, which both decide the order of its rows"); if (kernel === "gpu") fail("can't run on the GPU: GPU kernels have no order yet"); @@ -207,7 +203,7 @@ export const inputSchemaOf = (name: string, body: (...parameters: never[]) => vo */ const checkTarget = (leaf: Leaf): void => { const { name, declaration, input } = leaf; - const { target, components, kernel, up, order, reduce } = declaration; + const { target, components, kernel, up, order, resources } = declaration; if (target === undefined) return; const fail = (message: string): never => { throw new Error(`System "${name}" runs on one entity, so it ${message}.`); }; const field: Schema | undefined = input !== undefined && "properties" in input ? Reflect.get(input.properties, target) : undefined; @@ -215,7 +211,7 @@ const checkTarget = (leaf: Leaf): void => { if (components === undefined) fail("needs components"); if (kernel !== undefined) fail("isn't a kernel"); if (up !== undefined || order !== undefined) fail("has no up or order"); - if (reduce !== undefined) fail("reduces into nothing"); + if ((resources?.reduce?.length ?? 0) > 0) fail("reduces into nothing"); }; /** Every leaf under a system, each once. Throws for a member no System constructor returned. */ diff --git a/packages/ecs/src/interface/system/composite-declaration.ts b/packages/ecs/src/interface/system/composite-declaration.ts index f3c13922..07d0c1fa 100644 --- a/packages/ecs/src/interface/system/composite-declaration.ts +++ b/packages/ecs/src/interface/system/composite-declaration.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Name } from './name.js'; import type { System } from './system.js'; /** Each run passes every member the same input, with the fields of all their inputs. */ export type CompositeDeclaration = { - readonly name?: Name; readonly systems: readonly System[]; /** Hard constraint, applied to every member. */ readonly before?: readonly System[]; diff --git a/packages/ecs/src/interface/system/composite-input.type-test.ts b/packages/ecs/src/interface/system/composite-input.type-test.ts index a8d1ae94..93b08711 100644 --- a/packages/ecs/src/interface/system/composite-input.type-test.ts +++ b/packages/ecs/src/interface/system/composite-input.type-test.ts @@ -5,40 +5,40 @@ import { System } from './system.js'; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; const check = <_T extends true>() => { }; -const move = System.create({ name: "compositeInputTest/move" }, (_context, input: { readonly distance: number }) => { void input.distance; }); -const step = System.create({ name: "compositeInputTest/step" }, (_context, input: { readonly distance: number; readonly dt: number }) => { void input.dt; }); -const pause = System.create({ name: "compositeInputTest/pause" }, (_context, input: { readonly dt?: number }) => { void input.dt; }); -const label = System.create({ name: "compositeInputTest/label" }, (_context, input: { readonly distance: string }) => { void input.distance; }); -const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); +const move = System.create({}, (_context, input: { readonly distance: number }) => { void input.distance; }); +const step = System.create({}, (_context, input: { readonly distance: number; readonly dt: number }) => { void input.dt; }); +const pause = System.create({}, (_context, input: { readonly dt?: number }) => { void input.dt; }); +const label = System.create({}, (_context, input: { readonly distance: string }) => { void input.distance; }); +const idle = System.create({}, () => {}); // A composite takes every field its members' inputs have; a field in several is one value for all of them. -const frame = System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }); +const frame = System.create({ systems: [move, step, idle] }); check>>(); // A field optional in one member and required in another is required. -const paused = System.create({ name: "compositeInputTest/paused", systems: [pause, step] }); +const paused = System.create({ systems: [pause, step] }); check>>(); // Nested composites take their members' fields too. -const outer = System.create({ name: "compositeInputTest/outer", systems: [System.create({ name: "compositeInputTest/inner", systems: [move] }), pause] }); +const outer = System.create({ systems: [System.create({ systems: [move] }), pause] }); check>>(); -// A composite without a name or constraints is its list of members. +// A composite without constraints is its list of members. const listed = System.create([move, step, idle]); check>>(); const constrained = System.create({ systems: [pause], after: [listed] }); check>>(); // Without inputs, a composite takes none. -const quiet = System.create({ name: "compositeInputTest/quiet", systems: [idle] }); +const quiet = System.create({ systems: [idle] }); check>(); // @ts-expect-error a field with different types in two members is an error at the member -System.create({ name: "compositeInputTest/conflict", systems: [move, label] }); +System.create({ systems: [move, label] }); // @ts-expect-error in a list of members too System.create([move, label]); // @ts-expect-error a composite declaration lists its systems -System.create({ name: "compositeInputTest/empty", after: [move] }); +System.create({ after: [move] }); declare const world: World; void world.run(frame, { distance: 1, dt: 0.5 }); diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts index e32bdccc..14d23b0c 100644 --- a/packages/ecs/src/interface/system/conditions.type-test.ts +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -27,18 +27,18 @@ declare module '@adobe/ecs' { } // `is`: runs while each is neither null nor false, and reads it narrowed so. -System.create({ name: "conditionsTypeTest/loaded", resources: { is: [level, cache] } }, ({ resources }) => { +System.create({ resources: { is: [level, cache] } }, ({ resources }) => { check>(); check>>(); // @ts-expect-error a condition is read-only resources[level] = null; }); -System.create({ name: "conditionsTypeTest/playing", resources: { is: [paused] } }, ({ resources }) => { +System.create({ resources: { is: [paused] } }, ({ resources }) => { check>(); }); // `not`: runs while each is null or false; its value is known, so it isn't readable. -System.create({ name: "conditionsTypeTest/running", components: { write: [position] }, resources: { not: [paused] } }, (row, context) => { +System.create({ components: { write: [position] }, resources: { not: [paused] } }, (row, context) => { row[position] += 1; // @ts-expect-error a `not` condition isn't readable void context.resources; @@ -46,18 +46,18 @@ System.create({ name: "conditionsTypeTest/running", components: { write: [positi // Only a resource whose type allows null or false is a condition. // @ts-expect-error a number is never null or false -System.create({ name: "conditionsTypeTest/counted", resources: { is: [count] } }, () => {}); +System.create({ resources: { is: [count] } }, () => {}); // @ts-expect-error a value that isn't data and doesn't allow null is never null -System.create({ name: "conditionsTypeTest/handled", resources: { not: [handle] } }, () => {}); +System.create({ resources: { not: [handle] } }, () => {}); // A condition is listed once: `is` already makes it readable, and a written value would have two types. // @ts-expect-error both is and not -System.create({ name: "conditionsTypeTest/both", resources: { is: [paused], not: [paused] } }, () => {}); +System.create({ resources: { is: [paused], not: [paused] } }, () => {}); // @ts-expect-error is already makes it readable -System.create({ name: "conditionsTypeTest/read", resources: { is: [level], read: [level] } }, () => {}); +System.create({ resources: { is: [level], read: [level] } }, () => {}); // @ts-expect-error a condition isn't written -System.create({ name: "conditionsTypeTest/write", resources: { is: [level], write: [level] } }, () => {}); +System.create({ resources: { is: [level], write: [level] } }, () => {}); // Kernels and render systems take conditions too. -System.create({ name: "conditionsTypeTest/kernel", kernel: "cpu", components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); -System.render({ name: "conditionsTypeTest/draw", resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); +System.create({ kernel: "cpu", components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); +System.render({ resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 24f1deed..3d49933b 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; -import { definitionOf, firstItemOf, labelOfItem, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; +import { definitionOf, firstItemOf, idOrNull, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; import { identify } from "../registry/ids.test-helper.js"; import { clock, gpuDevice } from "../resources/index.js"; import { System } from "./system.js"; @@ -46,59 +46,44 @@ const createTaking = (declaration: object, input: unknown): unknown => Reflect.a describe("System.create", () => { it("records the system, its value holding its declaration and body", () => { const body = (): void => {}; - const move = System.create({ name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body); - expect(definitionOf(move)).toEqual({ kind: "system", name: "typesSystemTest/move", declaration: { name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body }); - expect(labelOfItem(move)).toBe("typesSystemTest/move"); - expect(definitionOf({ name: "typesSystemTest/move" })).toBe(undefined); + const move = System.create({ components: { write: [position] }, resources: { read: [time] } }, body); + expect(definitionOf(move)).toEqual({ kind: "system", name: "move", declaration: { components: { write: [position] }, resources: { read: [time] } }, body }); + expect(move.name).toBe("move"); + expect(definitionOf({ name: "move" })).toBe(undefined); }); - it("keeps systems of one name apart, labeling each by its name numbered", () => { - const first = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); - expect(labelOfItem(first)).toBe("typesSystemTest/twice"); - const again = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); - const other = System.create({ name: "typesSystemTest/twice", components: { write: [position] } }, row => { row[position] = 0; }); + it("labels each system by the binding or function it's made in, however many share the label", () => { + const twice = (): System => System.create({ components: { read: [position] } }, row => { void row[position]; }); + const [first, again] = [twice(), twice()]; expect(sameItem(first, again)).toBe(false); - expect([first, again, other].map(labelOfItem)).toEqual(["typesSystemTest/twice", "typesSystemTest/twice#2", "typesSystemTest/twice#3"]); - expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/twice", systems: [first, again] }))).tiers.flat()).toEqual([first, again]); + expect([first.name, again.name, idOrNull(first)]).toEqual(["twice", "twice", null]); + expect(scheduleOf(requireDefinition(System.create({ systems: [first, again] }))).tiers.flat()).toEqual([first, again]); }); - it("labels systems of one name by name and id, and takes a declaration of a registered id, the same, as the same system", () => { - const declare = (id: string, write: boolean) => System.create(identify({ name: "typesSystemTest/linked", components: write ? { write: [position] } : { read: [position] } }, id), () => {}); - const first = declare("a.ts#linked", false); - const elsewhere = declare("b.ts#linked", false); - expect([labelOfItem(first), labelOfItem(elsewhere)]).toEqual(["typesSystemTest/linked (a.ts#linked)", "typesSystemTest/linked (b.ts#linked)"]); - const again = declare("a.ts#linked", false); + it("labels a system by the binding of its id, which reports give beside it, and takes a declaration of a registered id, the same, as the same system", () => { + const [first, elsewhere, again] = ["a.ts#linked", "b.ts#linked", "a.ts#linked"].map(id => System.create(identify({ components: { read: [position] } }, id), () => {})); + expect([first?.name, elsewhere?.name, first === undefined ? null : idOrNull(first)]).toEqual(["linked", "linked", "a.ts#linked"]); expect(again).not.toBe(first); - expect([sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)]).toEqual([true, false, first]); - expect(labelOfItem(again)).toBe("typesSystemTest/linked (a.ts#linked)"); - const write = System.create({ name: "typesSystemTest/afterLinked", components: { write: [position] }, after: [again] }, row => { row[position] = 0; }); - const tiers = scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/linkedFrame", systems: [write, first, again] }))).tiers; - expect(tiers).toEqual([[first], [write]]); - expect(() => declare("a.ts#linked", true)).toThrow('Cannot declare "typesSystemTest/linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); - expect(() => System.create(identify({ name: "typesSystemTest/linked", systems: [first] }, "a.ts#linked"))).toThrow("its id is a system's"); - expect(labelOfItem(declare("c.ts#linked", false))).toBe("typesSystemTest/linked (c.ts#linked)"); + expect(first !== undefined && again !== undefined && elsewhere !== undefined ? [sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)] : null).toEqual([true, false, first]); + expect(() => ["a.ts#linked"].map(id => System.create(identify({ components: { write: [position] } }, id), () => {}))).toThrow('Cannot declare "linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + expect(() => ["a.ts#linked"].map(id => first === undefined ? null : System.create(identify({ systems: [first] }, id)))).toThrow("its id is a system's"); }); - it("names a system declared without a name by its binding and id, or else by its kind numbered", () => { + it("labels a system made with neither an id nor a binding by its kind numbered", () => { const body = ({ commands }: { readonly commands: System.Commands }): void => { commands.spawn([position])({ [position]: 0 }); }; - const alone = System.create(identify(body, "setup.ts#spawnOnce")); - expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce (setup.ts#spawnOnce)", declaration: {}, body }); - expect(labelOfItem(alone)).toBe("spawnOnce (setup.ts#spawnOnce)"); - expect(sameItem(System.create(identify(body, "setup.ts#spawnOnce")), alone)).toBe(true); - const show = System.create(identify({ components: { read: [position] } }, "show.ts#show"), row => { void row[position]; }); - expect(labelOfItem(show)).toBe("show (show.ts#show)"); - const [first, second] = [System.create(() => {}), System.create({ systems: [show] })].map(labelOfItem); + const [alone] = [System.create(identify(body, "setup.ts#spawnOnce"))]; + expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce", declaration: {}, body }); + const [first, second] = [System.create(() => {}), System.create({ systems: [alone] })].map(system => system.name); const number = Number(first?.slice("system #".length)); expect([first, second]).toEqual([`system #${number}`, `system #${number + 1}`]); - expect(labelOfItem(System.render({ shader: { accessors: {} } }, () => {}))).toBe(`system #${number + 2}`); - expect(() => create({ components: { read: [position], write: [position] } })).toThrow(`System "system #${number + 3}" lists "typesSystemTest/position" in both components read and write`); + expect([System.render({ shader: { accessors: {} } }, () => {})].map(system => system.name)).toEqual([`system #${number + 2}`]); + expect(() => create({ components: { read: [position], write: [position] } })).toThrow(`lists "typesSystemTest/position" in both components read and write`); }); it("rejects writing what only the world writes, for untyped callers", () => { - expect(() => create({ name: "typesSystemTest/clock", resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(() => create({ resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); }); it("rejects what types reject, for untyped callers", () => { - expect(() => create({ name: "typesSystemTest/both", components: { read: [position], write: [position] } })).toThrow("in both components read and write"); - expect(() => create({ name: "typesSystemTest/bothResources", resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); - expect(() => create({ name: "typesSystemTest/lookup", components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); - expect(() => createTaking({ name: "typesSystemTest/input" }, { type: "number" })).toThrow("input is an object schema of named fields"); - expect(labelOfItem(System.create({ name: "typesSystemTest/both" }, () => {}))).toBe("typesSystemTest/both"); + expect(() => create({ components: { read: [position], write: [position] } })).toThrow("in both components read and write"); + expect(() => create({ resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); + expect(() => create({ components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); + expect(() => createTaking({}, { type: "number" })).toThrow("input is an object schema of named fields"); }); it("rejects at creation what the registry rules out, for untyped callers", () => { const rejected: readonly (readonly [object, string])[] = [ @@ -106,10 +91,9 @@ describe("System.create", () => { [{ components: { read: [position] }, lookup: { others: { read: [position], not: [unregistered] } } }, `uses component "typesSystemTest/unregistered", which isn't a registered component`], [{ components: { read: [position] }, lookup: { others: { read: [position], changed: [height] } } }, `lookup "others" watches "typesSystemTest/height", which it neither reads nor has`], [{ components: { write: [parent] } }, `writes "typesSystemTest/parent", a reference, which changes only through commands`], - [{ components: { has: [position] }, reduce: { [total]: "max" } }, `reduces "typesSystemTest/total" with "max", but it's declared reduce: "+"`], - [{ components: { has: [position] }, reduce: { [time]: "+" } }, `reduces "typesSystemTest/time", which has no reduce trait`], + [{ components: { has: [position] }, resources: { reduce: [time] } }, `reduces "typesSystemTest/time", which has no reduce trait`], [{ resources: { write: [total] } }, `writes "typesSystemTest/total", which is reduced`], - [{ components: { has: [position] }, resources: { read: [total] }, reduce: { [total]: "+" } }, `reduces "typesSystemTest/total", so it can't also read, write or filter on it`], + [{ components: { has: [position] }, resources: { read: [total], reduce: [total] } }, `reduces "typesSystemTest/total", so it can't also read, write or filter on it`], [{ resources: { is: [time] } }, `runs on the condition of "typesSystemTest/time", which is never null or false`], [{ resources: { not: [position] } }, `runs on the condition of "typesSystemTest/position", which isn't a registered resource`], [{ resources: { is: [paused], not: [paused] } }, `lists "typesSystemTest/paused" in both resources is and not`], @@ -118,105 +102,100 @@ describe("System.create", () => { [{ up: { through: parent } }, "uses up, which orders rows, without components"], [{ kernel: "gpu", components: { write: [position], changed: [position] } }, "can't run on the GPU: GPU kernels don't support change filters yet"], [{ kernel: "gpu", components: { read: [position] }, lookup: { others: { read: [position] } } }, "can't run on the GPU: GPU kernels don't support lookups yet"], - [{ kernel: "gpu", components: { has: [position] }, reduce: { [total]: "+" } }, "can't run on the GPU: GPU kernels don't support reductions yet"], + [{ kernel: "gpu", components: { has: [position] }, resources: { reduce: [total] } }, "can't run on the GPU: GPU kernels don't support reductions yet"], [{ observer: false, resources: { read: [time] } }, "observer is true or left out"], [{ observer: true, components: { write: [position] } }, "is an observer, so it writes nothing"], [{ observer: true, resources: { write: [time] } }, "is an observer, so it writes nothing"], - [{ observer: true, components: { has: [position] }, reduce: { [total]: "+" } }, "is an observer, so it reduces into nothing"], + [{ observer: true, components: { has: [position] }, resources: { reduce: [total] } }, "is an observer, so it reduces into nothing"], [{ observer: true, kernel: "cpu", components: { read: [position] } }, "is an observer, so it isn't a kernel"], [{ observer: true, components: { read: [position] }, up: { through: parent } }, "is an observer, so it has no up"], [{ observer: true, resources: { read: [time] }, after: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], [{ observer: true, resources: { read: [time] }, before: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], [{ observer: true, resources: { read: [time] }, during: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], ]; - expect(() => createTaking({ name: "typesSystemTest/observing", observer: true }, { type: "object", properties: {} })).toThrow("is an observer, so it takes no input"); + expect(() => createTaking({ observer: true }, { type: "object", properties: {} })).toThrow("is an observer, so it takes no input"); rejected.forEach(([declaration, message], index) => { - expect(() => create({ name: `typesSystemTest/rejected/${index}`, ...declaration })).toThrow(message); - // A rejected system records nothing, so its name labels only the next. - expect(labelOfItem(System.create({ name: `typesSystemTest/rejected/${index}` }, () => {}))).toBe(`typesSystemTest/rejected/${index}`); + expect(() => create({ ...declaration })).toThrow(message); }); }); it("derives its thread from its object components and resources, and rejects two", () => { - const show = System.create({ name: "typesSystemTest/show", components: { read: [position, node] }, resources: { read: [time, document] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); - const count = System.create({ name: "typesSystemTest/count", components: { read: [tags], has: [node] } }, row => { void row[tags]; }); - const tick = System.create({ name: "typesSystemTest/tick", components: { write: [position] } }, row => { row[position] += 1; }); + const show = System.create({ components: { read: [position, node] }, resources: { read: [time, document] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); + const count = System.create({ components: { read: [tags], has: [node] } }, row => { void row[tags]; }); + const tick = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); const leaves = [show, count, tick].map(system => definitionOf(system)); expect(leaves.map(leaf => leaf === undefined || leaf.kind === "composite" ? undefined : threadOf(leaf.declaration, "System", leaf.name))).toEqual(["main", "world", null]); - expect(() => create({ name: "typesSystemTest/span", components: { read: [tags], write: [node] } })).toThrow('System "typesSystemTest/span" uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); - expect(() => create({ name: "typesSystemTest/spanResource", components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); + expect(() => create({ components: { read: [tags], write: [node] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); + expect(() => create({ components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); // A string lives in shared memory, so it goes with any thread's objects. - const caption = System.create({ name: "typesSystemTest/caption", components: { read: [label, node] } }, row => { row[node].textContent = row[label]; }); + const caption = System.create({ components: { read: [label, node] } }, row => { row[node].textContent = row[label]; }); const captionLeaf = definitionOf(caption); expect(captionLeaf === undefined || captionLeaf.kind === "composite" ? undefined : threadOf(captionLeaf.declaration, "System", captionLeaf.name)).toBe("main"); - expect(() => create({ name: "typesSystemTest/unregistered", components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); - expect(() => create({ name: "typesSystemTest/wrongKind", components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); + expect(() => create({ components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); + expect(() => create({ components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); }); }); describe("System.create of a composite", () => { it("records a composite whose members may live on different threads, from its list or its declaration", () => { - const show = System.create({ name: "typesSystemTest/showMember", components: { read: [node] } }, row => { row[node].hidden = true; }); - const name = System.create({ name: "typesSystemTest/nameMember", components: { write: [label] } }, row => { row[label] = ""; }); - const frame = System.create({ name: "typesSystemTest/frame", systems: [show, name] }); + const show = System.create({ components: { read: [node] } }, row => { row[node].hidden = true; }); + const name = System.create({ components: { write: [label] } }, row => { row[label] = ""; }); + const frame = System.create({ systems: [show, name] }); expect(definitionOf(frame)?.kind).toBe("composite"); - expect(definitionOf(System.create({ name: "typesSystemTest/frame", systems: [show] }))).not.toBe(definitionOf(frame)); + expect(definitionOf(System.create({ systems: [show] }))).not.toBe(definitionOf(frame)); const listed = System.create([show, name]); const listedDefinition = definitionOf(listed); expect(listedDefinition?.kind === "composite" ? listedDefinition.declaration.systems : undefined).toEqual([show, name]); - expect(listed.name).toMatch(/^system #\d+$/); + expect(listed.name).toBe("listed"); }); it("tells a composite's declaration from a system's by its body", () => { const untyped: (...parameters: never[]) => unknown = System.create; - expect(() => Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/bodiless" }])).toThrow("A system needs a body, and a composite its systems."); - const leaf = Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/leafWithSystems", systems: [] }, () => {}]); + expect(() => Reflect.apply(untyped, undefined, [{ name: "bodiless" }])).toThrow("A system needs a body, and a composite its systems."); + const leaf = Reflect.apply(untyped, undefined, [{ name: "leafWithSystems", systems: [] }, () => {}]); expect(definitionOf(leaf)?.kind).toBe("system"); }); it("rejects members ordered in a cycle, and conflicting members with no order between them", () => { - const first = System.create({ name: "typesSystemTest/first", components: { write: [position] } }, row => { row[position] += 1; }); - const second = System.create({ name: "typesSystemTest/second", components: { read: [position] }, after: [first] }, row => { void row[position]; }); - const third = System.create({ name: "typesSystemTest/third", components: { read: [position] } }, row => { void row[position]; }); - expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/ordered", systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/first"], ["typesSystemTest/second"]]); - expect(() => System.create({ name: "typesSystemTest/unordered", systems: [first, third] })).toThrow('Systems "typesSystemTest/first" and "typesSystemTest/third" both access "typesSystemTest/position" and one writes it'); - const looped = System.create({ name: "typesSystemTest/looped", before: [first], after: [second] }, () => {}); - expect(() => System.create({ name: "typesSystemTest/cycle", systems: [first, second, looped] })).toThrow("Ordering cycle"); - expect(labelOfItem(System.create({ name: "typesSystemTest/unordered", systems: [first] }))).toBe("typesSystemTest/unordered"); + const first = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); + const second = System.create({ components: { read: [position] }, after: [first] }, row => { void row[position]; }); + const third = System.create({ components: { read: [position] } }, row => { void row[position]; }); + expect(scheduleOf(requireDefinition(System.create({ systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["first"], ["second"]]); + expect(() => System.create({ systems: [first, third] })).toThrow('Systems "first" and "third" both access "typesSystemTest/position" and one writes it'); + const looped = System.create({ before: [first], after: [second] }, () => {}); + expect(() => System.create({ systems: [first, second, looped] })).toThrow("Ordering cycle"); }); it("runs observer members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { - const write = System.create({ name: "typesSystemTest/observerWriter", components: { write: [position] } }, row => { row[position] += 1; }); - const watch = System.create({ name: "typesSystemTest/observer", observer: true, components: { read: [position] } }, row => { void row[position]; }); - const later = System.create({ name: "typesSystemTest/afterObserver", components: { read: [position] }, after: [watch, write] }, row => { void row[position]; }); - const free = System.create({ name: "typesSystemTest/afterObserverOnly", resources: { read: [time] }, after: [watch] }, ({ resources }) => { void resources[time]; }); + const write = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); + const watch = System.create({ observer: true, components: { read: [position] } }, row => { void row[position]; }); + const later = System.create({ components: { read: [position] }, after: [watch, write] }, row => { void row[position]; }); + const free = System.create({ resources: { read: [time] }, after: [watch] }, ({ resources }) => { void resources[time]; }); const schedule = scheduleOf(requireDefinition(System.create([write, watch, later, free]))); - expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/observerWriter", "typesSystemTest/afterObserverOnly"], ["typesSystemTest/afterObserver"]]); - expect(schedule.observer.map(leaf => leaf.name)).toEqual(["typesSystemTest/observer"]); + expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["write", "free"], ["later"]]); + expect(schedule.observer.map(leaf => leaf.name)).toEqual(["watch"]); const alone = scheduleOf(requireDefinition(watch)); - expect([alone.tiers.length, alone.observer.map(leaf => leaf.name)]).toEqual([0, ["typesSystemTest/observer"]]); + expect([alone.tiers.length, alone.observer.map(leaf => leaf.name)]).toEqual([0, ["watch"]]); }); it("rejects a member no constructor returned, and an input field two members declare differently", () => { - expect(() => System.create({ name: "typesSystemTest/forged", systems: [{ name: "typesSystemTest/showMember" }] })).toThrow('member "typesSystemTest/showMember" isn\'t a system a System constructor returned'); - const move = System.create({ name: "typesSystemTest/moveBy" }, (_context, input: { readonly distance: number }) => {}); - const tag = System.create({ name: "typesSystemTest/tagBy" }, (_context, input: { readonly distance: string }) => {}); - const nested = System.create({ name: "typesSystemTest/nested", systems: [tag] }); + expect(() => System.create({ systems: [{ name: "showMember" }] })).toThrow('member "showMember" isn\'t a system a System constructor returned'); + const move = System.create({}, (_context, input: { readonly distance: number }) => {}); + const tag = System.create({}, (_context, input: { readonly distance: string }) => {}); + const nested = System.create({ systems: [tag] }); const untyped: (...parameters: never[]) => unknown = System.create; const composite = (declaration: object): unknown => Reflect.apply(untyped, undefined, [declaration]); - expect(() => composite({ name: "typesSystemTest/conflict", systems: [move, nested] })).toThrow('Systems "typesSystemTest/moveBy" and "typesSystemTest/tagBy" declare input field "distance" with different schemas.'); - expect(labelOfItem(System.create({ name: "typesSystemTest/conflict", systems: [move] }))).toBe("typesSystemTest/conflict"); + expect(() => composite({ systems: [move, nested] })).toThrow('Systems "move" and "tag" declare input field "distance" with different schemas.'); }); }); describe("System.render", () => { it("reads the components its accessors name, once each, and runs only while gpuDevice is set", () => { - const draw = System.render({ name: "typesSystemTest/draw", shader: { accessors: { place: height, again: height } } }, () => {}); + const draw = System.render({ shader: { accessors: { place: height, again: height } } }, () => {}); const definition = definitionOf(draw); expect(definition?.kind === "render" ? [definition.declaration.components, definition.declaration.resources] : null).toEqual([{ read: [height] }, { is: [gpuDevice] }]); const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); - expect(() => render({ name: "typesSystemTest/drawDocument", resources: { read: [document] }, shader: { accessors: {} } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); + expect(() => render({ resources: { read: [document] }, shader: { accessors: {} } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); }); it("rejects at creation accessors its shader can't read, and reductions", () => { const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); - expect(() => System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: { loop: height } } }, () => {})).toThrow(`System "typesSystemTest/drawReserved" can't render: "loop" isn't usable as a WGSL function name.`); - expect(() => render({ name: "typesSystemTest/drawReduced", shader: { accessors: {} }, reduce: { [total]: "+" } })).toThrow("can't reduce: a render system records GPU work rather than running rows"); - expect(() => render({ name: "typesSystemTest/drawObserver", shader: { accessors: {} }, observer: true })).toThrow("is an observer, so it records no GPU work"); - expect(labelOfItem(System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: {} } }, () => {}))).toBe("typesSystemTest/drawReserved"); + expect(() => System.render({ shader: { accessors: { loop: height } } }, () => {})).toThrow(`can't render: "loop" isn't usable as a WGSL function name.`); + expect(() => render({ shader: { accessors: {} }, resources: { reduce: [total] } })).toThrow("can't reduce: a render system records GPU work rather than running rows"); + expect(() => render({ shader: { accessors: {} }, observer: true })).toThrow("is an observer, so it records no GPU work"); }); }); diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 61c71e43..2a59a49e 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -20,7 +20,7 @@ import type { System } from './system.js'; import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; import type { HeldBy } from './values.js'; -type Reduced = keyof Property & Resource.Key; +type Reduced = Listed, "reduce"> & Resource.Key; type Written = Listed, "write">; type Is = Listed, "is">; type Lookups = Property; @@ -56,6 +56,7 @@ type Lists = { readonly write?: readonly Exclude>[]; readonly changed?: readonly Exclude>[]; readonly not?: readonly Exclude>[]; + readonly reduce?: readonly Exclude | Is>[]; }; }; @@ -75,8 +76,7 @@ type Kernel = { type GpuKernel = { readonly lookup?: { readonly [name: string]: never }; readonly components?: { readonly added?: never; readonly changed?: never }; - readonly resources?: { readonly changed?: never }; - readonly reduce?: { readonly [K in Resource.Key]?: never }; + readonly resources?: { readonly changed?: never; readonly reduce?: never }; readonly order?: { readonly "error: GPU kernels have no order yet": true }; }; @@ -87,8 +87,7 @@ type GpuKernel = { type Observer = { readonly kernel?: never; readonly components?: { readonly write?: never }; - readonly resources?: { readonly write?: never }; - readonly reduce?: never; + readonly resources?: { readonly write?: never; readonly reduce?: never }; readonly up?: never; readonly before?: never; readonly after?: never; @@ -100,7 +99,7 @@ type Targeted = { readonly kernel?: never; readonly up?: never; readonly order?: never; - readonly reduce?: never; + readonly resources?: { readonly reduce?: never }; }; type Validate = @@ -153,7 +152,7 @@ type Checked = C extends Alone ? unknown : C extends CompositeDeclaration ? { * - `create(body)`: a system that declares nothing, whose body runs once per run with its commands. * - `create(declaration, body)`: a system. * - `create([move, collide])`: a composite of the systems listed, which may live on different threads. - * - `create({ systems: [move, collide], after: [input] })`: a composite with a name or constraints. + * - `create({ systems: [move, collide], after: [input] })`: a composite with constraints. * * Each run passes a composite's members one input, with every field of theirs. Throws if a system names a key that * isn't registered as its kind, lists a key in both `read` and `write`, writes a reference, a lookup reads a component @@ -174,12 +173,12 @@ export function create(first: Declaration | Leaf["body"] | readonly System = { kind: "system", name, declaration, input: schema, body }; checkLeaf(system); @@ -189,8 +188,9 @@ export function create(first: Declaration | Leaf["body"] | readonly System[] => Array.isArray(value); -const compose = (declaration: CompositeDeclaration, id: string | undefined): System => { - const system: Composite & System = { kind: "composite", name: nameOf(declaration.name, id, "system"), declaration }; +/** A composite of `declaration`, labeled by `first`, the argument the plugin labeled. */ +const compose = (first: object, declaration: CompositeDeclaration, id: string | undefined): System => { + const system: Composite & System = { kind: "composite", name: nameOf(first, id, "system"), declaration }; checkComposite(system); claimSystem(system, id); return system; diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 362f49e2..624e41c3 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -2,10 +2,8 @@ import type { Index } from '../indexes/index-type.js'; import type { Resource } from '../resource/resource.js'; import type { ComponentAccess, ResourceAccess, SystemLookup, Up } from './access.js'; -import type { Name } from './name.js'; import type { Shader } from './render.js'; import type { System } from './system.js'; -import type { OperatorOf, Reduced } from '../resource/reduction.js'; /** * A system never declares its thread: it runs on the one its object components and resources live on, those it @@ -13,7 +11,6 @@ import type { OperatorOf, Reduced } from '../resource/reduction.js'; * Without them, a kernel runs on the kernel pool and any other system on `"world"`. */ export type Declaration = { - readonly name?: Name; /** * Compiled to a kernel, which the build rejects if the body leaves the kernel subset. Values in `components` and * `lookup` must be structs and resources read-only. Without it, the system runs as written, on its thread. @@ -39,8 +36,6 @@ export type Declaration = { */ readonly order?: Index<{ readonly sorted: true }>; readonly resources?: ResourceAccess; - /** The resources its body contributes to, each with the operator its `reduce` trait declares. */ - readonly reduce?: { readonly [K in Reduced]?: OperatorOf }; /** * The field of its body's input naming the one entity it runs on: its body runs once, on that entity's row, when * the entity is alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, diff --git a/packages/ecs/src/interface/system/name.ts b/packages/ecs/src/interface/system/name.ts index ef87e3b5..a5b7d02d 100644 --- a/packages/ecs/src/interface/system/name.ts +++ b/packages/ecs/src/interface/system/name.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + * The label reports and errors give it, for display: its binding, from the id the plugin gave it; for one made in a + * function, the binding or function the plugin found it in; else its kind numbered. The value its constructor returns + * is its identity, so labels needn't be unique. */ export type Name = string; diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index d99130ff..00ce30be 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -12,7 +12,6 @@ import type { Body } from './body.js'; import { accessorsOf } from '../gpu/accessors.js'; import { checkLeaf } from './check.js'; import type { Leaf } from './leaf.js'; -import type { Name } from './name.js'; import type { StructComponent } from './struct.js'; import type { System } from './system.js'; import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; @@ -43,7 +42,6 @@ export type RenderContext = { }; type RenderDeclaration = { - readonly name?: Name; /** It reads the components its accessors name. */ readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** It runs only while `gpuDevice` is set, as if `is` listed it. */ @@ -77,7 +75,7 @@ export const render = ( body: Body>, ): System => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "system"); + const name = nameOf(declaration, id, "system"); const system: Leaf & System = { kind: "render", name, diff --git a/packages/ecs/src/interface/system/system.ts b/packages/ecs/src/interface/system/system.ts index 72f2c3cf..daca38d7 100644 --- a/packages/ecs/src/interface/system/system.ts +++ b/packages/ecs/src/interface/system/system.ts @@ -2,8 +2,8 @@ declare const input: unique symbol; /** - * A system, the value a System constructor returned: identified by that value and labeled by its name. `run` takes it - * with a value of type `I`, or with nothing when `I` is `undefined`. + * A system, the value a System constructor returned: identified by that value and labeled by `name`, the binding it's + * declared at. `run` takes it with a value of type `I`, or with nothing when `I` is `undefined`. */ export type System = { readonly name: string; readonly [input]?: I }; diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 3536c554..41ed5989 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -38,7 +38,6 @@ declare module '@adobe/ecs' { // Row: read values are read-only, write values writable, others absent. export const integrate = System.create({ - name: "test/integrate", components: { read: [velocity], write: [position], has: [player] }, resources: { read: [time] }, }, (row, { resources: { [time]: delta } }) => { @@ -54,14 +53,12 @@ export const integrate = System.create({ // Write grants read: a component in write is readable, and listing it in read as well is rejected. System.create({ - name: "test/integrate", // @ts-expect-error write already makes it readable components: { read: [position], write: [position] }, }, () => {}); // Lookups: read-only values by entity, or null. export const follow = System.create({ - name: "test/follow", components: { read: [target, position], write: [velocity] }, lookup: { targets: { read: [position] } }, after: [integrate], @@ -81,7 +78,6 @@ export const follow = System.create({ // A lookup by entity with `changed`: its row also says whether what `changed` names may have changed since the system // last ran. System.create({ - name: "test/followChanged", components: { read: [target], write: [velocity] }, lookup: { targets: { read: [position], changed: [position] }, players: { has: [player], changed: [player] } }, }, (row, { lookup: { targets, players } }) => { @@ -97,7 +93,6 @@ System.create({ }); System.create({ - name: "test/followChanged", components: { read: [target], write: [velocity] }, // @ts-expect-error a lookup by entity's changed names what it reads or has lookup: { targets: { read: [position], changed: [velocity] } }, @@ -105,7 +100,6 @@ System.create({ // Input: the type of the body's parameter after its context. export const move = System.create({ - name: "test/move", components: { has: [player], write: [position] }, }, (row, _context, input: { readonly distance: number }) => { row[position] += input.distance; @@ -125,7 +119,6 @@ System.create({ input: { type: "object", properties: {} } }, () => {}); // A system without components takes only its context; resource writes are writable, and written ones readable. export const tally = System.create({ - name: "test/tally", resources: { read: [time], write: [score] }, }, ({ resources, commands }) => { resources[score] += resources[time]; @@ -138,7 +131,7 @@ export const tally = System.create({ }); // The context holds only what the declaration declares. -System.create({ name: "test/tally" }, context => { +System.create({}, context => { // @ts-expect-error no resources declared void context.resources; // @ts-expect-error no lookups declared @@ -152,28 +145,26 @@ System.create({ name: "test/tally" }, context => { // A system without components has no row: its second parameter is its input. check>>(); -const alone = System.create({ name: "test/tally" }, (_context, input: { readonly count: number }) => { void input.count; }); +const alone = System.create({}, (_context, input: { readonly count: number }) => { void input.count; }); System.create({ - name: "test/tally", // @ts-expect-error write already makes a resource readable resources: { read: [score], write: [score] }, }, () => {}); System.create({ - name: "test/integrate", // @ts-expect-error ordering takes systems after: ["test/unregistered"], }, () => {}); // @ts-expect-error a system with input can't use change filters -System.create({ name: "test/move", components: { changed: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +System.create({ components: { changed: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); // @ts-expect-error a system with input can't use change filters -System.create({ name: "test/move", components: { added: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); -System.create({ name: "test/move", components: { changed: [position] } }, row => { void row; }); +System.create({ components: { added: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +System.create({ components: { changed: [position] } }, row => { void row; }); -export const main = System.create({ name: "test/main", systems: [integrate, follow, tally] }); -const moving = System.create({ name: "test/moving", systems: [move] }); +export const main = System.create({ systems: [integrate, follow, tally] }); +const moving = System.create({ systems: [move] }); // A composite takes its members' input. check>>(); @@ -198,35 +189,30 @@ declare module '@adobe/ecs' { // Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. System.create({ - name: "test/integrate", kernel: "cpu", components: { read: [velocity, vector, player, target], write: [position], has: [label] }, resources: { read: [time] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "cpu", // @ts-expect-error kernel values must be linear components: { read: [label] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "cpu", // @ts-expect-error kernel values can't be variable-length arrays components: { write: [path] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "cpu", // @ts-expect-error kernel values can't be sets or maps components: { read: [marks] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "cpu", // @ts-expect-error kernel lookups must read linear values lookup: { labels: { read: [label] } }, @@ -236,7 +222,6 @@ declare const byTarget: Index<{ readonly key: readonly [typeof target] }>; // A kernel's lookup by entity matches has and not, as any system's does. System.create({ - name: "test/followPlayers", kernel: "cpu", components: { read: [target], write: [position] }, lookup: { players: { read: [position], has: [player], not: [velocity] } }, @@ -246,14 +231,12 @@ System.create({ }); System.create({ - name: "test/followPlayers", kernel: "cpu", // @ts-expect-error an index lookup matches what its index holds, so it takes no has lookup: { byTarget: { index: byTarget, read: [position], has: [player] } }, }, () => {}); System.create({ - name: "test/tally", kernel: "cpu", // @ts-expect-error kernels can't write resources resources: { write: [score] }, @@ -261,7 +244,6 @@ System.create({ // GPU kernels: everything kernels can't do, and no lookups, change filters or commands. System.create({ - name: "test/integrate", kernel: "gpu", components: { read: [velocity], write: [position] }, resources: { read: [time] }, @@ -270,14 +252,12 @@ System.create({ }); System.create({ - name: "test/integrate", kernel: "gpu", // @ts-expect-error GPU kernel values must be linear too components: { read: [label] }, }, () => {}); System.create({ - name: "test/follow", kernel: "gpu", components: { read: [target], write: [position] }, // @ts-expect-error GPU kernels can't use lookups @@ -285,14 +265,12 @@ System.create({ }, () => {}); System.create({ - name: "test/integrate", kernel: "gpu", // @ts-expect-error GPU kernels can't use change filters components: { write: [position], changed: [velocity] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "gpu", components: { read: [velocity], write: [position] }, }, (row, context) => { @@ -301,16 +279,14 @@ System.create({ }); System.create({ - name: "test/integrate", // @ts-expect-error a kernel is "cpu" or "gpu" kernel: true, }, () => {}); // Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. System.create({ - name: "test/tally", components: { read: [velocity, vector], changed: [velocity] }, - reduce: { [total]: "+", [bounds]: "min", [flagged]: "|" }, + resources: { reduce: [total, bounds, flagged] }, }, (row, { reduce }) => { reduce[total](row[velocity]); reduce[bounds](row[vector]); @@ -322,49 +298,37 @@ System.create({ }); System.create({ - name: "test/tally", - // @ts-expect-error the operator must equal the resource's reduce trait - reduce: { [total]: "max" }, -}, () => {}); - -System.create({ - name: "test/tally", // @ts-expect-error only resources with a reduce trait can be reduced - reduce: { [score]: "+" }, + resources: { reduce: [score] }, }, () => {}); System.create({ - name: "test/tally", // @ts-expect-error a reduced resource is never written resources: { write: [total] }, }, () => {}); System.create({ - name: "test/tally", // @ts-expect-error a contributor doesn't read what it reduces into - resources: { read: [total] }, - reduce: { [total]: "+" }, + resources: { read: [total], reduce: [total] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "gpu", components: { read: [velocity] }, // @ts-expect-error GPU kernels can't reduce yet - reduce: { [total]: "+" }, + resources: { reduce: [total] }, }, () => {}); // Render systems: the accessors name the components they read. -System.render({ name: "test/draw", components: { has: [player] }, resources: { read: [time] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { +System.render({ components: { has: [player] }, resources: { read: [time] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { void [gpu.encoder, resources[time]]; }); -System.render({ name: "test/draw", shader: { accessors: { place: position } } }, (_, context) => { +System.render({ shader: { accessors: { place: position } } }, (_, context) => { // @ts-expect-error a render body issues no commands void context.commands; }); System.render({ - name: "test/draw", // @ts-expect-error a render system reads the components its accessors name components: { read: [position] }, shader: { accessors: { place: position } }, @@ -377,7 +341,7 @@ const drawTable = (table: System.RenderTable, pass: GPURenderPassEncoder): void table.bind(pass); pass.draw(6, table.count); }; -System.render({ name: "test/drawShared", shader: { accessors: { place: position } } }, gpu => { +System.render({ shader: { accessors: { place: position } } }, gpu => { const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); drawAll(gpu, pass); pass.end(); @@ -386,25 +350,22 @@ System.render({ name: "test/drawShared", shader: { accessors: { place: position declare const byPosition: Index<{ readonly key: readonly [typeof position]; readonly sorted: true }>; declare const hashedByPosition: Index<{ readonly key: readonly [typeof position] }>; declare const someIndex: Index; -System.render({ name: "test/drawOrdered", components: { has: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { +System.render({ components: { has: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); drawAll(gpu, pass); pass.end(); }); System.render({ - name: "test/drawOrdered", // @ts-expect-error a render system is ordered by a sorted index order: hashedByPosition, shader: { accessors: { place: position } }, }, () => {}); System.render({ - name: "test/drawOrdered", // @ts-expect-error an index whose declaration isn't known isn't known to be sorted order: someIndex, shader: { accessors: { place: position } }, }, () => {}); System.render({ - name: "test/drawOrdered", // @ts-expect-error an order is an index order: [position], shader: { accessors: { place: position } }, @@ -434,7 +395,6 @@ System.render({ shader: { accessors: { place: position } } }, gpu => { void gpu. // Observer systems read what a sync point left, after it: rows, resources, lookups and change filters, and no commands. export const watchPositions = System.create({ - name: "test/watchPositions", observer: true, components: { read: [position], changed: [position] }, resources: { read: [time] }, @@ -459,7 +419,7 @@ System.create({ observer: true, components: { write: [position] } }, () => {}); // @ts-expect-error an observer system writes no resources System.create({ observer: true, resources: { write: [score] } }, () => {}); // @ts-expect-error an observer system reduces into nothing -System.create({ observer: true, components: { read: [velocity] }, reduce: { [total]: "+" } }, () => {}); +System.create({ observer: true, components: { read: [velocity] }, resources: { reduce: [total] } }, () => {}); // @ts-expect-error an observer system takes no input System.create({ observer: true, resources: { read: [time] } }, (_context, input: { readonly count: number }) => { void input; }); // @ts-expect-error an observer system isn't a kernel diff --git a/packages/ecs/src/interface/system/threads.type-test.ts b/packages/ecs/src/interface/system/threads.type-test.ts index fbea0375..0462fc06 100644 --- a/packages/ecs/src/interface/system/threads.type-test.ts +++ b/packages/ecs/src/interface/system/threads.type-test.ts @@ -36,72 +36,72 @@ declare module '@adobe/ecs' { } // A system runs on the thread of the object components and resources it uses; strings and structs go with any. -System.create({ name: "threadsTypeTest/render", components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); -System.create({ name: "threadsTypeTest/play", components: { read: [audio, position] } }, () => {}); -System.create({ name: "threadsTypeTest/label", components: { write: [label] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); +System.create({ components: { read: [audio, position] } }, () => {}); +System.create({ components: { write: [label] }, resources: { is: [settings] } }, () => {}); // Presence-only lists don't place a system. -System.create({ name: "threadsTypeTest/count", components: { read: [label], has: [node], not: [audio] }, resources: { read: [time] } }, () => {}); -System.create({ name: "threadsTypeTest/paused", components: { read: [node] }, resources: { not: [settings] } }, () => {}); -System.create({ name: "threadsTypeTest/caption", components: { read: [label], write: [node] } }, () => {}); -System.create({ name: "threadsTypeTest/captionChanged", components: { read: [node], changed: [label] }, lookup: { labels: { read: [label] } } }, () => {}); +System.create({ components: { read: [label], has: [node], not: [audio] }, resources: { read: [time] } }, () => {}); +System.create({ components: { read: [node] }, resources: { not: [settings] } }, () => {}); +System.create({ components: { read: [label], write: [node] } }, () => {}); +System.create({ components: { read: [node], changed: [label] }, lookup: { labels: { read: [label] } } }, () => {}); // @ts-expect-error a system's object components live on one thread -System.create({ name: "threadsTypeTest/both", components: { read: [node, tags] } }, () => {}); +System.create({ components: { read: [node, tags] } }, () => {}); // @ts-expect-error writes and change filters place it too -System.create({ name: "threadsTypeTest/changed", components: { write: [tags], changed: [audio] } }, () => {}); +System.create({ components: { write: [tags], changed: [audio] } }, () => {}); // @ts-expect-error a lookup's reads place it too -System.create({ name: "threadsTypeTest/lookup", components: { read: [tags] }, lookup: { nodes: { read: [node] } } }, () => {}); +System.create({ components: { read: [tags] }, lookup: { nodes: { read: [node] } } }, () => {}); // @ts-expect-error resources and components live on one thread -System.create({ name: "threadsTypeTest/mixed", components: { read: [audio] }, resources: { read: [document] } }, () => {}); +System.create({ components: { read: [audio] }, resources: { read: [document] } }, () => {}); // @ts-expect-error a condition it reads places it -System.create({ name: "threadsTypeTest/condition", components: { read: [node] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { read: [node] }, resources: { is: [settings] } }, () => {}); // Render systems run on gpuDevice's thread, "world". -System.render({ name: "threadsTypeTest/draw", resources: { read: [settings] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ resources: { read: [settings] }, shader: { accessors: { place: position } } }, () => {}); // @ts-expect-error a render system's resources live with gpuDevice -System.render({ name: "threadsTypeTest/drawNode", resources: { read: [document] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ resources: { read: [document] }, shader: { accessors: { place: position } } }, () => {}); // A composite's members may live on different threads. -const render = System.create({ name: "threadsTypeTest/renderMember", components: { write: [node] } }, () => {}); -const play = System.create({ name: "threadsTypeTest/playMember", components: { write: [audio] } }, () => {}); -export const frame = System.create({ name: "threadsTypeTest/frame", systems: [render, play] }); +const render = System.create({ components: { write: [node] } }, () => {}); +const play = System.create({ components: { write: [audio] } }, () => {}); +export const frame = System.create({ systems: [render, play] }); // Finalizers too run on one thread. -Finalizer.create({ name: "threadsTypeTest/release", components: { read: [node] }, resources: { read: [document] } }, () => {}); +Finalizer.create({ components: { read: [node] }, resources: { read: [document] } }, () => {}); // @ts-expect-error a finalizer's object components and resources live on one thread -Finalizer.create({ name: "threadsTypeTest/releaseBoth", components: { read: [node, audio] } }, () => {}); -Finalizer.create({ name: "threadsTypeTest/releaseCaption", components: { read: [node, label] } }, () => {}); +Finalizer.create({ components: { read: [node, audio] } }, () => {}); +Finalizer.create({ components: { read: [node, label] } }, () => {}); // A finalizer on another thread than the world's, which a joined thread may fill, runs there after the sync point: it // issues no commands, writes no resources and looks up nothing. -Finalizer.create({ name: "threadsTypeTest/detach", components: { read: [node, position] }, resources: { read: [document, time] } }, (row, context) => { +Finalizer.create({ components: { read: [node, position] }, resources: { read: [document, time] } }, (row, context) => { row[node].remove(); void [row[position], context.resources[document], context.resources[time]]; // @ts-expect-error a finalizer on another thread issues no commands void context.commands; }); -Finalizer.create({ name: "threadsTypeTest/silence", components: { read: [audio] } }, (row, context) => { +Finalizer.create({ components: { read: [audio] } }, (row, context) => { void row[audio]; // @ts-expect-error a finalizer on another thread issues no commands void context.commands; }); // @ts-expect-error a finalizer on another thread writes no resources -Finalizer.create({ name: "threadsTypeTest/detachWriting", components: { read: [node] }, resources: { write: [time] } }, () => {}); +Finalizer.create({ components: { read: [node] }, resources: { write: [time] } }, () => {}); // @ts-expect-error a finalizer writing another thread's resource is on that thread -Finalizer.create({ name: "threadsTypeTest/documentWriting", components: { read: [position] }, resources: { write: [document] } }, () => {}); +Finalizer.create({ components: { read: [position] }, resources: { write: [document] } }, () => {}); // @ts-expect-error a finalizer on another thread looks up nothing -Finalizer.create({ name: "threadsTypeTest/detachLooking", components: { read: [node] }, lookup: { places: { read: [position] } } }, () => {}); +Finalizer.create({ components: { read: [node] }, lookup: { places: { read: [position] } } }, () => {}); // On the world's thread, structs and its objects alike, it does. -Finalizer.create({ name: "threadsTypeTest/forget", components: { read: [label, position] }, resources: { write: [time] }, lookup: { labels: { read: [label] } } }, (row, { commands, resources, lookup }) => { +Finalizer.create({ components: { read: [label, position] }, resources: { write: [time] }, lookup: { labels: { read: [label] } } }, (row, { commands, resources, lookup }) => { resources[time] = lookup.labels(row.entity)?.[label].length ?? 0; commands.despawn(row.entity); }); // A changes finalizer runs on the world's thread: watching another thread's objects, or reading its resources, doesn't compile. -Finalizer.changes({ name: "threadsTypeTest/save", watch: [label, position], resources: { read: [time] } }, (_, { commands }) => { void commands; }); +Finalizer.changes({ watch: [label, position], resources: { read: [time] } }, (_, { commands }) => { void commands; }); // @ts-expect-error a changes finalizer watches no other thread's objects -Finalizer.changes({ name: "threadsTypeTest/saveNodes", watch: [node] }, () => {}); +Finalizer.changes({ watch: [node] }, () => {}); // @ts-expect-error a changes finalizer reads no other thread's resources -Finalizer.changes({ name: "threadsTypeTest/saveDocument", resources: { read: [document] } }, () => {}); +Finalizer.changes({ resources: { read: [document] } }, () => {}); // The error names each object component and resource with its thread; data places nothing. check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts index 0f915dd3..6f6838af 100644 --- a/packages/ecs/src/interface/system/up.type-test.ts +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -23,7 +23,6 @@ declare module '@adobe/ecs' { // `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the // system writes. System.create({ - name: "upTypeTest/propagate", kernel: "cpu", components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, @@ -35,21 +34,19 @@ System.create({ }); // Without `up`, the context has none. -System.create({ name: "upTypeTest/none", components: { read: [local] } }, (row, context) => { +System.create({ components: { read: [local] } }, (row, context) => { void row[local]; // @ts-expect-error no up declared void context.up; }); System.create({ - name: "upTypeTest/unreferenced", components: { write: [world] }, // @ts-expect-error through is a reference component up: { through: local, read: [world] }, }, () => {}); System.create({ - name: "upTypeTest/text", kernel: "cpu", components: { write: [world] }, // @ts-expect-error a kernel's up reads linear values @@ -58,7 +55,6 @@ System.create({ // A GPU kernel reads up too. System.create({ - name: "upTypeTest/gpu", kernel: "gpu", components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, @@ -67,7 +63,6 @@ System.create({ }); System.create({ - name: "upTypeTest/rowless", // @ts-expect-error up orders rows, so it needs components up: { through: parent, read: [world] }, }, () => {}); diff --git a/packages/ecs/src/interface/world/loop.type-test.ts b/packages/ecs/src/interface/world/loop.type-test.ts index db5a1213..aa9cdaa7 100644 --- a/packages/ecs/src/interface/world/loop.type-test.ts +++ b/packages/ecs/src/interface/world/loop.type-test.ts @@ -43,23 +43,23 @@ type Removed = "component" | "persistent" | "shared" | "threadPort" | "threadSta check, never>>(); // Systems read the loop's state, ask for a wake time and change its cadence. -System.create({ name: "loopTypeTest/read", resources: { read: [clock] } }, ({ resources }) => { +System.create({ resources: { read: [clock] } }, ({ resources }) => { const now: number = resources[clock].now; void now; }); -System.create({ name: "loopTypeTest/pace", resources: { write: [cadence, always] } }, ({ resources }) => { +System.create({ resources: { write: [cadence, always] } }, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1000 / 60, maxSteps: 2 }; resources[always] = true; }); -System.create({ name: "loopTypeTest/sleep", reduce: { [wake]: "min" } }, ({ reduce }) => { reduce[wake](1000); }); +System.create({ resources: { reduce: [wake] } }, ({ reduce }) => { reduce[wake](1000); }); // @ts-expect-error clock is written only by the world -System.create({ name: "loopTypeTest/clock", resources: { write: [clock] } }, () => {}); +System.create({ resources: { write: [clock] } }, () => {}); // @ts-expect-error wake is reduced, never written -System.create({ name: "loopTypeTest/wake", resources: { write: [wake] } }, () => {}); +System.create({ resources: { write: [wake] } }, () => {}); // @ts-expect-error a finalizer doesn't write clock either -Finalizer.create({ name: "loopTypeTest/finalizeClock", components: {}, resources: { write: [clock] } }, () => {}); +Finalizer.create({ components: {}, resources: { write: [clock] } }, () => {}); // @ts-expect-error a cadence is a frame or a fixed step -System.create({ name: "loopTypeTest/badCadence", resources: { write: [cadence] } }, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); +System.create({ resources: { write: [cadence] } }, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); // Worlds start the loop's settings at other values; clock is the world's own. const paced: World = create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true, [workers]: 2 }); diff --git a/packages/ecs/src/observe/entity.ts b/packages/ecs/src/observe/entity.ts index e3f47830..adac7186 100644 --- a/packages/ecs/src/observe/entity.ts +++ b/packages/ecs/src/observe/entity.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Index, System, type Component, type Entity, type World } from '../interface/index.js'; import type { Observe } from './observe.js'; -import { createEntry, give, giveFirst, keysId, labelOf, none, noDelivery, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; +import { createEntry, give, giveFirst, keysId, labelOf, labelled, none, noDelivery, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; /** An observed entity's value, shared by its observers, and by optional component whether it had it last. */ type EntityEntry = Entry & { readonly entity: Entity; readonly present: boolean[] }; @@ -32,7 +32,7 @@ const memberIndexes = new Map { const id = keysId(required); let index = memberIndexes.get(id); - if (index === undefined) memberIndexes.set(id, index = Index.create({ name: `Observe.entity(${labelOf(required)})`, key: [], components: { has: [...required] } })); + if (index === undefined) memberIndexes.set(id, index = Index.create(labelled({ key: [], components: { has: [...required] } }, `Observe.entity(${labelOf(required)})`))); return index; }; @@ -129,14 +129,14 @@ const createShape = (state: WorldState, byShape: Map, id: s let finders: Finders | null = null; let source: object | null = null; const system = optional.length === 0 - ? System.create({ name, observer: true, lookup: { presence: { has: required, changed: required }, values: { read: required }, members: { index: membersOf(required), changed: required } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { presence: { has: required, changed: required }, values: { read: required }, members: { index: membersOf(required), changed: required } } }, name), ({ lookup }) => { if (source !== lookup || finders === null) { source = lookup; finders = { presence: lookup.presence, values: lookup.values, optional: noOptional }; } pass(lookup.members().changed, finders); }) - : System.create({ name, observer: true, lookup: optionalLookups(required, optional) }, ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: optionalLookups(required, optional) }, name), ({ lookup }) => { if (source !== lookup || finders === null) { source = lookup; finders = { presence: lookup.presence, values: lookup.values, optional: (index, entity) => lookup[index](entity) }; diff --git a/packages/ecs/src/observe/resource.ts b/packages/ecs/src/observe/resource.ts index 3751df1a..22a1a474 100644 --- a/packages/ecs/src/observe/resource.ts +++ b/packages/ecs/src/observe/resource.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { System, type Resource, type World } from '../interface/index.js'; import type { Observe } from './observe.js'; -import { createEntry, give, giveFirst, none, noDelivery, ownName, passed, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; +import { createEntry, give, giveFirst, labelled, none, noDelivery, ownName, passed, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; /** * A resource's observers, sharing one value: `notifier`, added, gives the value after each pass whose sync point @@ -31,14 +31,14 @@ const createShape = (state: WorldState, byKey: Map, const label = ownName(`Observe.resource(${key.description ?? "?"})`); // On a joined thread, first values come once a run of this system resolved, never in its pass: none to skip. const { joined } = state; - const notifier = System.create({ name: label, observer: true, resources: { read: [key], changed: [key] } }, ({ resources }) => { + const notifier = System.create(labelled({ observer: true, resources: { read: [key], changed: [key] } }, label), ({ resources }) => { passed(state); entry.value = resources[key]; entry.valid = true; if (entry.stopped > 0) sweepSubscribers(entry); throwFailed(give(entry, 0, true, state.pass, joined ? -1 : state.pass, none)); }); - const reader = System.create({ name: label, observer: true, resources: { read: [key] } }, ({ resources }) => { + const reader = System.create(labelled({ observer: true, resources: { read: [key] } }, label), ({ resources }) => { const through = shape.called.shift() ?? 0; entry.value = resources[key]; entry.valid = true; diff --git a/packages/ecs/src/observe/select.ts b/packages/ecs/src/observe/select.ts index ba6cf163..1af6a702 100644 --- a/packages/ecs/src/observe/select.ts +++ b/packages/ecs/src/observe/select.ts @@ -4,7 +4,7 @@ import { compare, predicateOf } from './compare.js'; import type { Observe } from './observe.js'; import { createAll, createCount, createHeap, createSorted, inEntityOrder, indexOf, type Gather, type Page, type Term, type Values } from './plans.js'; import { - createEntry, deliverAlso, give, giveFirst, keysId, labelOf, none, noDelivery, numberOf, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, + createEntry, deliverAlso, give, giveFirst, keysId, labelOf, labelled, none, noDelivery, numberOf, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState, } from './world-state.js'; @@ -93,7 +93,7 @@ const excluding = (not: readonly Component.Key[], held: readonly Component.Key[] const keylessOf = (has: readonly Component.Key[], not: readonly Component.Key[]) => { const id = `${keysId(has)}|${keysId(not)}`; let index = keyless.get(id); - if (index === undefined) keyless.set(id, index = Index.create({ name: `Observe.query(${labelOf(has)})`, key: [], components: { has: [...has], not: excluding(not, has) } })); + if (index === undefined) keyless.set(id, index = Index.create(labelled({ key: [], components: { has: [...has], not: excluding(not, has) } }, `Observe.query(${labelOf(has)})`))); return index; }; @@ -103,7 +103,7 @@ const sortedOf = (key: readonly Component.Key[], has: readonly Component.Key[], let index = sortedIndexes.get(id); if (index === undefined) { try { - index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf(key)}`, key: [...key], sorted: true, components: { has: [...has], not: excluding(not, [...key, ...has]) } }); + index = Index.create(labelled({ key: [...key], sorted: true, components: { has: [...has], not: excluding(not, [...key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf(key)}`)); } catch { index = null; @@ -179,7 +179,7 @@ const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readon let index = rangedIndexes.get(id); if (index === undefined) { try { - index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf([key])}`, key: [key], sorted: true, components: { has: [...has], not: excluding(not, [key, ...has]) } }); + index = Index.create(labelled({ key: [key], sorted: true, components: { has: [...has], not: excluding(not, [key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf([key])}`)); } catch { index = null; @@ -403,16 +403,16 @@ const createShape = (state: WorldState, byShape: Map, id: st }; const tracker = ordered !== null - ? System.create({ name, observer: true, lookup: { list: { index: ordered, read: reads, changed: reads } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: ordered, read: reads, changed: reads } } }, name), ({ lookup }) => { track(lookup.list(), null, fromSorted); }) : byRange !== null - ? System.create({ name, observer: true, lookup: { list: { index: byRange, read: reads, changed: reads } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: byRange, read: reads, changed: reads } } }, name), ({ lookup }) => { // A range is of a `where` condition on the index's key, which the query's type checked against its // values. track(null, entry => ownRange(entry, range => lookup.list(range as Parameters[0]), () => lookup.list()), fromSorted); }) - : System.create({ name, observer: true, lookup: { list: { index: keylessOf(has, not), read: reads, changed: reads } } }, ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: { list: { index: keylessOf(has, not), read: reads, changed: reads } } }, name), ({ lookup }) => { track(lookup.list(), null, fromKeyless); }); @@ -466,7 +466,7 @@ const createShape = (state: WorldState, byShape: Map, id: st for (const entry of scan.entries) entry.gather.start(); accept = acceptOf(scan.entries); }; - const scanner = System.create({ name, observer: true, components: { read: reads, has: required.filter(key => !reads.includes(key)), not } }, row => { + const scanner = System.create(labelled({ observer: true, components: { read: reads, has: required.filter(key => !reads.includes(key)), not } }, name), row => { if (accept === null) start(); if (direct === 0) { accept?.(row); diff --git a/packages/ecs/src/observe/world-state.ts b/packages/ecs/src/observe/world-state.ts index 43ccdd5b..b6535ec9 100644 --- a/packages/ecs/src/observe/world-state.ts +++ b/packages/ecs/src/observe/world-state.ts @@ -91,18 +91,32 @@ export const rethrowLater = (error: unknown): void => { */ const ownNames = new Set(); -/** Names a system the factories make. */ +/** The registry's map of labels (`@adobe/ecs/labels@1`), which the plugin fills for declarations made in functions. */ +const labels = Symbol.for("@adobe/ecs/labels@1"); + +/** Labels a declaration of a system or index the factories make, and returns it. */ +export const labelled = (declaration: D, label: string): D => { + let map: unknown = Reflect.get(globalThis, labels); + if (!(map instanceof Map)) Reflect.set(globalThis, labels, map = new Map()); + if (map instanceof Map) map.set(declaration, label); + return declaration; +}; + +/** Names a system the factories make, whose failures are theirs to rethrow. */ export const ownName = (name: string): string => { ownNames.add(name); return name; }; +/** Labels a declaration of a system the factories make, whose failures are theirs to rethrow. */ +export const own = (declaration: D, label: string): D => labelled(declaration, ownName(label)); + const ignore = (): void => { // A disposed world runs nothing more: its observers' first values never come. }; /** Runs nothing: a run of it resolves while its world lives. */ -const alive = System.create({ name: ownName("Observe/alive"), observer: true }, () => {}); +const alive = System.create(own({ observer: true }, "Observe/alive"), () => {}); /** * What a rejected run of the factories' own does: nothing for a disposed world, whose first values never come, and for @@ -174,7 +188,7 @@ const sweepShapes = (state: WorldState): void => { }; /** A system no world adds, which only a joined thread's `remove` refuses, since it isn't an observer. */ -const probe = System.create({ name: "Observe/probe" }, () => {}); +const probe = System.create(labelled({}, "Observe/probe"), () => {}); /** Whether `world` is a joined thread's: its `remove` takes only observer systems, where the world's ignores one never added. */ const isJoined = (world: World): boolean => { @@ -222,11 +236,11 @@ export const stateOf = (world: World): WorldState => { rejected: rejectedOf(world), // Run with the factories' own systems, never added: what one of them throws is in a later report, whose errors // are written once, which the next of the factories' runs sees. - rethrow: System.create({ name: ownName("Observe/rethrow"), observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + rethrow: System.create(own({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, "Observe/rethrow"), row => { for (const failure of row[Report.errors].failures) if (ownNames.has(failure.name)) rethrowLater(new Error(`${failure.name}: ${failure.message}`)); }), composites: new WeakMap(), - deliver: System.create({ name: ownName("Observe/deliver"), observer: true }, () => { + deliver: System.create(own({ observer: true }, "Observe/deliver"), () => { const delivery = created.deliveries.shift(); if (delivery === undefined) return; created.delivered = delivery.batch; @@ -237,7 +251,7 @@ export const stateOf = (world: World): WorldState => { states.set(world, created); if (joined) return created; // Added before any shape's system, so in each pass it counts the pass before they run. - world.add(System.create({ name: ownName("Observe/maintain"), observer: true }, () => { + world.add(System.create(own({ observer: true }, "Observe/maintain"), () => { created.pass++; if (created.idle.length > 0) sweepShapes(created); })); diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts index 0f82594a..4549a737 100644 --- a/packages/ecs/src/perftest/editor.ts +++ b/packages/ecs/src/perftest/editor.ts @@ -43,8 +43,8 @@ const Group = Archetype.create([...node, parent, group]); const Shape = Archetype.create([...node, parent, shape]); const Image = Archetype.create([...node, parent, image]); -const byPaintDepth = Index.create({ name: "editor/byPaintDepth", key: [paintDepth], sorted: true }); -const childrenOf = Index.create({ name: "editor/childrenOf", key: [parent] }); +const byPaintDepth = Index.create({ key: [paintDepth], sorted: true }); +const childrenOf = Index.create({ key: [parent] }); /** Groups under the document, subgroups in each, and leaves in each subgroup: 50,201 nodes. */ const groups = 200; @@ -53,7 +53,7 @@ const leaves = 124; const nodes = 1 + groups * (1 + subgroups * (1 + leaves)); /** Spawns the document depth first, as a file lists it, its paint depth its place in that order. */ -const load = System.create({ name: "editor/load" }, ({ commands }) => { +const load = System.create({}, ({ commands }) => { let depth = 0; const values = (x: number) => ({ [localX]: x, [localY]: 1, [worldX]: 0, [worldY]: 0, [opacity]: 0.99, [worldOpacity]: 1, [paintDepth]: depth++ }); const document = commands.spawn(Document)(values(0)); @@ -71,7 +71,6 @@ const load = System.create({ name: "editor/load" }, ({ commands }) => { /** Each node's world position is its parent's plus its own. */ const place = System.create({ - name: "editor/place", components: { read: [localX, localY], write: [worldX, worldY], changed: [localX, localY] }, up: { through: parent, read: [worldX, worldY] }, }, (row, { up }) => { @@ -80,7 +79,6 @@ const place = System.create({ }); /** Each node's world opacity is its parent's times its own. */ const fade = System.create({ - name: "editor/fade", components: { read: [opacity], write: [worldOpacity], changed: [opacity] }, up: { through: parent, read: [worldOpacity] }, }, (row, { up }) => { @@ -88,14 +86,14 @@ const fade = System.create({ }); let checksum = 0; /** Each group counts its children, as a layout does. */ -const layout = System.create({ name: "editor/layout", components: { has: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { +const layout = System.create({ components: { has: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { checksum += lookup.children(row.entity).length; }); /** Visits every node in paint order, as drawing does. */ -const paint = System.create({ name: "editor/paint", components: { read: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { +const paint = System.create({ components: { read: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { checksum = (checksum * 31 + row[worldX] * row[worldOpacity]) % 1000003; }); -const frame = System.create({ name: "editor/frame", systems: [place, fade, layout, paint] }); +const frame = System.create({ systems: [place, fade, layout, paint] }); const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts index 2fe97cff..6b2b9044 100644 --- a/packages/ecs/src/perftest/filters.ts +++ b/packages/ecs/src/perftest/filters.ts @@ -34,13 +34,12 @@ declare module "@adobe/ecs" { const rows = 100_000; const filtered = Archetype.create([value, other, at, out0, out1, out2, out3]); -const seed = System.create({ name: "filters/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(filtered)({ [value]: 0, [other]: 0, [at]: i, [out0]: 0, [out1]: 0, [out2]: 0, [out3]: 0 }); }); /** Writes the value of the rows whose `at` is `first` plus a multiple of `stride`. */ const write = System.create({ - name: "filters/write", kernel: "cpu", components: { read: [at], write: [value] }, }, (row, _context, input: { readonly first: I32; readonly stride: I32 }) => { @@ -51,26 +50,23 @@ const write = System.create({ let seen = 0; /** Main-thread systems each filtering on `value`'s changes. */ const readers = System.create({ - name: "filters/readers", - systems: Array.from({ length: 8 }, (_, i) => System.create({ name: `filters/reader${i}`, components: { read: [value], changed: [value] } }, row => { + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { read: [value], changed: [value] } }, row => { seen += row[value]; })), }); /** The same filtering on `value`'s or `other`'s changes, of which only `value` is written. */ const readersOfTwo = System.create({ - name: "filters/readersOfTwo", - systems: Array.from({ length: 8 }, (_, i) => System.create({ name: `filters/readerOfTwo${i}`, components: { read: [value], changed: [value, other] } }, row => { + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { read: [value], changed: [value, other] } }, row => { seen += row[value]; })), }); /** Kernels each filtering on `value`'s changes, writing an output of its own. */ const kernels = System.create({ - name: "filters/kernels", systems: [ - System.create({ name: "filters/kernel0", kernel: "cpu", components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), - System.create({ name: "filters/kernel1", kernel: "cpu", components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), - System.create({ name: "filters/kernel2", kernel: "cpu", components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), - System.create({ name: "filters/kernel3", kernel: "cpu", components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), + System.create({ kernel: "cpu", components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), + System.create({ kernel: "cpu", components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), + System.create({ kernel: "cpu", components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), + System.create({ kernel: "cpu", components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), ], }); diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index 86c66be7..f1cc112d 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -43,39 +43,39 @@ export const despawnedObjects = 10_000; /** On the joined thread: what the finalizer released. */ export const releases = { count: 0 }; -export const release = Finalizer.create({ name: "finalized/release", components: { read: [handle, spot] } }, row => { +export const release = Finalizer.create({ components: { read: [handle, spot] } }, row => { row[handle]?.release(); if (row[spot][0] >= 0) releases.count++; }); /** On the joined thread: the same, reading the world's string resource too. */ -export const releaseNoted = Finalizer.create({ name: "finalized/releaseNoted", components: { read: [noted, spot] }, resources: { read: [note] } }, (row, { resources }) => { +export const releaseNoted = Finalizer.create({ components: { read: [noted, spot] }, resources: { read: [note] } }, (row, { resources }) => { row[noted]?.release(); if (row[spot][0] >= 0 && resources[note].length > 0) releases.count++; }); /** On the joined thread: the same, reading two linear resources of the world's too. */ -export const releaseMeasured = Finalizer.create({ name: "finalized/releaseMeasured", components: { read: [measured, spot] }, resources: { read: [scale, offset] } }, (row, { resources }) => { +export const releaseMeasured = Finalizer.create({ components: { read: [measured, spot] }, resources: { read: [scale, offset] } }, (row, { resources }) => { row[measured]?.release(); if (row[spot][0] * resources[scale] + resources[offset][0] >= 0) releases.count++; }); -const spawn = System.create({ name: "finalized/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); -const despawn = System.create({ name: "finalized/despawn", components: { has: [handle, spot] } }, (row, { commands }) => { +const despawn = System.create({ components: { has: [handle, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const spawnNoted = System.create({ name: "finalized/spawnNoted" }, ({ commands }) => { +const spawnNoted = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, noted])({ [spot]: [k, 0, 0], [noted]: null }); }); -const spawnMeasured = System.create({ name: "finalized/spawnMeasured" }, ({ commands }) => { +const spawnMeasured = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, measured])({ [spot]: [k, 0, 0], [measured]: null }); }); -const despawnMeasured = System.create({ name: "finalized/despawnMeasured", components: { has: [measured, spot] } }, (row, { commands }) => { +const despawnMeasured = System.create({ components: { has: [measured, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const despawnNoted = System.create({ name: "finalized/despawnNoted", components: { has: [noted, spot] } }, (row, { commands }) => { +const despawnNoted = System.create({ components: { has: [noted, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts index 905feeb6..45715f9c 100644 --- a/packages/ecs/src/perftest/flat.ts +++ b/packages/ecs/src/perftest/flat.ts @@ -43,14 +43,14 @@ const archetypes = [ ] as const; /** Spawns with values passed inline. */ -export const spawnFlat = System.create({ name: "flat/spawn" }, ({ commands }, input: { readonly count: number }) => { +export const spawnFlat = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(archetypes[i % 4])({ [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }); } }); /** The same spawns with each value object built first, as values from data or from another system's results are. */ -export const spawnFlatDynamic = System.create({ name: "flat/dynamic" }, ({ commands }, input: { readonly count: number }) => { +export const spawnFlatDynamic = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const values = { [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }; commands.spawn(archetypes[i % 4])(values); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 30928fa2..37f48c47 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -33,16 +33,16 @@ const tags = [g0, g1, g2, g3, g4, g5, g6] as const; const hundred = Array.from({ length: 100 }, (_, bits) => Archetype.create([value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); /** Spawns `count` entities into each of the hundred archetypes. */ -export const fillEach = System.create({ name: "growth/fillEach" }, ({ commands }, input: { readonly count: number }) => { +export const fillEach = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const archetype of hundred) for (let i = 0; i < input.count; i++) commands.spawn(archetype)({ [value]: i }); }); /** One spawn into each of the hundred archetypes. */ -export const spawnEach = System.create({ name: "growth/spawnEach" }, ({ commands }) => { +export const spawnEach = System.create({}, ({ commands }) => { for (const archetype of hundred) commands.spawn(archetype)({ [value]: 0 }); }); /** A kernel over every row of the hundred archetypes. */ -export const bumpEach = System.create({ name: "growth/bumpEach", kernel: "cpu", components: { write: [value] } }, row => { +export const bumpEach = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts index 5e45e28e..4c841d5d 100644 --- a/packages/ecs/src/perftest/handoff.ts +++ b/packages/ecs/src/perftest/handoff.ts @@ -23,31 +23,31 @@ declare module "@adobe/ecs" { } /** An object of the joined thread, which places the systems reading it there. */ -const here0 = System.create({ name: "handoff/here0" }, () => {}); -const there0 = System.create({ name: "handoff/there0", resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here1 = System.create({ name: "handoff/here1", after: [there0] }, () => {}); -const there1 = System.create({ name: "handoff/there1", resources: { read: [token] }, after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here2 = System.create({ name: "handoff/here2", after: [there1] }, () => {}); -const there2 = System.create({ name: "handoff/there2", resources: { read: [token] }, after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here3 = System.create({ name: "handoff/here3", after: [there2] }, () => {}); -const there3 = System.create({ name: "handoff/there3", resources: { read: [token] }, after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here4 = System.create({ name: "handoff/here4", after: [there3] }, () => {}); -const there4 = System.create({ name: "handoff/there4", resources: { read: [token] }, after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here0 = System.create({}, () => {}); +const there0 = System.create({ resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here1 = System.create({ after: [there0] }, () => {}); +const there1 = System.create({ resources: { read: [token] }, after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here2 = System.create({ after: [there1] }, () => {}); +const there2 = System.create({ resources: { read: [token] }, after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here3 = System.create({ after: [there2] }, () => {}); +const there3 = System.create({ resources: { read: [token] }, after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here4 = System.create({ after: [there3] }, () => {}); +const there4 = System.create({ resources: { read: [token] }, after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten tiers alternating the world's thread and the joined one, empty systems. */ -export const handoff = System.create({ name: "handoff/ticks", systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); +export const handoff = System.create({ systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); -const fused0 = System.create({ name: "handoff/fused0", resources: { read: [token] } }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused1 = System.create({ name: "handoff/fused1", resources: { read: [token] }, after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused2 = System.create({ name: "handoff/fused2", resources: { read: [token] }, after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused3 = System.create({ name: "handoff/fused3", resources: { read: [token] }, after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused4 = System.create({ name: "handoff/fused4", resources: { read: [token] }, after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused5 = System.create({ name: "handoff/fused5", resources: { read: [token] }, after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused6 = System.create({ name: "handoff/fused6", resources: { read: [token] }, after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused7 = System.create({ name: "handoff/fused7", resources: { read: [token] }, after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused8 = System.create({ name: "handoff/fused8", resources: { read: [token] }, after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused9 = System.create({ name: "handoff/fused9", resources: { read: [token] }, after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused0 = System.create({ resources: { read: [token] } }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused1 = System.create({ resources: { read: [token] }, after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused2 = System.create({ resources: { read: [token] }, after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused3 = System.create({ resources: { read: [token] }, after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused4 = System.create({ resources: { read: [token] }, after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused5 = System.create({ resources: { read: [token] }, after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused6 = System.create({ resources: { read: [token] }, after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused7 = System.create({ resources: { read: [token] }, after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused8 = System.create({ resources: { read: [token] }, after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused9 = System.create({ resources: { read: [token] }, after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten consecutive tiers on the joined thread, empty systems. */ -export const fused = System.create({ name: "handoff/fused", systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); +export const fused = System.create({ systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); /** Every system the joined thread runs, which it imports so the world can place them there. */ export const handoffSystems: readonly System[] = [handoff, fused]; diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index f203ef7f..ad7019c6 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -25,7 +25,6 @@ const taggedChild = Archetype.create([local, world, parent, leaf]); /** Each row's world value is its parent's plus its own. */ export const propagate = System.create({ - name: "hierarchy/propagate", kernel: "cpu", components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, @@ -35,7 +34,6 @@ export const propagate = System.create({ /** The same, only for rows whose own or parent's values changed since it last ran. */ export const propagateChanged = System.create({ - name: "hierarchy/propagateChanged", kernel: "cpu", components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, @@ -45,7 +43,6 @@ export const propagateChanged = System.create({ /** `roots` chains of `depth` rows each. */ export const forest = System.create({ - name: "hierarchy/forest", }, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(root)({ [local]: 1, [world]: 0 }); @@ -56,12 +53,12 @@ export const forest = System.create({ const target = { type: "object", properties: { entity: Entity.schema } } as const; /** Changes one row's own value. */ -export const nudge = System.create({ name: "hierarchy/nudge", components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { +export const nudge = System.create({ components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { if (row.entity === input.entity) row[local] += 1; }); /** Adds one leaf under a row. */ -export const sprout = System.create({ name: "hierarchy/sprout" }, ({ commands }, input: { readonly entity: Entity }) => { +export const sprout = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: input.entity }); }); @@ -71,7 +68,6 @@ const leaves: Entity[] = []; /** Despawns the leaves it spawned last time and spawns `count` new ones, one under each of `under`'s first rows; `tagged` puts them in an archetype of their own. */ export const turnover = System.create({ - name: "hierarchy/turnover", }, ({ commands }, input: { readonly count: number; readonly tagged: boolean }) => { for (const entity of leaves.splice(0)) commands.despawn(entity); for (let i = 0; i < input.count; i++) { @@ -83,7 +79,6 @@ export const turnover = System.create({ /** Moves a row, and the subtree under it, to another parent. */ export const reparent = System.create({ - name: "hierarchy/reparent", }, ({ commands }, input: { readonly entity: Entity; readonly to: Entity }) => { commands.add(input.entity, parent, input.to); }); @@ -104,7 +99,6 @@ const gpuChild = Archetype.create([offset, placed, parent]); /** `propagate` in 4-byte values, on the GPU. */ export const place = System.create({ - name: "hierarchy/place", kernel: "gpu", components: { read: [offset], write: [placed] }, up: { through: parent, read: [placed] }, @@ -114,7 +108,6 @@ export const place = System.create({ /** `forest` in 4-byte values. */ export const gpuForest = System.create({ - name: "hierarchy/gpuForest", }, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(gpuRoot)({ [offset]: 1, [placed]: 0 }); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index c7b622ce..5f81e361 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -34,9 +34,9 @@ const million = 1_000_000; /** Distinct keys among the indexed entities: ten entities a bucket. */ const keys = million / 10; -const byKey = Index.create({ name: "indexes/byKey", key: [key] }); +const byKey = Index.create({ key: [key] }); -const seedKeys = System.create({ name: "indexes/seedKeys" }, ({ commands }, input: { readonly count: number; readonly marked: number }) => { +const seedKeys = System.create({}, ({ commands }, input: { readonly count: number; readonly marked: number }) => { for (let i = 0; i < input.count; i++) { if (i < input.marked) commands.spawn([key, amount, marked])({ [key]: (i * 7919) % keys, [amount]: i, [marked]: true }); else commands.spawn([key, amount])({ [key]: (i * 7919) % keys, [amount]: i }); @@ -45,13 +45,13 @@ const seedKeys = System.create({ name: "indexes/seedKeys" }, ({ commands }, inpu /** Moves every marked entity to another bucket, and back again the next run. */ let flip = 1; -const rekeyMarked = System.create({ name: "indexes/rekeyMarked", components: { write: [key], has: [marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyMarked = System.create({ components: { write: [key], has: [marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); /** Moves every entity to another bucket. */ -const rekeyAll = System.create({ name: "indexes/rekeyAll", components: { write: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyAll = System.create({ components: { write: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); let found = 0; /** Uses `byKey`, so a run of it refreshes the index. */ -const lookOnce = System.create({ name: "indexes/lookOnce", lookup: { byKey: { index: byKey } } }, ({ lookup }) => { found += lookup.byKey(0).length; }); +const lookOnce = System.create({ lookup: { byKey: { index: byKey } } }, ({ lookup }) => { found += lookup.byKey(0).length; }); /** The fastest run of `measured` right after `before`. */ const after = async (before: () => Promise, measured: () => Promise, rounds = 40, warmup = 6): Promise => { @@ -75,7 +75,7 @@ const threads = navigator.hardwareConcurrency - 1; const pooled = (): World => createWorld({ [workers]: threads }); /** A kernel touching nothing, so a world's pool starts, as it does with its first kernel. */ -const startPool = System.create({ name: "indexes/startPool", kernel: "cpu", components: { has: [marked] } }, () => {}); +const startPool = System.create({ kernel: "cpu", components: { has: [marked] } }, () => {}); /** Indexes `index idle` keeps, each over its own thousand entities. */ const idleIndexes = 1000; @@ -87,12 +87,12 @@ declare module "@adobe/ecs" { } /** Unique serials among `unique lookup 1M`'s entities. */ -const bySerial = Index.create({ name: "indexes/bySerial", key: [serial], unique: true, components: { has: [amount] } }); -const seedSerials = System.create({ name: "indexes/seedSerials" }, ({ commands }) => { +const bySerial = Index.create({ key: [serial], unique: true, components: { has: [amount] } }); +const seedSerials = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) commands.spawn([serial, amount])({ [serial]: i, [amount]: 1 }); }); let summed = 0; -const lookUnique = System.create({ name: "indexes/lookUnique", lookup: { bySerial: { index: bySerial, read: [amount] } } }, ({ lookup }) => { +const lookUnique = System.create({ lookup: { bySerial: { index: bySerial, read: [amount] } } }, ({ lookup }) => { let sum = 0; for (let i = 0; i < million; i++) sum += lookup.bySerial(i)?.[amount] ?? 0; summed = sum; @@ -100,22 +100,22 @@ const lookUnique = System.create({ name: "indexes/lookUnique", lookup: { bySeria /** Parents in `children lookup`, with ten children each. */ const parents = 10_000; -const childrenOf = Index.create({ name: "indexes/childrenOf", key: [parent], components: { has: [amount] } }); -const seedFamilies = System.create({ name: "indexes/seedFamilies" }, ({ commands }) => { +const childrenOf = Index.create({ key: [parent], components: { has: [amount] } }); +const seedFamilies = System.create({}, ({ commands }) => { for (let p = 0; p < parents; p++) { const entity = commands.spawn([marked])({ [marked]: true }); for (let c = 0; c < 10; c++) commands.spawn([parent, amount])({ [parent]: entity, [amount]: 1 }); } }); -const sumChildren = System.create({ name: "indexes/sumChildren", components: { has: [marked] }, lookup: { children: { index: childrenOf, read: [amount] } } }, (row, { lookup }) => { +const sumChildren = System.create({ components: { has: [marked] }, lookup: { children: { index: childrenOf, read: [amount] } } }, (row, { lookup }) => { const children = lookup.children(row.entity); for (let i = 0; i < children.length; i++) summed += children.at(i)[amount]; }); /** Particles in `grid neighbors 100K`, in a 100 by 100 by 10 grid of unit cells, ten a cell. */ const particles = 100_000; -const grid = Index.create({ name: "indexes/grid", key: [cell], components: { has: [place] } }); -const seedParticles = System.create({ name: "indexes/seedParticles" }, ({ commands }) => { +const grid = Index.create({ key: [cell], components: { has: [place] } }); +const seedParticles = System.create({}, ({ commands }) => { for (let i = 0; i < particles; i++) { const x = ((i * 7919) % 1000) / 10; const y = ((i * 104729) % 1000) / 10; @@ -124,7 +124,7 @@ const seedParticles = System.create({ name: "indexes/seedParticles" }, ({ comman } }); /** The same on a kernel: on the pool, each row's 27 probes in straight-line code. */ -const neighborsKernel = System.create({ name: "indexes/neighborsKernel", kernel: "cpu", components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighborsKernel = System.create({ kernel: "cpu", components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -141,7 +141,7 @@ const neighborsKernel = System.create({ name: "indexes/neighborsKernel", kernel: } row[amount] = push; }); -const seedKernelParticles = System.create({ name: "indexes/seedKernelParticles" }, ({ commands }) => { +const seedKernelParticles = System.create({}, ({ commands }) => { for (let i = 0; i < particles; i++) { const x = ((i * 7919) % 1000) / 10; const y = ((i * 104729) % 1000) / 10; @@ -152,12 +152,12 @@ const seedKernelParticles = System.create({ name: "indexes/seedKernelParticles" /** Particles of `rekey 1M`, drifting through cells of 4 units. */ const drifting = million; -const drift = System.create({ name: "indexes/drift", kernel: "cpu", components: { write: [place] } }, row => { +const drift = System.create({ kernel: "cpu", components: { write: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 0.25, y, z]; }); /** The cell a particle is in, written only when it crosses into another, so the index's work is the crossings. */ -const rekeyCells = System.create({ name: "indexes/rekeyCells", kernel: "cpu", components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { +const rekeyCells = System.create({ kernel: "cpu", components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { const [x, y, z] = row[place]; const [cx, cy, cz] = row[cell]; const nx = Math.floor(x / 4); @@ -165,11 +165,11 @@ const rekeyCells = System.create({ name: "indexes/rekeyCells", kernel: "cpu", co const nz = Math.floor(z / 4); if (nx !== cx || ny !== cy || nz !== cz) row[cell] = [nx, ny, nz]; }); -const coarse = Index.create({ name: "indexes/coarse", key: [cell], components: { has: [place] } }); +const coarse = Index.create({ key: [cell], components: { has: [place] } }); /** Uses `coarse` once, so a run refreshes it. */ -const lookCoarse = System.create({ name: "indexes/lookCoarse", lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); -const frame = System.create({ name: "indexes/frame", systems: [drift, rekeyCells, lookCoarse] }); -const seedDrifting = System.create({ name: "indexes/seedDrifting" }, ({ commands }) => { +const lookCoarse = System.create({ lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); +const frame = System.create({ systems: [drift, rekeyCells, lookCoarse] }); +const seedDrifting = System.create({}, ({ commands }) => { for (let i = 0; i < drifting; i++) { const x = (i * 7919) % 4000 / 10; const y = (i * 104729) % 4000 / 10; @@ -177,12 +177,12 @@ const seedDrifting = System.create({ name: "indexes/seedDrifting" }, ({ commands } }); /** The same frame without the index: what computing the key costs alone. */ -const frameUnindexed = System.create({ name: "indexes/frameUnindexed", systems: [drift, rekeyCells] }); +const frameUnindexed = System.create({ systems: [drift, rekeyCells] }); /** Rows of `order run 100K`, ranked in a scattered order. */ const orderedRows = 100_000; -const byRank = Index.create({ name: "indexes/byRank", key: [serial], sorted: true, components: { has: [amount] } }); -const seedRanked = System.create({ name: "indexes/seedRanked" }, ({ commands }) => { +const byRank = Index.create({ key: [serial], sorted: true, components: { has: [amount] } }); +const seedRanked = System.create({}, ({ commands }) => { for (let i = 0; i < orderedRows; i++) { if (i < orderedRows / 100) commands.spawn([serial, amount, marked])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); else commands.spawn([serial, amount])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); @@ -190,19 +190,19 @@ const seedRanked = System.create({ name: "indexes/seedRanked" }, ({ commands }) }); let accumulated = 0; /** Sums its rows' amounts, weighting each by the sum so far, so the order rows run in is part of the result. */ -const inRankOrder = System.create({ name: "indexes/inRankOrder", components: { read: [amount] }, order: byRank }, row => { +const inRankOrder = System.create({ components: { read: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const inStorageOrder = System.create({ name: "indexes/inStorageOrder", components: { read: [amount] } }, row => { +const inStorageOrder = System.create({ components: { read: [amount] } }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const changedInRankOrder = System.create({ name: "indexes/changedInRankOrder", components: { read: [amount], changed: [amount] }, order: byRank }, row => { +const changedInRankOrder = System.create({ components: { read: [amount], changed: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const bumpMarked = System.create({ name: "indexes/bumpMarked", components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); +const bumpMarked = System.create({ components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); let pushed = 0; -const neighbors = System.create({ name: "indexes/neighbors", components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighbors = System.create({ components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -214,9 +214,9 @@ const neighbors = System.create({ name: "indexes/neighbors", components: { read: }); /** Ranks of `sorted range 1M`, one an entity, and the ranges it looks up, ten ranks each. */ -const ranked = Index.create({ name: "indexes/ranked", key: [serial], sorted: true, components: { has: [amount] } }); +const ranked = Index.create({ key: [serial], sorted: true, components: { has: [amount] } }); const ranges = 10_000; -const lookRanges = System.create({ name: "indexes/lookRanges", lookup: { ranked: { index: ranked, read: [amount] } } }, ({ lookup }) => { +const lookRanges = System.create({ lookup: { ranked: { index: ranked, read: [amount] } } }, ({ lookup }) => { let sum = 0; for (let r = 0; r < ranges; r++) { const start = (r * 7919) % (million - 10); @@ -228,15 +228,15 @@ const lookRanges = System.create({ name: "indexes/lookRanges", lookup: { ranked: /** Entities `sorted page 100K` pages through, by a value one of them changes each run, the page the 20 after the first 100 with a small rank. */ const pageRows = 100_000; -const byAmount = Index.create({ name: "indexes/byAmount", key: [amount], sorted: true, components: { has: [key] } }); -const seedPage = System.create({ name: "indexes/seedPage" }, ({ commands }) => { +const byAmount = Index.create({ key: [amount], sorted: true, components: { has: [key] } }); +const seedPage = System.create({}, ({ commands }) => { commands.spawn([amount, key, marked])({ [amount]: 0, [key]: 0, [marked]: true }); for (let i = 1; i < pageRows; i++) commands.spawn([amount, key])({ [amount]: (i * 7919) % pageRows, [key]: i }); }); -const nudgeAmount = System.create({ name: "indexes/nudgeAmount", components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); +const nudgeAmount = System.create({ components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); let page: number[] = []; /** The page, recomputed only when what it reads changed: the 20 rows after the first 100 with a key below half, highest amounts first. */ -const readPage = System.create({ name: "indexes/readPage", lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { +const readPage = System.create({ lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { const all = lookup.byAmount(); if (!all.changed) return; const rows: number[] = []; @@ -249,7 +249,7 @@ const readPage = System.create({ name: "indexes/readPage", lookup: { byAmount: { } page = rows; }); -const nudgeThenPage = System.create({ name: "indexes/nudgeThenPage", systems: [nudgeAmount, readPage] }); +const nudgeThenPage = System.create({ systems: [nudgeAmount, readPage] }); /** Entities of the keyless benchmarks, and the 1% of them `keyless refresh 1M 1%` moves in and out. */ const tagged = Symbol("indexes/tagged"); @@ -258,8 +258,8 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -const untagged = Index.create({ name: "indexes/untagged", key: [], components: { has: [amount], not: [tagged] } }); -const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands }) => { +const untagged = Index.create({ key: [], components: { has: [amount], not: [tagged] } }); +const seedAmounts = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) { if (i % 100 === 0) commands.spawn([amount, marked])({ [amount]: i, [marked]: true }); else commands.spawn([amount])({ [amount]: i }); @@ -267,24 +267,24 @@ const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands } }); /** Tags every marked entity, which leaves the index, and untags them the next run, which enter it again. */ let tagging = true; -const toggleMarked = System.create({ name: "indexes/toggleMarked", components: { has: [marked] } }, (row, { commands }) => { +const toggleMarked = System.create({ components: { has: [marked] } }, (row, { commands }) => { if (tagging) commands.add(row.entity, tagged); else commands.remove(row.entity, tagged); }); -const lookUntagged = System.create({ name: "indexes/lookUntagged", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { found += lookup.untagged().length; }); +const lookUntagged = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { found += lookup.untagged().length; }); /** Despawns the entities it spawned the run before and spawns as many again. */ let spawnedLast: Entity[] = []; -const spawnAndDespawn = System.create({ name: "indexes/spawnAndDespawn" }, ({ commands }) => { +const spawnAndDespawn = System.create({}, ({ commands }) => { for (const entity of spawnedLast) commands.despawn(entity); const spawned: Entity[] = []; for (let i = 0; i < million / 100; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); spawnedLast = spawned; }); /** Writes every entity's amount, which the index holds by but doesn't watch. */ -const writeAmounts = System.create({ name: "indexes/writeAmounts", kernel: "cpu", components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); +const writeAmounts = System.create({ kernel: "cpu", components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); /** Reads the index's length and whether it changed, as a count kept by a system would. */ const lengthReads = 100_000; -const readLengths = System.create({ name: "indexes/readLengths", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { +const readLengths = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { let sum = 0; for (let i = 0; i < lengthReads; i++) { const rows = lookup.untagged(); @@ -302,12 +302,12 @@ declare module "@adobe/ecs" { [flag]: { type: boolean }; } } -const byFlag = Index.create({ name: "indexes/byFlag", key: [flag] }); -const seedFlags = System.create({ name: "indexes/seedFlags" }, ({ commands }) => { +const byFlag = Index.create({ key: [flag] }); +const seedFlags = System.create({}, ({ commands }) => { for (let i = 0; i < flagged; i++) commands.spawn([flag])({ [flag]: i % 2 === 0 }); }); /** A run of observer systems alone, which passes every one added. */ -const pass = System.create({ name: "indexes/pass", observer: true }, () => {}); +const pass = System.create({ observer: true }, () => {}); /** A count of each bucket, written only when the index's entries changed: by `changed: true`, or by its lists' `changed`. */ const countsWhenChanged = (filtered: boolean): System => filtered ? System.create({ observer: true, lookup: { byFlag: { index: byFlag, changed: true } } }, ({ lookup }) => { @@ -335,19 +335,19 @@ const restingCounts = async (filtered: boolean): Promise => { /** Entities toggled between `byFlag`'s two buckets, one a run, each a different one spread through entity order. */ let flaggedEntities: Entity[] = []; -const seedFlagged = System.create({ name: "indexes/seedFlagged" }, ({ commands }, input: { readonly count: number }) => { +const seedFlagged = System.create({}, ({ commands }, input: { readonly count: number }) => { flaggedEntities = []; for (let i = 0; i < input.count; i++) flaggedEntities.push(commands.spawn([flag])({ [flag]: i % 2 === 0 })); }); -const toggleFlag = System.create({ name: "indexes/toggleFlag", target: "entity", components: { write: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { +const toggleFlag = System.create({ target: "entity", components: { write: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { row[flag] = !row[flag]; }); /** Both buckets' lengths, as a count of done and not done todos. */ -const readFlagLengths = System.create({ name: "indexes/readFlagLengths", lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { +const readFlagLengths = System.create({ lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { found = lookup.byFlag(false).length + lookup.byFlag(true).length; }); /** Both buckets' first ten rows, in entity order. */ -const readFlagRows = System.create({ name: "indexes/readFlagRows", lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { +const readFlagRows = System.create({ lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { let sum = 0; for (const value of [false, true]) { const rows = lookup.byFlag(value); diff --git a/packages/ecs/src/perftest/loop.ts b/packages/ecs/src/perftest/loop.ts index 48b84da9..18658a4b 100644 --- a/packages/ecs/src/perftest/loop.ts +++ b/packages/ecs/src/perftest/loop.ts @@ -12,7 +12,7 @@ export const loopTicks = 10_000; export const observerIdles = 100; /** Observer systems no entity matches, each run after every tick. */ -const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ name: `loop/idleObserver${i}`, observer: true, components: { has: [unused] } }, () => {})); +const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ observer: true, components: { has: [unused] } }, () => {})); /** * The fastest of `stretches` stretches of `loopTicks` ticks with nothing changed, after one to warm up: a world with diff --git a/packages/ecs/src/perftest/observed-gpu.ts b/packages/ecs/src/perftest/observed-gpu.ts index f2050074..57df0640 100644 --- a/packages/ecs/src/perftest/observed-gpu.ts +++ b/packages/ecs/src/perftest/observed-gpu.ts @@ -21,11 +21,11 @@ declare module "@adobe/ecs" { const heated = 100_000; let spawned: Entity[] = []; -const spawn = System.create({ name: "observedGpu/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < heated; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: 0, [rate]: 1 })); }); -const warm = System.create({ name: "observedGpu/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { row[heat] = row[heat] + row[rate]; }); diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 7dc803e1..a8809202 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -28,19 +28,19 @@ declare module "@adobe/ecs" { } /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "observed/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); /** Writes the marked entity's value, so one entity observer is notified and a selection or count is a candidate. */ -const nudge = System.create({ name: "observed/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank. */ -const advance = System.create({ name: "observed/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); /** A system no entity matches: a run that changes nothing. */ -const idle = System.create({ name: "observed/idle", components: { has: [unmatched] } }, () => {}); +const idle = System.create({ components: { has: [unmatched] } }, () => {}); const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); diff --git a/packages/ecs/src/perftest/observer.ts b/packages/ecs/src/perftest/observer.ts index 7f479fa9..fa5ba5da 100644 --- a/packages/ecs/src/perftest/observer.ts +++ b/packages/ecs/src/perftest/observer.ts @@ -14,14 +14,14 @@ import type { World } from "../interface/world/world.js"; * first call only starts the stretch that warms up. */ export const eachTick = (world: World, notify: () => void): (() => void) => { - const counter = System.create({ name: "perftest/eachTick", observer: true, resources: { changed: [clock] } }, () => { notify(); }); + const counter = System.create({ observer: true, resources: { changed: [clock] } }, () => { notify(); }); world.add(counter); return () => world.remove(counter); }; /** The clock as an observer run last read it. */ const read = { tick: 0, now: 0 }; -const readClock = System.create({ name: "perftest/readClock", observer: true, resources: { read: [clock] } }, ({ resources }) => { +const readClock = System.create({ observer: true, resources: { read: [clock] } }, ({ resources }) => { read.tick = resources[clock].tick; read.now = resources[clock].now; }); @@ -36,7 +36,7 @@ export const ticksAfter = async (world: World, count: number): Promise => let { tick, now } = read; let seen = 0; await new Promise(resolve => { - const counter = System.create({ name: "perftest/ticksAfter", observer: true, resources: { read: [clock] } }, ({ resources }) => { + const counter = System.create({ observer: true, resources: { read: [clock] } }, ({ resources }) => { const current = resources[clock]; if (current.tick === tick && current.now === now) return; tick = current.tick; diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index f4b04244..456fe004 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -37,7 +37,7 @@ const archetypes = [ Archetype.create([color, mass, position, velocity]), ] as const; -export const spawn = System.create({ name: "perf/spawn" }, ({ commands }, input: { readonly count: number }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const p = [i + 1, i + 1, i + 1] as const; const v = [-i, -i, -i] as const; @@ -46,7 +46,6 @@ export const spawn = System.create({ name: "perf/spawn" }, ({ commands }, input: }); export const move = System.create({ - name: "perf/move", kernel: "cpu", components: { read: [velocity], write: [position], has: [visible, enabled] }, }, row => { diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index 2ca732a8..d4bd44eb 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -26,23 +26,23 @@ declare module "@adobe/ecs" { } /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "reads/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); /** Writes the marked entity's value. */ -const nudge = System.create({ name: "reads/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ -const advance = System.create({ name: "reads/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); /** The entities `look` and `lookChanged` look up, each run. */ let looked: readonly Entity[] = []; let found = 0; /** Looks up every entity of `looked` by entity, reading its value, after `nudge` where both run. */ -const look = System.create({ name: "reads/look", lookup: { values: { read: [value] } }, after: [nudge] }, ({ lookup }) => { +const look = System.create({ lookup: { values: { read: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0) found++; @@ -50,15 +50,15 @@ const look = System.create({ name: "reads/look", lookup: { values: { read: [valu }); /** The same, with `changed`: counts the entities whose value may have changed since it last ran. */ -const lookChanged = System.create({ name: "reads/lookChanged", lookup: { values: { read: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { +const lookChanged = System.create({ lookup: { values: { read: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0 && row.changed) found++; } }); -const nudgeThenLook = System.create({ name: "reads/nudgeThenLook", systems: [nudge, look] }); -const nudgeThenLookChanged = System.create({ name: "reads/nudgeThenLookChanged", systems: [nudge, lookChanged] }); +const nudgeThenLook = System.create({ systems: [nudge, look] }); +const nudgeThenLookChanged = System.create({ systems: [nudge, lookChanged] }); /** A run of `system` over a world of `observedEntities` entities `looked` names, `found` after the last. */ const lookups = async (system: System): Promise<{ readonly best: number; readonly found: number }> => { diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index 83e01a6f..d877bbf3 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -32,14 +32,14 @@ declare module "@adobe/ecs" { } /** An empty transaction. */ -export const empty = System.create({ name: "remote/empty" }, () => {}); +export const empty = System.create({}, () => {}); /** Changes the observed resource every tick. */ -export const count = System.create({ name: "remote/count", resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); -export const seedRanks = System.create({ name: "remote/seedRanks" }, ({ commands }, input: { readonly count: number }) => { +export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); +export const seedRanks = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); -const flip = System.create({ name: "remote/flip", resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); -const reverse = System.create({ name: "remote/reverse", kernel: "cpu", components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { +const flip = System.create({ resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); +const reverse = System.create({ kernel: "cpu", components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { row[order] = row[rank] * resources[sign]; }); /** Reverses the entities' order every tick. */ @@ -49,19 +49,19 @@ export const reorder = System.create([flip, reverse]); export const observerSeen = { last: 0, notified: 0 }; /** On a joined thread, after each sync point that changed it: the observed resource. */ -export const observerCounter = System.create({ name: "remote/observerCounter", observer: true, resources: { read: [counter], changed: [counter] } }, ({ resources }) => { +export const observerCounter = System.create({ observer: true, resources: { read: [counter], changed: [counter] } }, ({ resources }) => { observerSeen.last = resources[counter]; observerSeen.notified++; }); /** Every entity with a rank, in entity order. */ -const ranked = Index.create({ name: "remote/ranked", key: [], components: { has: [rank, order] } }); +const ranked = Index.create({ key: [], components: { has: [rank, order] } }); const sortedKeys = { keys: new Float64Array(0), entities: new Float64Array(0), positions: new Uint32Array(0) }; /** * On a joined thread, after each sync point: the entities in `order`, sorted from a scan of the index of every ranked * entity into reused arrays. */ -export const observerSelect = System.create({ name: "remote/observerSelect", observer: true, lookup: { all: { index: ranked, read: [order] } } }, ({ lookup }) => { +export const observerSelect = System.create({ observer: true, lookup: { all: { index: ranked, read: [order] } } }, ({ lookup }) => { const list = lookup.all(); const count = list.length; if (sortedKeys.keys.length < count) { @@ -84,13 +84,13 @@ export const observerSelect = System.create({ name: "remote/observerSelect", obs }); /** Rows with a caption and an amount, a todo's label and a number, which `retext` and `reamount` rewrite 1% of a tick. */ -export const seedCaptions = System.create({ name: "remote/seedCaptions" }, ({ commands }, input: { readonly count: number }) => { +export const seedCaptions = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, caption, amount])({ [rank]: i, [caption]: `Buy milk and bread #${i}`, [amount]: i }); }); -const recaption = System.create({ name: "remote/recaption", components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const recaption = System.create({ components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[caption] = `Buy milk and bread #${row[rank]}.${resources[counter]}`; }); -const reamount = System.create({ name: "remote/reamount", components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const reamount = System.create({ components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[amount] = row[rank] + resources[counter]; }); /** Rewrites 1% of the captions a tick. */ @@ -99,12 +99,12 @@ export const retext = System.create([count, recaption]); export const reamounted = System.create([count, reamount]); /** On a joined thread, after each sync point: every row's caption, out of its record. */ -export const observerCaptions = System.create({ name: "remote/observerCaptions", observer: true, components: { read: [caption] } }, row => { +export const observerCaptions = System.create({ observer: true, components: { read: [caption] } }, row => { observerSeen.last += row[caption].length; observerSeen.notified++; }); /** On a joined thread, after each sync point: every row's amount. */ -export const observerAmounts = System.create({ name: "remote/observerAmounts", observer: true, components: { read: [amount] } }, row => { +export const observerAmounts = System.create({ observer: true, components: { read: [amount] } }, row => { observerSeen.last += row[amount]; observerSeen.notified++; }); diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts index b082f64b..c6f14f2c 100644 --- a/packages/ecs/src/perftest/render-order.ts +++ b/packages/ecs/src/perftest/render-order.ts @@ -22,11 +22,11 @@ declare module "@adobe/ecs" { } } -const byDepth = Index.create({ name: "renderOrder/byDepth", key: [paintDepth], sorted: true }); +const byDepth = Index.create({ key: [paintDepth], sorted: true }); const items = 50_000; let spawned: Entity[] = []; -const spawn = System.create({ name: "renderOrder/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < items; i++) { spawned.push(i % 2 === 0 ? commands.spawn([paintDepth, shape])({ [paintDepth]: i, [shape]: true }) : commands.spawn([paintDepth, image])({ [paintDepth]: i, [image]: true })); @@ -34,15 +34,14 @@ const spawn = System.create({ name: "renderOrder/spawn" }, ({ commands }) => { }); /** Moves one item to a new depth, as reordering it does. */ const restack = System.create({ - name: "renderOrder/restack", components: { write: [paintDepth] }, target: "item", }, (row, _context, input: { readonly item: Entity; readonly depth: number }) => { row[paintDepth] = input.depth; }); /** Draws nothing: its run uploads what it reads and its order. */ -const draw = System.render({ name: "renderOrder/draw", order: byDepth, shader: { accessors: { paintDepth } }, after: [restack] }, () => {}); -const frame = System.create({ name: "renderOrder/frame", systems: [restack, draw] }); +const draw = System.render({ order: byDepth, shader: { accessors: { paintDepth } }, after: [restack] }, () => {}); +const frame = System.create({ systems: [restack, draw] }); /** Timed per run by `timed`, which waits for the GPU. */ export const renderOrderBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts index c834cdf1..f3b80b90 100644 --- a/packages/ecs/src/perftest/strings.ts +++ b/packages/ecs/src/perftest/strings.ts @@ -23,17 +23,17 @@ const rows = 10_000; /** A todo's label, of a typical length, different per row and round. */ const labelOf = (row: number, round: number): string => `Buy milk and bread #${row}.${round}`; -const seed = System.create({ name: "strings/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([text, boxed])({ [text]: labelOf(i, 0), [boxed]: { text: labelOf(i, 0) } }); }); let total = 0; let round = 0; -const readText = System.create({ name: "strings/readText", components: { read: [text] } }, row => { total += row[text].length; }); -const readBoxed = System.create({ name: "strings/readBoxed", components: { read: [boxed] } }, row => { total += row[boxed].text.length; }); -const writeText = System.create({ name: "strings/writeText", components: { write: [text] } }, row => { row[text] = labelOf(row.entity, round); }); -const writeBoxed = System.create({ name: "strings/writeBoxed", components: { write: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); +const readText = System.create({ components: { read: [text] } }, row => { total += row[text].length; }); +const readBoxed = System.create({ components: { read: [boxed] } }, row => { total += row[boxed].text.length; }); +const writeText = System.create({ components: { write: [text] } }, row => { row[text] = labelOf(row.entity, round); }); +const writeBoxed = System.create({ components: { write: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); /** A seeded world, with a joined thread on this one when `shared`, which keeps records of its strings. */ const seeded = async (shared = false): Promise<{ readonly world: World; readonly dispose: () => Promise }> => { diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index d5927d1f..aca06794 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -54,7 +54,6 @@ const counted = { type: "object", properties: { count: { type: "integer" } } } a /** `count` entities of one archetype, or with `sources: 4`, interleaved over four by two source tags. */ export const seed = System.create({ - name: "structural/seed", }, ({ commands }, input: { readonly count: number; readonly sources: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(seeded[input.sources === 1 ? 0 : i % 4])({ [index]: i, [value]: i }); @@ -62,12 +61,12 @@ export const seed = System.create({ }); /** `count` spawns of one archetype. */ -export const spawnBulk = System.create({ name: "structural/spawnBulk" }, ({ commands }, input: { readonly count: number }) => { +export const spawnBulk = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(made)({ [index]: i, [value]: i }); }); /** One spawn of each of four archetypes: a run's fixed cost per spawn. */ -export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ({ commands }) => { +export const spawnSingles = System.create({}, ({ commands }) => { commands.spawn(made)({ [index]: 0, [value]: 0 }); commands.spawn(sixteen[1])({ [index]: 1, [value]: 1 }); commands.spawn(sixteen[2])({ [index]: 2, [value]: 2 }); @@ -75,78 +74,78 @@ export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ( }); /** `count` spawns over sixteen archetypes, entity i into archetype i % 16: more component sets than spawns cache. */ -export const spawnSixteen = System.create({ name: "structural/spawnSixteen" }, ({ commands }, input: { readonly count: number }) => { +export const spawnSixteen = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(sixteen[i % 16])({ [index]: i, [value]: i }); } }); /** A spawn per row, from a kernel. */ -export const spawnKernel = System.create({ name: "structural/spawnKernel", kernel: "cpu", components: { read: [index], not: [spawned] } }, (row, { commands }) => { +export const spawnKernel = System.create({ kernel: "cpu", components: { read: [index], not: [spawned] } }, (row, { commands }) => { commands.spawn(made)({ [index]: row[index], [value]: 0 }); }); /** Despawns every entity a spawn benchmark made. */ -export const despawnSpawned = System.create({ name: "structural/despawnSpawned", components: { has: [spawned] } }, (row, { commands }) => { +export const despawnSpawned = System.create({ components: { has: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Despawns every seeded entity. */ -export const despawnAll = System.create({ name: "structural/despawnAll", components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnAll = System.create({ components: { has: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** The same, from a kernel. */ -export const despawnKernel = System.create({ name: "structural/despawnKernel", kernel: "cpu", components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnKernel = System.create({ kernel: "cpu", components: { has: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Entities `spawnOne` made, for `despawnOne`. */ const pending: Entity[] = []; -export const spawnOne = System.create({ name: "structural/spawnOne" }, ({ commands }) => { +export const spawnOne = System.create({}, ({ commands }) => { pending.push(commands.spawn(made)({ [index]: 0, [value]: 0 })); }); /** Despawns one entity: a run's fixed cost per despawn. */ -export const despawnOne = System.create({ name: "structural/despawnOne" }, ({ commands }) => { +export const despawnOne = System.create({}, ({ commands }) => { const entity = pending.pop(); if (entity !== undefined) commands.despawn(entity); }); -export const addFlag = System.create({ name: "structural/addFlag", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlag = System.create({ components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlag = System.create({ name: "structural/removeFlag", components: { has: [flag] } }, (row, { commands }) => { +export const removeFlag = System.create({ components: { has: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addFlagKernel = System.create({ name: "structural/addFlagKernel", kernel: "cpu", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlagKernel = System.create({ kernel: "cpu", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlagKernel = System.create({ name: "structural/removeFlagKernel", kernel: "cpu", components: { has: [flag] } }, (row, { commands }) => { +export const removeFlagKernel = System.create({ kernel: "cpu", components: { has: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addAmount = System.create({ name: "structural/addAmount", components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { +export const addAmount = System.create({ components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { commands.add(row.entity, amount, row[index]); }); -export const removeAmount = System.create({ name: "structural/removeAmount", components: { has: [amount] } }, (row, { commands }) => { +export const removeAmount = System.create({ components: { has: [amount] } }, (row, { commands }) => { commands.remove(row.entity, amount); }); /** Adds a value every row already has: no archetype moves, only values replaced and marked changed. */ -export const replaceAmount = System.create({ name: "structural/replaceAmount", components: { read: [amount] } }, (row, { commands }) => { +export const replaceAmount = System.create({ components: { read: [amount] } }, (row, { commands }) => { commands.add(row.entity, amount, row[amount] + 1); }); -export const addSeveral = System.create({ name: "structural/addSeveral", components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addSeveral = System.create({ components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, several, { [amount]: row[index], [extra]: 1 }); }); -export const removeSeveral = System.create({ name: "structural/removeSeveral", components: { has: [flag, amount, extra] } }, (row, { commands }) => { +export const removeSeveral = System.create({ components: { has: [flag, amount, extra] } }, (row, { commands }) => { commands.remove(row.entity, several); }); diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 6f4e8ad1..bc1b695a 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -47,7 +47,7 @@ const moving = Archetype.create([position, velocity, index]); const following = Archetype.create([position, velocity, index, target]); /** `count` entities, each targeting the one spawned before it. */ -export const seed = System.create({ name: "suite/seed" }, ({ commands }, input: { readonly count: number }) => { +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); for (let i = 1; i < input.count; i++) { previous = commands.spawn(following)({ [position]: [i, i * 0.5, -i], [velocity]: [1, 0, 0], [index]: i, [target]: previous }); @@ -55,14 +55,14 @@ export const seed = System.create({ name: "suite/seed" }, ({ commands }, input: }); /** Despawns a tenth of the entities and spawns as many replacements. */ -export const churn = System.create({ name: "suite/churn", components: { read: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { +export const churn = System.create({ components: { read: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { if (row[index] % 10 !== input.group) return; commands.despawn(row.entity); commands.spawn(moving)({ [position]: row[position], [velocity]: [0, 0, 0], [index]: row[index] }); }); /** Adds the tag to one tenth and removes it from another: a fifth of the entities move archetype. */ -export const retag = System.create({ name: "suite/retag", components: { read: [index] } }, (row, { commands }, input: { readonly group: number }) => { +export const retag = System.create({ components: { read: [index] } }, (row, { commands }, input: { readonly group: number }) => { const group = row[index] % 10; if (group === input.group) commands.add(row.entity, tag); else if (group === (input.group + 5) % 10) commands.remove(row.entity, tag); @@ -70,7 +70,6 @@ export const retag = System.create({ name: "suite/retag", components: { read: [i /** Writes 1% of positions. */ export const poke = System.create({ - name: "suite/poke", kernel: "cpu", components: { read: [index], write: [position] }, }, (row, _context, input: { readonly group: I32 }) => { @@ -82,7 +81,6 @@ export const poke = System.create({ /** Writes 1% of positions, clustered in one contiguous run of rows. */ export const pokeCluster = System.create({ - name: "suite/pokeCluster", kernel: "cpu", components: { read: [index], write: [position] }, }, (row, _context, input: { readonly group: I32 }) => { @@ -95,7 +93,6 @@ export const pokeCluster = System.create({ /** Sees only what changed since it last ran. */ export const observe = System.create({ - name: "suite/observe", kernel: "cpu", components: { read: [position], write: [velocity], changed: [position] }, }, row => { @@ -105,7 +102,6 @@ export const observe = System.create({ /** Steers toward the target: one lookup per row. */ export const follow = System.create({ - name: "suite/follow", kernel: "cpu", components: { read: [position, target], write: [velocity] }, lookup: { targets: { read: [position] } }, @@ -120,7 +116,6 @@ export const follow = System.create({ /** Issues commands from a kernel: a tenth of the rows record a hit. */ export const emit = System.create({ - name: "suite/emit", kernel: "cpu", components: { read: [index, position] }, }, (row, { commands }) => { @@ -131,37 +126,36 @@ export const emit = System.create({ }); /** Six dependent kernels: one tier each, so dispatch cost shows. */ -export const chain1 = System.create({ name: "suite/chain1", kernel: "cpu", components: { write: [velocity] } }, row => { +export const chain1 = System.create({ kernel: "cpu", components: { write: [velocity] } }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain2 = System.create({ name: "suite/chain2", kernel: "cpu", components: { write: [velocity] }, after: [chain1] }, row => { +export const chain2 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain1] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain3 = System.create({ name: "suite/chain3", kernel: "cpu", components: { write: [velocity] }, after: [chain2] }, row => { +export const chain3 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain2] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain4 = System.create({ name: "suite/chain4", kernel: "cpu", components: { write: [velocity] }, after: [chain3] }, row => { +export const chain4 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain3] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain5 = System.create({ name: "suite/chain5", kernel: "cpu", components: { write: [velocity] }, after: [chain4] }, row => { +export const chain5 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain4] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain6 = System.create({ name: "suite/chain6", kernel: "cpu", components: { write: [velocity] }, after: [chain5] }, row => { +export const chain6 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain5] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain = System.create({ name: "suite/chain", systems: [chain1, chain2, chain3, chain4, chain5, chain6] }); +export const chain = System.create({ systems: [chain1, chain2, chain3, chain4, chain5, chain6] }); /** Fifty small main-thread systems in one tier, each reading every row. */ let total = 0; export const smalls = System.create({ - name: "suite/smalls", - systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/small${i}`, components: { read: [position] } }, row => { + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { read: [position] } }, row => { total += row[position][0]; })), }); @@ -193,30 +187,29 @@ export const seeded = async (create: () => World, count: number): Promise }; /** Folds every row's index into a sum and a maximum. */ -export const reduce = System.create({ name: "suite/reduce", kernel: "cpu", components: { read: [index] }, reduce: { [indexSum]: "+", [highest]: "max" } }, (row, { reduce }) => { +export const reduce = System.create({ kernel: "cpu", components: { read: [index] }, resources: { reduce: [indexSum, highest] } }, (row, { reduce }) => { reduce[indexSum](row[index]); reduce[highest](row[index]); }); /** A system no entity matches: what a run costs with nothing to do. */ -export const idle = System.create({ name: "suite/idle", components: { has: [unused] } }, () => {}); +export const idle = System.create({ components: { has: [unused] } }, () => {}); /** A system that throws, so each run of it has a report to spawn. */ -export const failing = System.create({ name: "suite/failing" }, () => { throw new Error("suite: failing"); }); +export const failing = System.create({}, () => { throw new Error("suite: failing"); }); /** An observer system no entity matches: a run of it alone has no tiers. */ -export const idleObserver = System.create({ name: "suite/idleObserver", observer: true, components: { has: [unused] } }, () => {}); +export const idleObserver = System.create({ observer: true, components: { has: [unused] } }, () => {}); /** Fifty systems no entity matches, in one tier. */ export const idles = System.create({ - name: "suite/idles", - systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/idle${i}`, components: { has: [unused] } }, () => {})), + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { has: [unused] } }, () => {})), }); /** Spawns one entity: a run with a sync point that applies one command. */ -export const spawnOne = System.create({ name: "suite/spawnOne" }, ({ commands }) => { +export const spawnOne = System.create({}, ({ commands }) => { commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); }); /** One main-thread system reading every row. */ -export const small = System.create({ name: "suite/small", components: { read: [position] } }, row => { +export const small = System.create({ components: { read: [position] } }, row => { total += row[position][0]; }); diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts index 5c7621f0..9418c271 100644 --- a/packages/ecs/src/perftest/systems.ts +++ b/packages/ecs/src/perftest/systems.ts @@ -19,7 +19,7 @@ declare module "@adobe/ecs" { /** Entities the added systems read. */ const entities = 100; -const seed = System.create({ name: "systems/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < entities; i++) commands.spawn([level])({ [level]: i }); }); @@ -29,7 +29,7 @@ let read = 0; const fresh = (): System => System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); /** One module's system, added and removed again and again. */ -const again = System.create({ name: "systems/again", components: { read: [level], changed: [level] } }, row => { read += row[level]; }); +const again = System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); /** Ticks in each timed stretch. */ export const churnTicks = 10_000; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index 3b38bdac..684ef5ad 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -48,36 +48,36 @@ export const lateRows = 10_000; /** Ticks in each timed stretch of the prompt tail. */ export const tailTicks = 1_000; -const spawnPlaces = System.create({ name: "tails/spawnPlaces" }, ({ commands }) => { +const spawnPlaces = System.create({}, ({ commands }) => { for (let k = 0; k < promptRows; k++) commands.spawn([place])({ [place]: [k, 0, 0] }); }); -const spawnPoses = System.create({ name: "tails/spawnPoses" }, ({ commands }) => { +const spawnPoses = System.create({}, ({ commands }) => { for (let k = 0; k < lateRows; k++) commands.spawn([place, pose])({ [place]: [k, 0, 0], [pose]: [k, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }); }); -const here = System.create({ name: "tails/here" }, () => {}); +const here = System.create({}, () => {}); /** On the joined thread, terminal: reads every row's place, as a DOM-writing system reads positions. */ -const draw = System.create({ name: "tails/draw", components: { read: [place] }, resources: { read: [surface] } }, (row, { resources }) => { +const draw = System.create({ components: { read: [place] }, resources: { read: [surface] } }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0]; }); /** A tick of a world system and a 1,000-row tail that finishes before the next tick needs it. */ -export const promptTick = System.create({ name: "tails/promptTick", systems: [here, draw] }); +export const promptTick = System.create({ systems: [here, draw] }); /** On the joined thread, ahead of the late tail: busy for a while, as a main thread is with layout. */ -const busy = System.create({ name: "tails/busy", resources: { read: [surface] } }, ({ resources }) => { +const busy = System.create({ resources: { read: [surface] } }, ({ resources }) => { const until = performance.now() + 4; while (performance.now() < until && resources[surface] === surfaceStart); }); /** On the joined thread, terminal, after `busy`: reads every row's place and pose, and the resource `poke` writes. */ -const copied = System.create({ name: "tails/copied", components: { read: [place, pose] }, resources: { read: [surface, poked] }, after: [busy] }, (row, { resources }) => { +const copied = System.create({ components: { read: [place, pose] }, resources: { read: [surface, poked] }, after: [busy] }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0] + row[pose][15] + resources[poked]; }); -export const lateTick = System.create({ name: "tails/lateTick", systems: [busy, copied] }); +export const lateTick = System.create({ systems: [busy, copied] }); /** On the world's thread: writes what the late tail reads, so it copies the rest of the tail's reads first. */ -const poke = System.create({ name: "tails/poke", resources: { write: [poked] } }, ({ resources }) => { +const poke = System.create({ resources: { write: [poked] } }, ({ resources }) => { resources[poked] += 1; }); -const nothing = System.create({ name: "tails/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** Steps of work each row of the overlapping tail does, about 2.5 ms over 10,000 rows; the world's work after its writer. */ export const overlapWork = 40; @@ -86,23 +86,23 @@ export const overlapWorldMilliseconds = 2; export const overlapTicks = 100; /** On the world's thread: writes every row's place, which the last tick's tail still reads. */ -const overlapWrite = System.create({ name: "tails/overlapWrite", components: { write: [place] } }, row => { +const overlapWrite = System.create({ components: { write: [place] } }, row => { row[place] = [row[place][0], 1, 0]; }); /** On the world's thread, after the writer: the rest of the tick's work, as systems reading what it wrote do. */ -const overlapSimulate = System.create({ name: "tails/overlapSimulate", resources: { read: [worldWork] }, after: [overlapWrite] }, ({ resources }) => { +const overlapSimulate = System.create({ resources: { read: [worldWork] }, after: [overlapWrite] }, ({ resources }) => { const until = performance.now() + resources[worldWork]; while (performance.now() < until); }); /** On the joined thread, terminal: works on every row's place and pose, as a DOM-writing system on main does. */ -const overlapDraw = System.create({ name: "tails/overlapDraw", components: { read: [place, pose] }, resources: { read: [surface, rowWork] }, after: [overlapWrite] }, (row, { resources }) => { +const overlapDraw = System.create({ components: { read: [place, pose] }, resources: { read: [surface, rowWork] }, after: [overlapWrite] }, (row, { resources }) => { if (resources[surface] !== surfaceStart) return; let sum = row[place][0] + row[pose][15]; for (let k = resources[rowWork]; k > 0; k--) sum = Math.sqrt(sum + k); drawn.sum += sum; }); -export const overlapTick = System.create({ name: "tails/overlapTick", systems: [overlapWrite, overlapSimulate, overlapDraw] }); -const setOverlap = System.create({ name: "tails/setOverlap", resources: { write: [rowWork, worldWork] } }, ({ resources }) => { +export const overlapTick = System.create({ systems: [overlapWrite, overlapSimulate, overlapDraw] }); +const setOverlap = System.create({ resources: { write: [rowWork, worldWork] } }, ({ resources }) => { resources[rowWork] = overlapWork; resources[worldWork] = overlapWorldMilliseconds; }); diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts index 5d815b4b..33332fda 100644 --- a/packages/ecs/src/perftest/transfers.ts +++ b/packages/ecs/src/perftest/transfers.ts @@ -23,7 +23,7 @@ export const taken = { bytes: 0 }; /** On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. */ export const take = System.create({ - name: "transfers/take", resources: { read: [sink] }, + resources: { read: [sink] }, }, ({ resources }, input: { readonly buffer: ArrayBuffer; readonly bitmap: ImageBitmap }) => { if (resources[sink] !== null) return; taken.bytes += input.buffer.byteLength + input.bitmap.width * input.bitmap.height * 4; diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 19f564a7..77d28624 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -18,26 +18,25 @@ declare module "@adobe/ecs" { const shapes = 100_000; let spawned: Entity[] = []; -const spawn = System.create({ name: "uploads/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < shapes; i++) spawned.push(commands.spawn([place])({ [place]: [i, 0, 0] })); }); const drag = System.create({ - name: "uploads/drag", components: { write: [place] }, target: "shape", }, (row, _context, input: { readonly shape: Entity; readonly x: number }) => { row[place] = [input.x, 1, 0]; }); /** Draws nothing: its run uploads what it reads. */ -const draw = System.render({ name: "uploads/draw", shader: { accessors: { place } }, after: [drag] }, () => {}); -const frame = System.create({ name: "uploads/frame", systems: [drag, draw] }); -const slide = System.create({ name: "uploads/slide", kernel: "cpu", components: { write: [place] } }, row => { +const draw = System.render({ shader: { accessors: { place } }, after: [drag] }, () => {}); +const frame = System.create({ systems: [drag, draw] }); +const slide = System.create({ kernel: "cpu", components: { write: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 1, y, z]; }); -const drawAfterSlide = System.render({ name: "uploads/drawAfterSlide", shader: { accessors: { place } }, after: [slide] }, () => {}); -const slideFrame = System.create({ name: "uploads/slideFrame", systems: [slide, drawAfterSlide] }); +const drawAfterSlide = System.render({ shader: { accessors: { place } }, after: [slide] }, () => {}); +const slideFrame = System.create({ systems: [slide, drawAfterSlide] }); /** Timed per run by `timed`, which waits for the GPU. */ export const uploadBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 84b6e6c4..2ac94eb7 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -32,15 +32,15 @@ const Node = Archetype.create([pose, shown, extent, order]); const rows = 100_000; -const seed = System.create({ name: "values/seed" }, ({ commands }, input: { readonly count: number }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(Node)({ [pose]: { tx: i, ty: 0, rotation: 0, sx: 1, sy: 1 }, [shown]: { tx: 0, ty: 0, rotation: 0, sx: 1, sy: 1 }, [extent]: [1, 1], [order]: i }); }); /** Shows each row's pose: a struct read and written whole per row. */ -const showPose = System.create({ name: "values/showPose", components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); +const showPose = System.create({ components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); /** Commits a pose and an extent per row as commands, values every row already has. */ -const commitPose = System.create({ name: "values/commitPose", kernel: "cpu", components: { read: [order] } }, (row, { commands }) => { +const commitPose = System.create({ kernel: "cpu", components: { read: [order] } }, (row, { commands }) => { commands.add(row.entity, pose, { tx: row[order], ty: 1, rotation: 0, sx: 1, sy: 1 }); commands.add(row.entity, extent, [2, 2]); }); @@ -78,7 +78,7 @@ export const valueBenchmarks: readonly Benchmark[] = [ best: { bytes: 32, basis: "Each command writes its 40-byte struct or 8-byte vec2 and reads its entity's 8-byte location." }, run: async () => { // Program-wide, so made here, in this benchmark's own process. - Finalizer.changes({ name: "values/history", watch: [pose, extent] }, ({ groups }) => { + Finalizer.changes({ watch: [pose, extent] }, ({ groups }) => { replacedPoses = 0; for (const group of groups) if (group.replaced.includes(pose)) replacedPoses += group.entities.length; }); diff --git a/packages/ecs/src/samples/particles/gpu/main.ts b/packages/ecs/src/samples/particles/gpu/main.ts index 10638d04..ecfe091f 100644 --- a/packages/ecs/src/samples/particles/gpu/main.ts +++ b/packages/ecs/src/samples/particles/gpu/main.ts @@ -59,11 +59,11 @@ const start = async (): Promise => { const world = World.create({ [gpuDevice]: device }); const { draw, ready } = createDraw(device, Gpu.layout(device), context, format); await ready; - const frame = System.create({ name: "gpuParticles/frame", systems: [...moving, draw] }); + const frame = System.create({ systems: [...moving, draw] }); await world.run(spawn, { count, width: canvas.width, height: canvas.height }); /** For automated checks: a few drawn particles' positions, which an observer system reads back from the GPU. */ const probed: (readonly number[])[] = []; - const probe = System.create({ name: "gpuParticles/probe", observer: true, components: { read: [position], has: [visible, enabled] } }, row => { + const probe = System.create({ observer: true, components: { read: [position], has: [visible, enabled] } }, row => { if (probed.length < 3) probed.push(row[position]); }); Reflect.set(globalThis, "sampleProbe", async () => { diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 8b61f139..4c8d26fd 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -41,7 +41,6 @@ const random = (seed: number): (() => number) => { /** The particle count, and the field's width and height in pixels. Entity i goes to archetype i % 4. */ export const spawn = System.create({ - name: "gpuParticles/spawn", }, ({ commands }, { count, width, height }: { readonly count: number; readonly width: number; readonly height: number }) => { const next = random(7); for (let i = 0; i < count; i++) { @@ -56,7 +55,6 @@ export const spawn = System.create({ }); export const move = System.create({ - name: "gpuParticles/move", kernel: "gpu", components: { read: [velocity], write: [position], has: [visible, enabled] }, }, row => { @@ -65,7 +63,6 @@ export const move = System.create({ /** The same move on the CPU, for comparison. */ export const moveOnCpu = System.create({ - name: "gpuParticles/moveOnCpu", kernel: "cpu", components: { read: [velocity], write: [position], has: [visible, enabled] }, }, row => { diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index c5866b4d..cf6436d9 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -19,7 +19,6 @@ declare module '@adobe/ecs' { // Main thread: resource-only, runs when `count` changes, including the first run. export const spawn = System.create({ - name: "particles/spawn", resources: { read: [count], changed: [count] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[count]; i++) { @@ -29,7 +28,6 @@ export const spawn = System.create({ // Kernels: linear components and read-only resources, so they can run in chunks across workers. export const gravity = System.create({ - name: "particles/gravity", kernel: "cpu", components: { write: [velocity] }, resources: { read: [delta] }, @@ -39,7 +37,6 @@ export const gravity = System.create({ }); export const integrate = System.create({ - name: "particles/integrate", kernel: "cpu", components: { read: [velocity], write: [position] }, resources: { read: [delta] }, @@ -53,18 +50,16 @@ export const integrate = System.create({ // Transaction carrying frame time into the ECS. export const setDelta = System.create({ - name: "particles/setDelta", resources: { write: [delta] }, }, ({ resources }, input: { readonly seconds: number }) => { resources[delta] = input.seconds; }); -export const main = System.create({ name: "particles/main", systems: [spawn, gravity, integrate] }); +export const main = System.create({ systems: [spawn, gravity, integrate] }); // Reads every position the frame left, in spawn order. const positions: Vec3[] = []; const read = System.create({ - name: "particles/read", observer: true, components: { read: [position] }, }, row => { diff --git a/packages/ecs/src/samples/todo/app/app.browser.test.ts b/packages/ecs/src/samples/todo/app/app.browser.test.ts index 6c90e9f5..3c7fff53 100644 --- a/packages/ecs/src/samples/todo/app/app.browser.test.ts +++ b/packages/ecs/src/samples/todo/app/app.browser.test.ts @@ -182,13 +182,13 @@ describe("the todo app", () => { }); // It runs in the tick that gives the todo its row, alongside createRows. -const failing = System.create({ name: "test/failing", components: { has: [todo] } }, () => { +const failing = System.create({ components: { has: [todo] } }, () => { throw new Error("a tick's system failed"); }); -const failingObserver = System.create({ name: "test/failingObserver", observer: true, components: { has: [row], changed: [row] } }, () => { +const failingObserver = System.create({ observer: true, components: { has: [row], changed: [row] } }, () => { throw new Error("a tick's observer failed"); }); -const pass = System.create({ name: "test/pass", observer: true }, () => {}); +const pass = System.create({ observer: true }, () => {}); describe("the todo app's failures", () => { /** The messages of the failures logged. */ diff --git a/packages/ecs/src/samples/todo/app/log-failures.test.ts b/packages/ecs/src/samples/todo/app/log-failures.test.ts index b5c658f7..91bfcdc6 100644 --- a/packages/ecs/src/samples/todo/app/log-failures.test.ts +++ b/packages/ecs/src/samples/todo/app/log-failures.test.ts @@ -3,13 +3,13 @@ import { afterEach, expect, it, vi } from "vitest"; import { System, World, workers } from "../../../index.js"; import { logFailures } from "./log-failures.js"; -const fails = System.create({ name: "test/fails" }, () => { +const fails = System.create({}, () => { throw new Error("broken"); }); -const failsObserving = System.create({ name: "test/failsObserving", observer: true }, () => { +const failsObserving = System.create({ observer: true }, () => { throw new Error("observer broken"); }); -const nothing = System.create({ name: "test/nothing" }, () => {}); +const nothing = System.create({}, () => {}); afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ecs/src/samples/todo/app/log-failures.ts b/packages/ecs/src/samples/todo/app/log-failures.ts index 0d2f899e..ef72c911 100644 --- a/packages/ecs/src/samples/todo/app/log-failures.ts +++ b/packages/ecs/src/samples/todo/app/log-failures.ts @@ -2,7 +2,7 @@ import { Report, System } from "../../../index.js"; /** Logs what reports say failed, a run's an event started and a tick's alike: each report's errors are written once. */ -export const logFailures = System.create({ name: "app/logFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +export const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { const { failures } = row[Report.errors]; if (failures.length > 0) console.error("A run failed", failures); }); diff --git a/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts index 42a9d0b4..3e5fd72d 100644 --- a/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts +++ b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts @@ -18,7 +18,7 @@ import { logFailures } from "./log-failures.js"; * asserted: `pnpm test:perf src/samples/todo`. */ -const nothing = System.create({ name: "profile/nothing" }, () => {}); +const nothing = System.create({}, () => {}); const median = (values: readonly number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? NaN; /** The median of `repeats` runs awaited one at a time, and the mean of as many issued back to back. */ diff --git a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts index eaabf5b4..b5f5c351 100644 --- a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts +++ b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts @@ -84,11 +84,11 @@ const measure = async (operation: string, n: number, repeats: number, run: (repe results.push({ operation, n, ms: median(times), mutations, tick: median(ticks) }); }; -const nothing = System.create({ name: "perf/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** The failures of every report the apps measured spawned, which none should. */ const failed: Report.Failure[] = []; -const collectFailures = System.create({ name: "perf/collectFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const collectFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { failed.push(...row[Report.errors].failures); }); @@ -104,7 +104,7 @@ const forceLayout = (): void => { }; /** Marks every other todo done, for filters to hide half the rows. */ -const halfDone = System.create({ name: "perf/halfDone", components: { write: [done] } }, item => { +const halfDone = System.create({ components: { write: [done] } }, item => { item[done] = item.entity % 2 === 0; }); @@ -167,7 +167,6 @@ const benchmarkApp = async (n: number): Promise => { /** The filter not kept: an attribute of the list, which a style sheet selects rows by. */ const markList = System.create({ - name: "perf/markList", observer: true, resources: { read: [filter], changed: [filter], is: [list] }, }, ({ resources }) => { diff --git a/packages/ecs/src/samples/todo/features/completion/data.ts b/packages/ecs/src/samples/todo/features/completion/data.ts index e39abcb6..782249a7 100644 --- a/packages/ecs/src/samples/todo/features/completion/data.ts +++ b/packages/ecs/src/samples/todo/features/completion/data.ts @@ -3,4 +3,4 @@ import { Index } from "../../../../index.js"; import { done, todo } from "../todos/data.js"; /** Todos by whether they're done: its lists' lengths are the counts, kept as todos change. */ -export const byDone = Index.create({ name: "completion/byDone", key: [done], components: { has: [todo] } }); +export const byDone = Index.create({ key: [done], components: { has: [todo] } }); diff --git a/packages/ecs/src/samples/todo/features/completion/dom.ts b/packages/ecs/src/samples/todo/features/completion/dom.ts index 5c055363..dccec882 100644 --- a/packages/ecs/src/samples/todo/features/completion/dom.ts +++ b/packages/ecs/src/samples/todo/features/completion/dom.ts @@ -20,7 +20,6 @@ declare module "@adobe/ecs" { * removed or toggled: the index's entries changing is its only change filter. */ export const bindCompletion = System.create({ - name: "completion/bindCompletion", observer: true, resources: { is: [summary, toggleAllBox] }, lookup: { byDone: { index: byDone, changed: true } }, diff --git a/packages/ecs/src/samples/todo/features/completion/model.test.ts b/packages/ecs/src/samples/todo/features/completion/model.test.ts index f7392c62..dd9a89cf 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.test.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.test.ts @@ -8,7 +8,7 @@ import { clearCompleted, toggleAll } from "./model.js"; let writes = 0; /** Counts the todos written since it last ran. */ -const countWrites = System.create({ name: "test/countWrites", observer: true, components: { has: [done], changed: [done] } }, () => { +const countWrites = System.create({ observer: true, components: { has: [done], changed: [done] } }, () => { writes += 1; }); diff --git a/packages/ecs/src/samples/todo/features/completion/model.ts b/packages/ecs/src/samples/todo/features/completion/model.ts index 8bdd164d..21cc3bd0 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.ts @@ -5,7 +5,6 @@ import { byDone } from "./data.js"; /** Marks every todo done, or every one not done. Only todos that change are written. */ export const toggleAll = System.create({ - name: "completion/toggleAll", components: { write: [done], has: [todo] }, }, (row, _context, input: { readonly done: boolean }) => { if (row[done] !== input.done) row[done] = input.done; @@ -13,7 +12,6 @@ export const toggleAll = System.create({ /** Deletes the todos that are done. */ export const clearCompleted = System.create({ - name: "completion/clearCompleted", lookup: { byDone: { index: byDone } }, }, ({ lookup, commands }) => { const completed = lookup.byDone(true); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.ts b/packages/ecs/src/samples/todo/features/filter/dom.ts index 6d4992ad..f2c2b6aa 100644 --- a/packages/ecs/src/samples/todo/features/filter/dom.ts +++ b/packages/ecs/src/samples/todo/features/filter/dom.ts @@ -38,7 +38,6 @@ const focusNearest = (hiding: Element, chosen: Filter): void => { * README). */ export const filterRows = System.create({ - name: "filter/filterRows", observer: true, components: { read: [done, row], changed: [done, row] }, resources: { read: [filter], changed: [filter] }, @@ -51,7 +50,6 @@ export const filterRows = System.create({ }); export const bindFilters = System.create({ - name: "filter/bindFilters", observer: true, resources: { read: [filter], changed: [filter], is: [filters] }, }, ({ resources }) => { diff --git a/packages/ecs/src/samples/todo/features/filter/model.test.ts b/packages/ecs/src/samples/todo/features/filter/model.test.ts index 2eb763ba..4c58130b 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.test.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.test.ts @@ -5,7 +5,7 @@ import { filter, type Filter } from "./data.js"; import { setFilter } from "./model.js"; let seen: Filter | null = null; -const readFilter = System.create({ name: "test/readFilter", observer: true, resources: { read: [filter] } }, ({ resources }) => { +const readFilter = System.create({ observer: true, resources: { read: [filter] } }, ({ resources }) => { seen = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.ts b/packages/ecs/src/samples/todo/features/filter/model.ts index d6144bc9..c058d5f6 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.ts @@ -3,7 +3,6 @@ import { System } from "../../../../index.js"; import { filter, type Filter } from "./data.js"; export const setFilter = System.create({ - name: "filter/set", resources: { write: [filter] }, }, ({ resources }, input: { readonly filter: Filter }) => { resources[filter] = input.filter; diff --git a/packages/ecs/src/samples/todo/features/generate/model.ts b/packages/ecs/src/samples/todo/features/generate/model.ts index d938e0b1..a80ac564 100644 --- a/packages/ecs/src/samples/todo/features/generate/model.ts +++ b/packages/ecs/src/samples/todo/features/generate/model.ts @@ -9,7 +9,6 @@ export const generatedLabel = (n: number): string => `${tasks[n % tasks.length]} /** Adds `count` todos with generated labels at the end of the list. */ export const addGenerated = System.create({ - name: "generate/add", lookup: { ordered: { index: ordered, read: [order] } }, }, ({ lookup, commands }, input: { readonly count: number }) => { const all = lookup.ordered(); diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts index 22d8cb39..93a8bd56 100644 --- a/packages/ecs/src/samples/todo/features/todos/data.ts +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -20,7 +20,7 @@ declare module "@adobe/ecs" { export const Todo = Archetype.create([todo, label, done, order]); /** Every todo, in list order. */ -export const ordered = Index.create({ name: "todos/ordered", key: [order], sorted: true, components: { has: [todo] } }); +export const ordered = Index.create({ key: [order], sorted: true, components: { has: [todo] } }); /** The order just after the last of `ordered`'s todos, as a lookup through it gives them: 0 for none. */ export const orderAfter = (todos: { readonly length: number; readonly at: (index: number) => { readonly [order]: number } }): number => diff --git a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts index 3e7770db..df84ce12 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts @@ -29,7 +29,7 @@ const createDriven = async (): Promise<{ readonly world: World; readonly element const rowsOf = (element: TodoList): TodoRow[] => [...element.querySelectorAll("todo-row")]; let rows = 0; -const countRows = System.create({ name: "test/countRows", observer: true, components: { has: [row] } }, () => { +const countRows = System.create({ observer: true, components: { has: [row] } }, () => { rows += 1; }); diff --git a/packages/ecs/src/samples/todo/features/todos/dom.ts b/packages/ecs/src/samples/todo/features/todos/dom.ts index 57c80765..b21f2ff9 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.ts @@ -20,14 +20,13 @@ declare module "@adobe/ecs" { } /** Todos with rows, in list order. */ -const placed = Index.create({ name: "todos/placed", key: [order], sorted: true, components: { has: [todo, row] } }); +const placed = Index.create({ key: [order], sorted: true, components: { has: [todo, row] } }); /** * Gives each new todo a row, attached to it for events from the row, while the page has a list for it. It isn't placed * until the row is the todo's, so a todo gone first never shows one. */ export const createRows = System.create({ - name: "todos/createRows", components: { has: [todo], not: [row] }, resources: { is: [list] }, }, (item, { commands }) => { @@ -36,7 +35,6 @@ export const createRows = System.create({ /** Writes what changed of each todo to its row. */ export const bindRows = System.create({ - name: "todos/bindRows", observer: true, components: { read: [label, done, row], changed: [label, done, row] }, }, item => { @@ -66,7 +64,6 @@ const place = (parent: Element, element: HTMLElement, before: Element | null): v * its focus. */ export const placeRows = System.create({ - name: "todos/placeRows", observer: true, components: { read: [row, order], changed: [row, order] }, order: placed, @@ -80,7 +77,7 @@ export const placeRows = System.create({ if (before !== element) place(parent, element, before); }); -Finalizer.create({ name: "todos/removeRows", components: { read: [row] } }, item => { +Finalizer.create({ components: { read: [row] } }, item => { item[row].remove(); }); diff --git a/packages/ecs/src/samples/todo/features/todos/model.ts b/packages/ecs/src/samples/todo/features/todos/model.ts index 6dcfa1e2..559a292b 100644 --- a/packages/ecs/src/samples/todo/features/todos/model.ts +++ b/packages/ecs/src/samples/todo/features/todos/model.ts @@ -4,7 +4,6 @@ import { Todo, done, label, order, orderAfter, ordered, todo } from "./data.js"; /** Adds a todo at the end of the list, with its label trimmed; a blank label adds nothing. */ export const addTodo = System.create({ - name: "todos/add", lookup: { ordered: { index: ordered, read: [order] } }, }, ({ lookup, commands }, input: { readonly label: string }) => { const trimmed = input.label.trim(); @@ -16,7 +15,6 @@ export const addTodo = System.create({ type One = { readonly entity: Entity }; export const toggleTodo = System.create({ - name: "todos/toggle", target: "entity", components: { write: [done], has: [todo] }, }, (row, _context, _input: One) => { @@ -25,7 +23,6 @@ export const toggleTodo = System.create({ /** Gives a todo a new label, trimmed; a blank one deletes it. */ export const editTodo = System.create({ - name: "todos/edit", target: "entity", components: { write: [label], has: [todo] }, }, (row, { commands }, input: One & { readonly label: string }) => { @@ -35,7 +32,6 @@ export const editTodo = System.create({ }); export const deleteTodo = System.create({ - name: "todos/delete", target: "entity", components: { has: [todo] }, }, (row, { commands }, _input: One) => { @@ -47,7 +43,6 @@ export const deleteTodo = System.create({ * neighbours'. When no number lies between them, every todo is numbered again. */ export const moveTodo = System.create({ - name: "todos/move", lookup: { todos: { read: [order], has: [todo] }, ordered: { index: ordered, read: [order] } }, }, ({ lookup, commands }, input: { readonly entity: Entity; readonly before: Entity }) => { const { entity, before } = input; diff --git a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts index a4d9bc25..0d5ed7fe 100644 --- a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts +++ b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts @@ -7,7 +7,7 @@ import { addTodo } from "./model.js"; export type Listed = { readonly entity: Entity; readonly label: string; readonly done: boolean; readonly order: number }; const listed: Listed[] = []; -const readTodos = System.create({ name: "test/readTodos", observer: true, components: { read: [label, done, order] }, order: ordered }, row => { +const readTodos = System.create({ observer: true, components: { read: [label, done, order] }, order: ordered }, row => { listed.push({ entity: row.entity, label: row[label], done: row[done], order: row[order] }); }); diff --git a/packages/ecs/src/samples/todo/shared/attach.browser.test.ts b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts index 3cbf2f5d..2cb826fa 100644 --- a/packages/ecs/src/samples/todo/shared/attach.browser.test.ts +++ b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts @@ -11,7 +11,7 @@ afterEach(() => { const twoEntities = async (): Promise => { const spawned: Entity[] = []; await using world = World.create({ [workers]: 0 }); - await world.run(System.create({ name: "attachTest/spawn" }, ({ commands }) => { + await world.run(System.create({}, ({ commands }) => { spawned.push(commands.spawn([])({}), commands.spawn([])({})); })); const [first, second] = spawned; diff --git a/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts index f155b796..6adaac99 100644 --- a/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts +++ b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts @@ -4,7 +4,7 @@ import { System, clock, type World } from "../../../index.js"; const waiting: (() => void)[] = []; /** Passes after each tick, the only passes that change the clock, and on its first. */ -const ticked = System.create({ name: "test/ticked", observer: true, resources: { changed: [clock] } }, () => { +const ticked = System.create({ observer: true, resources: { changed: [clock] } }, () => { for (const resolve of waiting.splice(0)) resolve(); }); diff --git a/packages/ecs/src/samples/transfers/main.ts b/packages/ecs/src/samples/transfers/main.ts index d88ef229..722ca888 100644 --- a/packages/ecs/src/samples/transfers/main.ts +++ b/packages/ecs/src/samples/transfers/main.ts @@ -15,7 +15,7 @@ const overlay = (text: string): void => { let latest = { sprites: 0, frames: 0 }; // Added on this thread, it runs here after each of the world's frames that changed stats. -const show = System.create({ name: "transferSprites/show", observer: true, resources: { read: [stats], changed: [stats] } }, ({ resources }) => { +const show = System.create({ observer: true, resources: { read: [stats], changed: [stats] } }, ({ resources }) => { const { sprites, frames } = resources[stats]; latest = { sprites, frames }; overlay(`${sprites} sprites, each a texture the loader decoded and moved to the world's worker · frame ${frames}`); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index b572785f..77602588 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -28,7 +28,7 @@ declare module '@adobe/ecs' { /** The canvas main moved to the world's thread. */ /** From main: the canvas the world draws into. */ -export const attachSurface = System.create({ name: "transferSprites/attachSurface", resources: { write: [surface] } }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { +export const attachSurface = System.create({ resources: { write: [surface] } }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { resources[surface] = input.canvas; }); @@ -40,7 +40,7 @@ const random = (seed: number): number => { /** From the loader: a sprite with the texture it decoded, somewhere on the canvas, moving. */ export const addSprite = System.create({ - name: "transferSprites/addSprite", resources: { read: [surface], write: [stats] }, + resources: { read: [surface], write: [stats] }, }, ({ resources, commands }, input: { readonly bitmap: ImageBitmap; readonly seed: number }) => { const canvas = resources[surface]; if (canvas === null) return; @@ -55,7 +55,7 @@ export const addSprite = System.create({ }); /** Clears the canvas, once a frame. */ -const clear = System.create({ name: "transferSprites/clear", resources: { read: [surface], write: [stats] } }, ({ resources }) => { +const clear = System.create({ resources: { read: [surface], write: [stats] } }, ({ resources }) => { const canvas = resources[surface]; canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); resources[stats] = { sprites: resources[stats].sprites, frames: resources[stats].frames + 1 }; @@ -63,7 +63,7 @@ const clear = System.create({ name: "transferSprites/clear", resources: { read: /** Moves each sprite, bouncing off the canvas's edges. */ const move = System.create({ - name: "transferSprites/move", components: { read: [texture], write: [position, velocity] }, resources: { read: [surface, clock] }, + components: { read: [texture], write: [position, velocity] }, resources: { read: [surface, clock] }, }, (row, { resources }) => { const canvas = resources[surface]; if (canvas === null) return; @@ -79,10 +79,10 @@ const move = System.create({ }); /** Draws each sprite's texture where it is. */ -const draw = System.create({ name: "transferSprites/draw", components: { read: [texture, position] }, resources: { read: [surface] }, after: [clear, move] }, (row, { resources }) => { +const draw = System.create({ components: { read: [texture, position] }, resources: { read: [surface] }, after: [clear, move] }, (row, { resources }) => { const [x, y] = row[position]; resources[surface]?.getContext("2d")?.drawImage(row[texture], x, y); }); /** Every frame on the world's thread. */ -export const frame = System.create({ name: "transferSprites/frame", systems: [clear, move, draw] }); +export const frame = System.create({ systems: [clear, move, draw] }); diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index f9615806..92d02064 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -13,9 +13,9 @@ declare module "@adobe/ecs" { } } -const spawn = System.create({ name: "worldTest/spawn" }, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); -const raise = System.create({ name: "worldTest/raise", resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); -const add = System.create({ name: "worldTest/add", components: { write: [count] } }, row => { row[count] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); +const raise = System.create({ resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); +const add = System.create({ components: { write: [count] } }, row => { row[count] += 1; }); describe("World.create", () => { it("creates a world from the package root, the one import that names everything else", async () => { From bb531ff8c8fd99329fc4ca51817513b473e9f43e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 17:44:15 -0700 Subject: [PATCH 057/127] ecs: docs describe labels as bindings, report ids and reduce lists Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 26 ++++++++++++-------------- packages/ecs/followups.md | 17 +++++++---------- packages/ecs/threads.md | 2 +- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 55d0fc7f..1ffd20c1 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -82,12 +82,11 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, while no other registered key has it, else its id, and without either its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. + - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, whatever other keys have, so a label never changes as modules load; an error about an id names the id too. Labels are for display; nothing looks a key up by its label. - **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts export const follow = System.create({ - name: "ai/follow", components: { read: [position, target], write: [velocity] }, lookup: { targets: { read: [position] } }, after: [integrate], // imported from the physics module @@ -97,14 +96,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. }); export const move = System.create({ - name: "player/move", components: { has: [player], write: [position] }, }, (row, context, input: { readonly distance: number }) => { /* … */ }); - export const main = System.create({ name: "main", systems: [input, physics, follow, render] }); + export const main = System.create({ systems: [input, physics, follow, render] }); ``` - - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during, name }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their `name` alone. Nothing runs when they're called. - - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup` and `reduce` each appear in the context under the same name. + - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their label, `name`, alone. Nothing runs when they're called. + - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup` appears in the context under the same name, and `resources.reduce` as `reduce`. - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources. @@ -129,7 +127,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `up`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - - **Names are labels,** optional and not unique, so two libraries can both name a system `"linked"`; namespacing them (`"physics/integrate"`) keeps labels short. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. + - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. - **Composites** flatten their members. - A composite's constraints apply to every member, and members' own constraints resolve against the flattened set. - If the same system appears twice, as one value or as values of one id (see below), it's flattened to one entry, the first reached. Systems that only share a name are different systems. @@ -163,13 +161,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), returning nothing; the joined thread's progress and claims are the implementation's own storage. The other thread calls the implementation's `join(port, { threads })`, whose port and options are inferred from `share`, claiming the logical threads it fills, and gets a `World` whose `run` works as here, and whose `add` and `remove` take observer systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (an observer system it adds needs none, only its keys do): a run of a system without one, or one the world never imported, rejects, and a thread claimed twice, or one with a system or finalizer it can't run, without an id, naming a key without one, declaring `up` or `order`, looking up through an index with an object key part or with `changed: true`, or not imported there, can't join ([`threads.md`](threads.md)). The objects and object resources of the threads it claims are its own: commands elsewhere give them only their start or `null`, and systems elsewhere can't read them; the systems that read or write them run there, each tier waiting for it listed in the report's `warnings`, and are skipped with a `"thread"` failure once it's gone, later tiers still running. A terminal one, whose body the plugin proves never reaches `commands` and which writes only those objects, reduces into nothing and looks nothing up, runs as a tail when no later system conflicts with it: after its tick's sync point, unwarned, while the next tick goes on, a writer of what it still reads first copying the rest of its reads and waiting for at most the chunk it claimed; a run waits for its tails, and a tick's report is written once after them, with their failures. The finalizers that read them run there too, after the sync point: the world copies each stop's declared struct reads into that thread's stop list without waiting, the thread runs them as it next replays that sync, before its next task or observer post, and the index of an entity they stopped is held until the thread acknowledges; their failures are in a later run's report. When it's gone its objects come back to the world's thread, its held indexes return, stops it never finalized are `"thread"` failures, and its systems and finalizers run here again. Once the world is disposed, a joined thread finalizes every row its finalizers match. Without cross-origin isolation both throw. - **Observer systems are the way to read a world.** A consumer reads once by running an observer system that copies what it reads into variables of its own, and follows changes by adding one whose `changed` filters pass only after the runs and ticks that changed what it reads; the world has no observers. Derived values are computed by systems, inside the ECS. - **Observer factories** (`src/observe/`, internal, never exported: consumers write plain observer systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give observers as `Observe.resource(world, key)`, `Observe.entity(world, entity, { required, optional })` (no whole-entity form), `Observe.select(world, query)`, `Observe.count(world, query)` and `Observe.toPromise`, built on observer systems alone. Per world, in a `WeakMap`: one observer system per resource key and per shape of entity observer (its required and optional lists) or query (components, `where` keys, `order`, whether paged), shared by every subscriber, each `where` and page of a query an entry. A value they hold goes to a new subscriber during the subscribing call, as of the last sync point; any other waits for the flush at the end of the task, which runs what reads it (a resource's reader, a query's scan) and one delivering run, after every run called before, so cold first values are read-your-writes and come in subscribe order; a first value whose notify throws is thrown out of band and stops that subscriber, a later one's is a `"system"` failure of its run or tick. A maintenance observer system, added first, counts passes and removes shapes the passes found idle in three, so loops of first values stay warm; a resource's reader runs before it in its own run, and its notifier, filtering on the resource's `changed`, skips subscribers that had their first value in that pass. The failures of the factories' own runs, which nothing else reads, are rethrown out of band: each of their runs runs a system reading reports, `Observe/rethrow`, never added so no tick waits for it, which rethrows their systems' failures in the reports of their runs, a pass's failures once a later run of theirs sees them; on a joined thread, they're in a later report of the world's. Entity shapes look each entity up by entity with `changed`, reading values only once changed, and with no optional components skip every entity while an index without a key on the required ones reports no change. A query's first values come from one scan, an observer row system run for them, ties and unordered results put in entity order; a scan of one entry with at most one test, every entity or a count, gathers in the scanner's body into typed scratch, each value made at its length; only once a subscriber outlives its first value is the shape's tracker added: a sorted index on `order` when every term goes one direction, read from its end for descending, each run of equal keys in entity order; for a count, or a selection without a page, a sorted index on its first `where` key whose values have an order, each entry looking up its range with every bound made inclusive, so it holds every row the condition passes at the precision values are kept at, then testing each row, or for a count of one condition, taking the range's length less the rows at a bound the test rejects (rows at one key share their value, so one tells for all); otherwise an index without a key, in entity order. Each notifies only when its result differs; unordered results in entity order compare as sequences. Disposed worlds run nothing, so a first value not held never comes. `observe-cases.test.ts`, `observe-ticks-cases.test.ts`, `observe-cases.browser.test.ts` and `observe-factories.test.ts` cover them, and tests read through them (`reads.test-helper.ts`); tests of storage order, or of a joined thread's objects as of the world's last sync point, read through observer runs. Benchmarks count ticks with an observer system filtering on the clock, and read before what they time through observer runs, which leave nothing added (`perftest/observer.ts`); the factories only check results once nothing more is timed on a world, since they keep their maintenance system added there. On a joined thread's world, which the factories know by its `remove` refusing a system that isn't an observer, they add no maintenance or delivering system, since observer systems run there in posts in no order with one another: a shape's cold first values come once a run of its own system resolved, a post for it having run after every run the thread called before subscribing (a resource's notifier, whose first run once added passes its filter; an entity shape's system; a query's tracker, added on first use, with no scan, since a scan's rows can't be told from a later post's there, and for an ordered selection without a page an index without a key sorted there rather than a sorted one the world's thread keeps); a pass is a turn of the thread's event loop that ran a shape's system, idle shapes swept after it; indexes they make reach the world by declaration; a system the thread can't place, as for objects of the world's thread, throws when subscribed. On the world's thread, one naming objects a joined thread fills fails to register: an error in a run's report, or out of band for the factories' own runs, whose rejections on a live world are rethrown. `observe-joined-cases.test.ts` covers them on a joined thread, and the worker cases of `remote-objects` and `joining` on joined workers. - - **Identity is the value, and names are labels:** no name is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: its name while no other registered system, rule or finalizer has it, else its name with its id (`"linked (lib/src/link.ts#linked)"`), and without one its name numbered (`#2`) in registration order. One declared without a name is labeled by its binding and id (`"spawnOnce (app/src/setup.ts#spawnOnce)"`), and without an id by its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). Labels are for display. + - **Identity is the value, and labels are bindings:** no label is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: the binding its id names (`linked` for `lib@1.0.0/src/link.ts#linked`); for one made where no top-level binding is, the binding, function, property or method the plugin found it in (`mover` for one made in `const mover = (speed) => System.create(...)`), with no id; and else its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). A label never changes as other items load, and reports give the id beside it to tell items of one label apart. Internal factories label theirs through the registry's map of labels, which the plugin's labels never override. - **Ids:** an item the plugin gave an id, declared again under that id with the same kind and the same declaration, its keys and the systems and finalizers it lists matching by id, is that item, as a module evaluated again declares it: a system is a new value, with the new body, that schedules compare as the first one, so constraints from modules evaluated again still order it; a rule is the one already recorded and `Rule.create` returns it; a finalizer keeps its place in every world with the new body and `Finalizer.create` returns it. The same id with another kind or declaration throws, telling to reload the page: replacing a rule or finalizer would have to undo what worlds derived or finalized under the old one. An item without an id is only itself: declared again, it's a new item. Constraints list the systems themselves, imported from their modules; when two modules import each other, one declares the order. - `run(system, ...input): Promise` runs a system or composite, ends in a sync point, and resolves to its run number. Runs are sequential in call order; physically, runs that don't conflict may overlap. - It's async because workers can't be joined synchronously on the main thread. - **Registration is the first run:** a system's declaration is checked when it's created, with the components and resources it names, which must be registered by then: every check that needs no world, its reductions, conditions, `up`, placement and render accessors included. A compiled kernel is checked against its declaration and schemas where its module registers it, and a `within` cycle when the component closing it registers. The first time a world runs a system, it gives its keys ids, adds its resources and loads its kernels, serialized with runs, and the run rejects without recording anything if that fails: a kernel the build didn't compile, a kernel module or WASM that fails to load, and a GPU kernel whose shader can't be generated from its bound columns are found there. A kernel's first run pays for its load; a run before timing work avoids it. - **Observer systems** run after its sync point, before it resolves (see the observer pass). - - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. + - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, its `id`, the plugin's or null, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label and id. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. - Each system keeps its own `lastRun`, so it sees every change since it last ran. - `dispose()` and `[Symbol.asyncDispose]` wait for pending runs, then stop ticking, terminate workers and release memory, so `await using world = ...` works. - **Initial state** comes from the resources' schema defaults. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. @@ -187,7 +185,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Matching, not component removal, is the unit of add/remove symmetry.** An entity stops matching a query when a required component is removed, when it's despawned, or when an excluded component is added. Per-component removal is a monitored `With`. - Monitored queries are **structural only**. Their `Changed`/`Added` filters are stripped or rejected at the type level, and optional components don't affect matching. Value conditions become tag components set by systems. - Replacing a required component within one flush (remove + re-add) counts as a stop plus a start. -- **Rules (structural derivation):** `Rule.create({ name, components: { has, not }, derive: [component] })`: entities matching its components have the derived components. +- **Rules (structural derivation):** `Rule.create({ components: { has, not }, derive: [component] })`: entities matching its components have the derived components. - **Datalog with stratified negation** over which components each entity has. Rules and `within` declarations are evaluated as one fixed point per stratum; `Rule.create` rejects a cycle through a negation, which could oscillate, and any cycle through `within`. That makes the closure a pure function of the archetype, cached per archetype, so A→B becomes a single move A→closure(B), and entities never match and then unmatch within a flush. Reference cascades run first, on the values the plan leaves; they only despawn and remove, so the result is the same fixed point. - **Union ownership:** a derived component is present while any rule deriving it matches. - **Derived components** (`derived: true`) are structurally read-only to user code: commands reject them in types and at runtime; systems write their values. Each starts at its schema's `default`, which the types require, stamped `added`, and a normal `added` system fills it in. No initializer phase is needed, because creation can wait for the next system run and removal can't. @@ -195,7 +193,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Cascades** are component traits, expanded in Plan with the rules. - **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped. - **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null. -- **Finalizers are the only removal mechanism.** `Finalizer.create({ name, components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. +- **Finalizers are the only removal mechanism.** `Finalizer.create({ components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct and string reads and its resources copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. - **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched. - **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know. @@ -207,11 +205,11 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Order:** `before` and `after` list other finalizers, then creation order. Two that both write a resource, or where one reads what the other writes, must be ordered. Every active finalizer runs at every sync point; none is in a composite, and the phase costs nothing when none is active. - **Throwing:** the plan still applies, and the run's report lists each failure. - **`dispose()`** runs per-query finalizers for every live match, so handles are released, and no whole-plan finalizers. -- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ name, watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers. +- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers. - **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. - **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. - - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor. + - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system lists the resource, `resources: { reduce: [total] }`, and folds with its trait's operator, which a kernel reads at build time from the module declaring the resource; changing a resource's operator changes every contributor. - **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers. - **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`. - **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value. @@ -220,7 +218,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Contributions have a known shape:** a tuple or struct component contributed whole needs its leaves known, as whole-value writes do. - **Indexes** (`src/interface/indexes/`; its type tests pin the behavior) find rows by the values of stored components, for systems and finalizers. - - **Declared once, program-wide:** `Index.create({ name, key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. + - **Declared once, program-wide:** `Index.create({ key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. - **Lookups:** `lookup: { near: { index: grid, read: [position], changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. - **`changed: true`** on a system's index lookup makes the index a change filter, as a resource in `changed` is: every row passes when, since the system last ran, an entity entered or left the index or moved to another key or position in it, as its refreshes count them; a rebuild counts as a change, and a key written the same, a move to another archetype, or a change to what the index doesn't key on doesn't. With several, any one changing passes. A system whose only change filters these are, with no `up`, is skipped before its body when none changed, as when a condition fails, keeping its `lastRun`, at one comparison per such lookup, against a tick each index keeps of its last refresh that changed an entry; with other change filters its rows pass by them otherwise. Conditions still decide first. Its lists' `changed` then reports entries alone. Observer systems and CPU kernels take it, the world deciding before it calls them; finalizers, which run per entity, don't, and a system on a joined thread, or an observer placed on or added on one, is rejected, the decision needing the world's index. Systems without it pay one boolean test per run. - **`order: index`** runs a system's rows that a sorted index holds in its order, with change filters the rows that pass; commands and reductions follow it. Not with `up`, and not on GPU kernels. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index f85ec230..390a53bf 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -83,16 +83,13 @@ Open items awaiting a decision or work. Remove an item once it's decided or done The plugin is required, so it can derive what consumers now restate. Each is a contract change, reviewed one at a time; inferred values are emitted as the same literals the runtime takes today, so none costs anything at runtime. -1. **Names from bindings.** `name` on systems, composites, indexes and finalizers defaults to the plugin's id - (`#`), which 218 of 325 names restate. -2. **Reductions declared once.** A system's `reduce` drops the operator, which is the resource's `reduce` trait, and - its keys come from the body's `reduce[key](…)` calls. -3. **Kernels by default.** A body in the kernel subset compiles as a `"cpu"` kernel unflagged; `kernel: "cpu"` - asserts it, `"gpu"` chooses the backend, `false` runs it as written. Needs a measured rule for small runs, and - accepts results that aren't bit-exact with plain JS. -4. **Access inferred from bodies.** `components`, `resources`, `lookup` and `up` read and write lists come from the - body where `usedBy` follows every use, explicit otherwise. Bodies' rows are then typed over every component, so - it needs the planned ESLint rule to keep errors in the editor. +1. **Kernels by default.** `kernel?: boolean | "gpu"`: omitted compiles a row system whose body fits the subset, + `true` asserts it (a build error saying why, including for a per-run system), `false` never compiles, `"gpu"` as + today; per-run systems never compile automatically. Accepts results that aren't bit-exact with plain JS. +2. **Access inferred from bodies, saved for discussion with Kris.** Recommended: option B, membership explicit as + `components: { with: [...], not: [...] }` and access modes inferred from the body, so rows stay typed over `with` + and matching never changes behind the author's back; `write` stays as an override for bodies the plugin can't + follow. Option A infers the lists entirely, typing rows over every component. ## Findings from the todo sample (`src/samples/todo/README.md`) diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 0cb86991..6c411146 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -41,7 +41,7 @@ Referenced from `design.md`. system naming one fails to register. Ids name packages and their versions, not directories, so threads bundled apart agree on them, and the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. Every declared key has one, since a declaration names only a top-level `Symbol()` constant. -- **Systems, rules and finalizers are their values,** and names are optional labels, so a value is different on each +- **Systems, rules and finalizers are their values,** labeled by their bindings, so a value is different on each thread too. Threads agree on them through the ids the plugin gives top-level `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and `Finalizer.changes` bindings, composites included, by which the registry already takes values of one id as one item. A threaded world rejects an item without one where it crosses threads: From 81f822b184d62355b3debd03ee5b211f2c4d322e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:13:42 -0700 Subject: [PATCH 058/127] ecs: kernel is true, false or "gpu"; an unflagged row system compiles when its body fits, and the pool starts once rows warrant it A system with components and no kernel flag compiles as a kernel when its body is in the subset, its declaration allows one and its schemas match; otherwise it runs as written. true requires the kernel, false rules it out. Unflagged bodies don't inline module objects and arrays of data, which may change after the build. The kernel pool now starts once some archetype holds rows enough for workers, so small worlds start no workers. The plugin defaults worker bundles to ES modules, since kernels are chunks of their own. Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/compile-kernel.ts | 4 +- .../implementation/compiler/hygiene.test.ts | 2 +- .../implementation/compiler/identify.test.ts | 4 +- .../implementation/compiler/module-value.ts | 16 +- .../implementation/compiler/plugin.test.ts | 22 +-- .../ecs/src/implementation/compiler/plugin.ts | 12 +- .../src/implementation/compiler/signature.ts | 27 +++- .../ecs/src/implementation/compiler/subset.md | 2 +- .../implementation/compiler/transform.test.ts | 144 +++++++++++------- .../src/implementation/compiler/transform.ts | 62 ++++++-- .../ecs/src/implementation/compiler/unit.ts | 6 +- .../world/attach-kernel.test.ts | 7 + .../src/implementation/world/attach-kernel.ts | 18 ++- .../src/implementation/world/builtins.test.ts | 4 +- .../world/change-blocks.fixture.ts | 4 +- .../world/change-blocks.test.ts | 8 +- .../src/implementation/world/check-kernel.ts | 64 ++++---- .../world/command-rings.fixture.ts | 8 +- .../src/implementation/world/commands.test.ts | 10 +- .../world/composite-input.test.ts | 2 +- .../implementation/world/conditions.test.ts | 4 +- .../src/implementation/world/create.test.ts | 2 +- .../ecs/src/implementation/world/create.ts | 21 ++- .../implementation/world/dispatch.fixture.ts | 10 +- .../world/entity-lookup-workers.fixture.ts | 2 +- .../world/entity-lookups.test-helper.ts | 2 +- .../implementation/world/gpu.browser.test.ts | 2 +- .../world/imported-lists.test.ts | 4 +- .../world/index-buckets.test.ts | 2 +- .../world/index-build.fixture.ts | 2 +- .../world/index-changed.test.ts | 2 +- .../world/index-kernels.test.ts | 10 +- .../world/index-keyless.test.ts | 4 +- .../implementation/world/index-order.test.ts | 4 +- .../world/index-workers.fixture.ts | 15 +- .../src/implementation/world/indexes.test.ts | 4 +- .../world/kernel-imports.test.ts | 8 +- .../world/kernel-lookup.test-helper.ts | 2 +- .../world/kernel-path.test-helper.ts | 2 +- .../world/kernel-spawns.test.ts | 6 +- .../world/kernel-tried.test-helper.ts | 31 ++++ .../implementation/world/kernels-csp.test.ts | 2 +- .../src/implementation/world/kernels.test.ts | 15 +- .../world/layout-directory.browser.test.ts | 11 +- .../world/layout-directory.fixture.ts | 11 +- .../implementation/world/lifetime.fixture.ts | 2 +- .../world/observe-cases.test.ts | 4 +- .../world/observe-ticks-cases.test.ts | 4 +- .../world/observed-workers.fixture.ts | 4 +- .../implementation/world/pool-size.cases.ts | 6 + .../implementation/world/pool-size.fixture.ts | 26 +++- .../src/implementation/world/ports.fixture.ts | 2 +- .../world/reductions.fixture.ts | 2 +- .../implementation/world/reductions.test.ts | 12 +- .../src/implementation/world/remote.test.ts | 4 +- .../ecs/src/implementation/world/runner.ts | 12 +- .../implementation/world/semantics.test.ts | 2 +- .../ecs/src/implementation/world/tails.ts | 3 +- .../src/implementation/world/up-tree.test.ts | 4 +- .../world/up-workers.fixture.ts | 2 +- .../ecs/src/implementation/world/up.test.ts | 6 +- .../implementation/world/wasm-kernels.test.ts | 18 +-- .../implementation/world/workers.fixture.ts | 17 ++- .../ecs/src/interface/indexes/create.test.ts | 4 +- .../src/interface/indexes/index.type-test.ts | 14 +- packages/ecs/src/interface/system/check.ts | 49 +++--- .../interface/system/conditions.type-test.ts | 2 +- .../ecs/src/interface/system/create.test.ts | 2 +- packages/ecs/src/interface/system/create.ts | 14 +- .../ecs/src/interface/system/declaration.ts | 16 +- .../src/interface/system/system.type-test.ts | 36 +++-- .../ecs/src/interface/system/up.type-test.ts | 4 +- packages/ecs/src/perftest/filters.ts | 10 +- packages/ecs/src/perftest/growth.ts | 2 +- packages/ecs/src/perftest/hierarchy.ts | 4 +- packages/ecs/src/perftest/indexes.ts | 10 +- packages/ecs/src/perftest/observed.ts | 2 +- packages/ecs/src/perftest/particles.ts | 2 +- packages/ecs/src/perftest/reads.ts | 2 +- packages/ecs/src/perftest/remote.ts | 2 +- packages/ecs/src/perftest/structural.ts | 8 +- packages/ecs/src/perftest/suite.ts | 24 +-- packages/ecs/src/perftest/uploads.ts | 2 +- packages/ecs/src/perftest/values.ts | 2 +- .../src/samples/particles/gpu/particles.ts | 2 +- .../ecs/src/samples/particles/particles.ts | 4 +- 86 files changed, 597 insertions(+), 354 deletions(-) create mode 100644 packages/ecs/src/implementation/world/kernel-tried.test-helper.ts diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 5d85d24e..a173d223 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -42,14 +42,14 @@ const loops = (statements: readonly Program.Statement[]): boolean => statements. }); /** `target` "gpu" also checks, before anything runs, what only a GPU kernel can't do; schemas are checked at registration. */ -export const compileKernel = (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu" = "cpu", link: Link | null = null): CompiledKernel => { +export const compileKernel = (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu" = "cpu", link: Link | null = null, tried = false): CompiledKernel => { if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") { return fail(body, "a kernel body must be an inline function"); } if (body.async || body.generator) return fail(body, "a kernel body can't be async or a generator"); if (body.params.length > 3) return fail(body, "a kernel body takes its row, context and input"); const lists = createLists(module, link, [declaration, body]); - const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target); + const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target, tried); const scope = rootScope(unit, false); const prelude: Out = []; const rows: Out = []; diff --git a/packages/ecs/src/implementation/compiler/hygiene.test.ts b/packages/ecs/src/implementation/compiler/hygiene.test.ts index 6de4af83..bcc7348f 100644 --- a/packages/ecs/src/implementation/compiler/hygiene.test.ts +++ b/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -40,7 +40,7 @@ const readAll = System.create({ observer: true, components: { read: [p, c, l, s, // A kernel whose module constant and locals are named as the kernel emitter's bindings. const i = 100; -const grow = System.create({ kernel: "cpu", components: { read: [c, l], write: [p, s] } }, row => { +const grow = System.create({ kernel: true, components: { read: [c, l], write: [p, s] } }, row => { const n = row[c]; const x0 = row[l]; const s0 = n + x0; diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index c0b7f9dc..3f7b43a3 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -108,8 +108,8 @@ describe("identify, for systems, rules and finalizers", () => { expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "tag"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); }); test("identifies a kernel's call the kernel transform wrapped", async () => { - const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ tag: "grow", kernel: "cpu" }, () => {}), {});`); - expect(declared(result?.code)).toEqual([[`{ tag: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); + const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ tag: "grow", kernel: true }, () => {}), {});`); + expect(declared(result?.code)).toEqual([[`{ tag: "grow", kernel: true }`, "src/keys.ts#grow"]]); }); test("follows the namespace to the module that exports it, under any local name", async () => { const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ tag: "a" });\nconst b = Rule.create({});`); diff --git a/packages/ecs/src/implementation/compiler/module-value.ts b/packages/ecs/src/implementation/compiler/module-value.ts index 58d03221..242da483 100644 --- a/packages/ecs/src/implementation/compiler/module-value.ts +++ b/packages/ecs/src/implementation/compiler/module-value.ts @@ -4,7 +4,7 @@ import type { Program } from '../world/kernel.js'; import { isValueType } from '../world/intrinsics.js'; import { fail, within, type Frame } from './failure.js'; import { resolveBinding, type Resolved } from './link.js'; -import { isIntrinsicModule } from './paths.js'; +import { isIntrinsicModule, packagePathOf } from './paths.js'; import type { Module, ModuleBinding } from './module-scope.js'; import type { Out } from './out.js'; import { rootScope, type Scope } from './scope.js'; @@ -71,6 +71,16 @@ const intrinsicDefinition = (init: SourceExpression, module: Module, scope: Scop return { implementation, intrinsic: intrinsicDeclaration(declaration) }; }; +/** Whether a value holds data rather than only functions and namespaces. */ +const holdsData = (value: Value): boolean => { + switch (value.kind) { + case "tuple": return value.items.some(holdsData); + case "struct": return value.fields.some(([, field]) => holdsData(field)); + case "function": case "namespace": case "method": return false; + default: return true; + } +}; + /** A definition in `module`: a function to inline, or a constant compiled in its own module. */ const definition = (node: Node, name: string, binding: ModuleBinding, module: Module, scope: Scope, out: Out): Value => { const unit = scope.unit; @@ -94,6 +104,10 @@ const definition = (node: Node, name: string, binding: ModuleBinding, module: Mo const compile = (): Value => unit.expression(init, rootScope(unit, true, module), out); const value = module === unit.module ? compile() : within(node, frameIn(module, display), compile); unit.active.delete(identity); + // An object or array of data may be written after the build, which inlined it; the interface's own are fixed. + if (unit.tried && (init.type === "ObjectExpression" || init.type === "ArrayExpression") && holdsData(value) && packagePathOf(module.file)?.startsWith("interface/") !== true) { + return fail(node, `${display} holds values that may change, which a kernel would inline`); + } return value; } case "import": case "importNamespace": diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index f4b3eb6d..561c285e 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -19,7 +19,7 @@ import { declareComponent$ } from ${source("../../interface/registry/declare.ts" import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); /** A kernel inlining a TypeScript helper and a function from a package's `dist`. */ @@ -41,7 +41,7 @@ import { scaled } from "./helper.ts"; import { increment } from "fake-math"; import { score } from "./linked-keys.js"; // Serving evaluates this module again after an edit, declaring the same system again with the same keys. -const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); +const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); export const description = kernelOf(kernel); `); /** Systems taking input, of a type another TypeScript module declares, which the test edits. */ @@ -69,7 +69,7 @@ writeFileSync(listing, `import { System } from ${source("../../interface/system/ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { score } from "./linked-keys.js"; import { Listed } from "./listed.js"; -const kernel = System.create({ kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); +const kernel = System.create({ kernel: true, components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); export const description = kernelOf(kernel); `); /** Two kernels in one module, writing different functions of the entity. */ @@ -79,8 +79,8 @@ import { declareComponent$ } from ${source("../../interface/registry/declare.ts" import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const doubled = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -const tripled = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); +const doubled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const tripled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ @@ -96,7 +96,7 @@ declareComponent$(shown, { type: { const: true }, derived: true }); export const rule = Rule.create({ components: { has: [score] }, derive: [shown] }); export const release = Finalizer.create({ components: { read: [${released}] } }, () => {}); export const system = Systems.create({ components: { write: [score] } }, (row) => { row[score] = 1; }); -export const kernel = Systems.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 2; }); +export const kernel = Systems.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = 2; }); export const label = labelOf(score); `; const evaluated = join(directory, "evaluated.js"); @@ -132,7 +132,7 @@ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -const kernel = System.create({ kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); +const kernel = System.create({ kernel: true, components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; const externalKey = join(directory, "external-key.js"); @@ -330,7 +330,7 @@ test("an application's build compiles the kernels a library ships uncompiled", a import { declareComponent$ } from "@adobe/ecs/declare"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -export const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); `); // The test hook comes from the installed copy, which the library's compiled kernel attaches to. const application = join(project, "application.js"); @@ -391,7 +391,7 @@ declare module "@adobe/ecs" { interface Components { [speed]: { type: F32 } } } export const push = System.create({}, (_context, input: { readonly by: number }) => { void input; }); -export const slow = System.create({ kernel: "cpu", components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); +export const slow = System.create({ kernel: true, components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); `); const outDir = join(project, "dist"); const output = await build({ @@ -500,8 +500,8 @@ test("compiled code imports the runtime from the copy of the package its module writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); declareComponent$(score, { type: { type: "number" } }); -export const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -export const spawner = System.create({ components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); +export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const spawner = System.create({ kernel: false, components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); const server = await createServer({ configFile: false, logLevel: "silent", root: project, plugins: [plugin()], diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 855bd479..f5b2d71b 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -89,7 +89,7 @@ const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ }); /** - * Compiles kernel systems (`kernel: "cpu"` or `"gpu"`). Runs after TypeScript is stripped and before minification, so it sees plain JS + * Compiles kernel systems (`kernel: true` or `"gpu"`). Runs after TypeScript is stripped and before minification, so it sees plain JS * with source names. Each kernel's `create` becomes its own module at `description.url`: served from a * root-absolute url in dev, and emitted as a chunk in a build. * @@ -202,7 +202,7 @@ const inputPlugin = (): Plugin => { }; /** - * The kernel plugin's two parts: registrations and input schemas, before TypeScript is stripped, and kernels and ids, + * The plugin's two parts: registrations and input schemas, before TypeScript is stripped, and kernels and ids, * after. A library's build takes `{ library: true }`, the first part alone: its build then ships the registrations and * schemas its types give, which an application's build, reading only JavaScript there, can't make, and leaves its * kernels as source and its keys without ids, which the application's plugin compiles and gives. @@ -258,8 +258,12 @@ const compilePlugin = (): Plugin => { const instance: Plugin = { name: `${packageName}/kernel`, enforce: "post", - // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. - config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => plugin() } }), + // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. Kernels are chunks + // of their own, so a worker bundle holding one is a module, unless the configuration says otherwise. + config: user => ({ + optimizeDeps: { exclude: [packageName] }, + worker: { plugins: () => plugin(), ...user.worker?.format === undefined ? { format: "es" as const } : {} }, + }), configResolved: config => { serve = config.command === "serve"; repeated = config.plugins.find(other => other.name === instance.name) !== instance; diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 24e0bbff..df00ec84 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -71,16 +71,31 @@ const reductions = (node: Expression | Pattern, lists: Lists): readonly Reduced[ }).map((entry, index, all) => all.findIndex(other => other.resource === entry.resource) === index ? entry : fail(node, `${entry.resource} is reduced twice`)); }; -/** What kind of kernel a `System.create` declaration compiles to, or null when it isn't a kernel. */ -export const kernelOf = (declaration: ObjectExpression): "cpu" | "gpu" | null => { +/** How a `System.create` declaration's body compiles: to `target`, and whether its `kernel` requires it or it's tried. */ +export type KernelRequest = { readonly target: "cpu" | "gpu"; readonly required: boolean }; + +/** Whether a declaration has property `name`, written out. */ +const declares = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name); + +/** + * How a `System.create` declaration compiles to a kernel, or null when it doesn't: `kernel: true` or `"gpu"` requires + * it, `false` rules it out, and without it a system with components is tried, unless it observes, runs on one entity + * or spreads what the build can't read. + */ +export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => { for (const property of declaration.properties) { if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { const { value } = property; - if (value.type === "Literal" && (value.value === "cpu" || value.value === "gpu")) return value.value; - return fail(value, `kernel must be the literal "cpu" or "gpu"`); + if (value.type === "Literal" && value.value === true) return { target: "cpu", required: true }; + if (value.type === "Literal" && value.value === "gpu") return { target: "gpu", required: true }; + if (value.type === "Literal" && value.value === false) return null; + return fail(value, `kernel must be the literal true, false or "gpu"`); } } - return null; + if (declaration.properties.some(property => property.type === "SpreadElement")) return null; + if (!declares(declaration, "components") || declares(declaration, "observer") || declares(declaration, "target")) return null; + return { target: "cpu", required: false }; }; /** Reads and validates a kernel declaration; `lists` reads its lists of keys. */ @@ -147,7 +162,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li fail(property, `${name} is not allowed in a kernel declaration`); } } - if (components === undefined) return fail(declaration, "a kernel needs components"); + if (components === undefined) return fail(declaration, "a system without components runs once, so there's nothing to compile"); for (const { resource } of reduced) { if (resources.includes(resource) || changedResources.includes(resource)) fail(declaration, `${resource} is reduced, so it can't also be read or filtered on`); } diff --git a/packages/ecs/src/implementation/compiler/subset.md b/packages/ecs/src/implementation/compiler/subset.md index 478f11bd..83784ce4 100644 --- a/packages/ecs/src/implementation/compiler/subset.md +++ b/packages/ecs/src/implementation/compiler/subset.md @@ -1,6 +1,6 @@ # Kernel subset -What a kernel body (`kernel: "cpu"` or `"gpu"`) may contain. The build rejects anything else with a message at the +What a kernel body (`kernel: true` or `"gpu"`) may contain. The build rejects anything else with a message at the offending code. It is plain TypeScript; types are stripped before compiling. ``` diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 4fa40198..0e1bf0cc 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -319,10 +319,46 @@ const run = (description: Compiled, rows: number, fields: Fields, options: Optio const vec3 = (x: readonly number[], y: readonly number[], z: readonly number[]) => ({ "[0]": x, "[1]": y, "[2]": z }); +describe("kernels by default", () => { + /** Whether the transform attached a kernel to the module's system, and the failure if it failed. */ + const attached = async (source: string): Promise => { + const result = await transform(source); + if (result === null) return false; + return "failure" in result ? result.failure.message : result.code.includes("attachKernel$("); + }; + test("compiles a system with components whose body is in the subset, unflagged", async () => { + expect(await attached(`const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`)).toBe(true); + }); + test("runs as written, without failing, a body that leaves the subset or a declaration a kernel can't have", async () => { + expect(await attached(`const log = []; const kernel = System.create({ components: { read: [mass] } }, row => { log.push(row[mass]); });`)).toBe(false); + expect(await attached(`const kernel = System.create({ components: { read: [mass] }, resources: { write: [delta] } }, (row, { resources }) => { resources[delta] = row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ observer: true, components: { read: [mass] } }, row => { void row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ target: "entity", components: { write: [score] } }, row => { row[score] = 1; });`)).toBe(false); + expect(await attached(`const make = () => System.create({ components: { write: [score] } }, row => { row[score] = 1; });`)).toBe(false); + }); + test("runs as written a body reading data of a module object or array, which may change, and compiles one reading constants and functions", async () => { + expect(await attached(`export const scene = { subject: 0 }; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe(false); + expect(await attached(`const values = [1, 2]; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = values[0]; });`)).toBe(false); + expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); + expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); + }); + test("never compiles a system without components or with kernel: false", async () => { + expect(await attached(`const kernel = System.create({ resources: { read: [delta] } }, ({ resources }) => { void resources[delta]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: false, components: { read: [mass], write: [score] } }, row => { row[score] = row[mass]; });`)).toBe(false); + }); + test("fails, saying why, a system with kernel: true that can't compile", async () => { + expect(await attached(`const kernel = System.create({ kernel: true, resources: { read: [delta] } }, ({ resources }) => { void resources[delta]; });`)) + .toBe("a system without components runs once, so there's nothing to compile"); + expect(await attached(`const log = []; const kernel = System.create({ kernel: true, components: { read: [mass] } }, row => { log.push(row[mass]); });`)).not.toBe(true); + expect(await attached(`const make = () => System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = 1; });`)) + .toBe("kernel systems must be declared at module scope"); + }); +}); + describe("particles", () => { test("gravity and integrate", async () => { const gravity = await compile(`const kernel = System.create({ - kernel: "cpu", + kernel: true, components: { write: [velocity] }, resources: { read: [delta] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; @@ -335,7 +371,7 @@ describe("particles", () => { expect(gravity.resources).toEqual(["delta"]); const integrate = await compile(`const kernel = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{ name: "particles/gravity" }], }, (row, { resources }) => { const [px, py, pz] = row[position]; @@ -354,7 +390,7 @@ describe("particles", () => { describe("layout", () => { test("interleaved components are read and written at row * stride + offset, leaving padding untouched", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; row[velocity] = [z, row[alive] === true ? y * 10 : y, x]; });`); @@ -367,7 +403,7 @@ describe("layout", () => { }); test("stamps set the changed row and its chunk; each changed column is followed by its chunk column", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { if (row[mass] > 0) row[score] = 1; });`); const rows = 2 * 2 ** chunkShift + 5; @@ -381,12 +417,12 @@ describe("layout", () => { }); test("an unconditional stamp stamps the call's chunks once; a stamp after return stays per row", async () => { - const always = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const always = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { row[score] = row[mass]; });`); expect(String(always.create)).not.toContain(">> 10] = tick"); expect(run(always, 3200, {}, { range: [1000, 2100] }).chunks("score")).toEqual([tick, tick, tick, 0]); - const after = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const after = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { if (row[mass] > 0) return; row[score] = 1; });`); @@ -396,7 +432,7 @@ describe("layout", () => { }); test("indices stay in step when return skips the rest of a row", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; if (x > 1 && x < 5) return; row[velocity] = [z, y, x]; @@ -411,7 +447,7 @@ describe("reductions", () => { const reducing = (reduce: string, body: string, read = "[position, mass]") => `const total = "total"; const low = "low"; const bits = "bits"; -const kernel = System.create({ kernel: "cpu", components: { read: ${read} }, resources: { reduce: [${reduce}] } }, (row, { reduce }) => { ${body} });`; +const kernel = System.create({ kernel: true, components: { read: ${read} }, resources: { reduce: [${reduce}] } }, (row, { reduce }) => { ${body} });`; const fields = { position: vec3([1, -4, 2], [5, 0, -1], [3, 9, 6]), mass: { "[]": [2, 3, 12] } }; test("fold each contribution into its resource, leaf by leaf, from the partial", async () => { @@ -438,7 +474,7 @@ const kernel = System.create({ kernel: "cpu", components: { read: ${read} }, res expect(await failure(reducing("total", "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); expect(await failure(reducing("total", "reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); expect(await failure(reducing("total", "reduce[total](1);"), {}, {})).toBe("total is reduced, so the build reads its operator from its declaration's reduce trait, which it can't find: declare the resource with reduce in the module that makes its key"); - expect(await failure(reducing("total", "reduce[total](1);").replace(`kernel: "cpu"`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); + expect(await failure(reducing("total", "reduce[total](1);").replace(`kernel: true`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); expect(await failure(reducing("total", "reduce[total](1);").replace("resources: { reduce:", "resources: { read: [total], reduce:"))).toBe("total is reduced, so it can't also be read or filtered on"); expect(await failure(reducing("total, total", "reduce[total](1);"))).toBe("total is reduced twice"); }); @@ -446,7 +482,7 @@ const kernel = System.create({ kernel: "cpu", components: { read: ${read} }, res describe("let locals of component values", () => { test("written back whole after a tuple assignment have the tuple's shape", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [position] } }, (row) => { let v = row[position]; if (row[alive]) v = [7, 8, 9]; row[position] = v; @@ -459,7 +495,7 @@ describe("let locals of component values", () => { describe("binding identity", () => { test("a local shadowing a component key is not that component", async () => { - expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + expect(await failure(`const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { const velocity = 1; row[position] = row[velocity]; });`)).toBe("row is indexed by a component in components read or write, or .entity"); @@ -467,14 +503,14 @@ describe("binding identity", () => { test("another binding with the same key string is not the declared component", async () => { expect(await failure(`const speed = "velocity"; - const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { row[position] = row[speed]; });`)).toBe("speed is not in components read or write"); }); test("a helper parameter shadows a module binding only inside the helper", async () => { const kernel = await compile(`const twice = (velocity) => velocity * 2; - const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { row[position] = twice(row[velocity]) + row[velocity]; });`); expect(run(kernel, 1, { velocity: { "[]": [3] } }).field("position")).toEqual([9]); @@ -483,7 +519,7 @@ describe("binding identity", () => { describe("value semantics", () => { test("tuple locals are copies, and snapshots read before later writes", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { const before = row[position]; let v = [before[0], before[1]]; const w = v; @@ -497,7 +533,7 @@ describe("value semantics", () => { }); test("whole-value writes evaluate every leaf before storing", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { row[position] = [row[position][1], row[position][0]]; let a = 1; let b = 2; @@ -510,7 +546,7 @@ describe("value semantics", () => { }); test("struct values and conditional tuples", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [position] } }, (row) => { const p = row[mass] > 1 ? { x: 1, y: [2, 3] } : { x: 4, y: [5, 6] }; row[position] = p; });`); @@ -528,7 +564,7 @@ describe("helpers", () => { return [x * k, y * k]; }; const length = ([x, y]) => Math.sqrt(x * x + y * y); - const kernel = System.create({ kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { const k = 100; const x = 1; row[position] = scale(row[position], 0.5); @@ -541,14 +577,14 @@ describe("helpers", () => { test("recursive helpers are rejected", async () => { expect(await failure(`const f = (x) => f(x); - const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = f(1); });`)) + const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = f(1); });`)) .toBe("f is recursive"); }); }); describe("control flow", () => { test("return ends the row, also from inside a loop", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { if (row[mass] < 0) return; let total = 0; for (let i = 0; i < 10; i++) { @@ -563,7 +599,7 @@ describe("control flow", () => { test("a loop test that inlines a helper is re-evaluated each iteration", async () => { const kernel = await compile(`const below = (a, b) => { const limit = b * 2; return a < limit; }; - const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { let n = 0; while (below(n, row[mass])) { n++; } let m = 0; @@ -577,7 +613,7 @@ describe("control flow", () => { describe("context", () => { test("lookups map components to their read index, with null guards", async () => { const kernel = await compile(`const half = (value) => { const h = value / 2; return h; }; - const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { + const kernel = System.create({ kernel: true, components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { row[position] = other[position] - other[mass]; } row[score] = (other !== null && half(other[position])) || -1; @@ -590,7 +626,7 @@ describe("context", () => { }); test("optional lookup access, destructured context groups and input", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; @@ -598,7 +634,7 @@ describe("context", () => { }); test("context groups reached through the context parameter", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { const { resources } = context; row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + input; });`); @@ -607,7 +643,7 @@ describe("context", () => { }); test("boolean columns read as booleans", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [score] } }, (row) => { row[score] = row[alive] === true ? 1 : 2; });`); expect(run(kernel, 2, { alive: { "[]": [1, 0] } }, { booleans: ["alive"] }).field("score")).toEqual([1, 2]); @@ -615,7 +651,7 @@ describe("context", () => { }); test("boolean reads convert only where observable, including through locals", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { const flag = row[alive]; const copy = flag; row[score] = (copy === true ? 10 : 20) + row[mass] * 2 + (flag ? 1 : 0); @@ -634,7 +670,7 @@ describe("context", () => { }); test("a spawn nested in an add's value is issued before the add begins", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { commands.add(row.entity, [target, score], { [target]: commands.spawn([mass])({ [mass]: row[mass] }), [score]: 1 }); commands.despawn(row.entity); });`); @@ -648,7 +684,7 @@ describe("context", () => { }); test("commands name components through keys", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [position] } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [position] } }, (row, context) => { const { commands } = context; const spawned = commands.spawn([position, alive])({ [position]: [row[position][0], 1] }); commands.add(spawned, score, row.entity); @@ -673,7 +709,7 @@ describe("context", () => { const kernel = await compile(`import { Archetype } from "ecs"; const unit = Archetype.create([position, alive, score]); const pair = [mass, score]; - const kernel = System.create({ kernel: "cpu", components: { read: [position] } }, (row, { commands }) => { + const kernel = System.create({ kernel: true, components: { read: [position] } }, (row, { commands }) => { const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); commands.add(spawned, pair, { [mass]: 3 }); commands.remove(row.entity, unit); @@ -690,7 +726,7 @@ describe("context", () => { test("kernel declarations list components through module constants", async () => { const kernel = await compile(`import { Archetype } from "ecs"; const moving = Archetype.create([position, velocity]); - const kernel = System.create({ kernel: "cpu", components: { read: moving, write: [score] } }, row => { + const kernel = System.create({ kernel: true, components: { read: moving, write: [score] } }, row => { row[score] = row[position] + row[velocity]; });`); expect(run(kernel, 2, { position: { "[]": [1, 2] }, velocity: { "[]": [10, 20] } }).field("score")).toEqual([11, 22]); @@ -705,13 +741,13 @@ describe("context", () => { ["an add with too many arguments", "commands.add(row.entity, mass, 1, 2);", "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"], ["a remove with rest components", "commands.remove(row.entity, mass, score);", "a remove is commands.remove(entity, component) or commands.remove(entity, components)"], ])("fail the build for %s", async (_, body, message) => { - expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); + expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); }); }); describe("program", () => { test("input and resource paths are scalars in the program", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { const [, y] = d; row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); });`); @@ -722,7 +758,7 @@ describe("program", () => { }); test("runtime indexing into a resource has no program", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { row[score] = d[row[mass]]; });`); expect(run(kernel, 1, { mass: { "[]": [1] } }, { resources: [[5, 6]], program: false }).field("score")).toEqual([6]); @@ -733,14 +769,14 @@ describe("composites", () => { test("one argument to System.create that isn't a body is a composite, never a kernel", async () => { const result = await transform(`const a = System.create({}, () => {}); const listed = System.create([a]); -const declared = System.create({ kernel: "cpu", systems: [a], components: { write: [score] } });`); +const declared = System.create({ kernel: true, systems: [a], components: { write: [score] } });`); expect(result !== null && "failure" in result ? result.failure.message : result?.kernels ?? []).toEqual([]); }); }); describe("lookups in the program", () => { const targets = (entity: number) => entity === 1 ? [3, [10, 20, 30]] : entity === 2 ? [4, [40, 50, 60]] : null; - const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ kernel: "cpu", + const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ kernel: true, components: { read: ${read}, write: [score] }, lookup: { ${lookup} } }, (row, { lookup: { ${lookup.includes("others") ? "targets, others" : "targets"} } }) => { ${body} });`; @@ -786,7 +822,7 @@ describe("lookups in the program", () => { describe("subset", () => { const kernel = (body: string, components = "{ read: [mass], write: [score] }") => - `const kernel = System.create({ kernel: "cpu", components: ${components} }, (row, { commands }) => { ${body} });`; + `const kernel = System.create({ kernel: true, components: ${components} }, (row, { commands }) => { ${body} });`; test.each([ ["row[mass] = 1;", "mass is not in components write"], @@ -819,20 +855,20 @@ describe("subset", () => { .toBe("a GPU kernel can't issue commands or use values that aren't numbers or booleans"); expect(await failure(gpu("components: { read: [mass], write: [score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) .toBe("a GPU kernel can't use loops or labeled blocks yet"); - expect(await failure(`const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => {});`)) - .toBe(`kernel must be the literal "cpu" or "gpu"`); + expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + .toBe(`kernel must be the literal true, false or "gpu"`); }); test("GPU programs carry their locals' names", async () => { const body = "(row) => { const doubled = row[mass] * 2; row[score] = doubled; }"; const gpu = await compile(`const kernel = System.create({ kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); - const cpu = await compile(`const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, ${body});`); + const cpu = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, ${body});`); expect(gpu.program?.names).toEqual([expect.stringMatching(/^doubled_\d+$/)]); expect(cpu.program?.names).toBeUndefined(); }); test("kernels inside functions", async () => { - expect(await failure(`const make = () => System.create({ kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const make = () => System.create({ kernel: true, components: { write: [score] } }, (row) => {});`)) .toBe("kernel systems must be declared at module scope"); }); @@ -845,7 +881,7 @@ describe("subset", () => { test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { expect(await transform(`const s = System.create({ components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); - expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); + expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: true }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); @@ -888,7 +924,7 @@ describe("terminal bodies", () => { test("mark a system made from its body alone, and no kernel or finalizer", async () => { const alone = await transform("const system = System.create(() => { console.log(1); });"); expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); - const kernel = await transform(`const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); + const kernel = await transform(`const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = 1; });`); expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); expect(finalizer).toBeNull(); @@ -1040,7 +1076,7 @@ describe("helpers that return early", () => { } return [c, 2]; }; - const kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { read: [mass], write: [position] } }, (row) => { const [value, path] = pick(row[mass], 4); row[position] = [value, path, 0]; });`); @@ -1056,7 +1092,7 @@ describe("math builtins", () => { const results = (result: ReturnType) => [0, 1, 2].map(row => [0, 1, 2].map(k => result.field("position", [k])[row])); test("inline as scalar code, nested namespaces and helpers they call included", async () => { - const kernel = await compile(`${math}const kernel = System.create({ kernel: "cpu", components: { read: [velocity], write: [position, score] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position, score] } }, (row) => { const p = row[position]; const v = row[velocity]; row[position] = Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.F32.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); @@ -1075,7 +1111,7 @@ describe("math builtins", () => { }); test("across namespaces: quaternions rotating vectors, and matrices", async () => { - const kernel = await compile(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])); @@ -1090,7 +1126,7 @@ describe("math builtins", () => { const body = `(row) => { row[position] = Vec3.scale(Vec3.normalize(Vec3.cross(row[position], row[velocity])), Vec3.dot(row[position], row[velocity])); }`; const declaration = `components: { read: [velocity], write: [position] }`; const gpu = await compile(`${math}const kernel = System.create({ kernel: "gpu", ${declaration} }, ${body});`); - const cpu = await compile(`${math}const kernel = System.create({ kernel: "cpu", ${declaration} }, ${body});`); + const cpu = await compile(`${math}const kernel = System.create({ kernel: true, ${declaration} }, ${body});`); const regions = (gpu.program?.body ?? []).flatMap(statement => statement.kind === "intrinsic" ? [statement.intrinsic.operation] : []); expect(regions).toEqual(["cross", "normalize", "dot", "*"]); expect((cpu.program?.body ?? []).some(statement => statement.kind === "intrinsic")).toBe(false); @@ -1104,17 +1140,17 @@ describe("math builtins", () => { }); test("that leave the kernel subset fail with the function's reason", async () => { - expect(await failure(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { row[position] = Vec3.random(); });`)).toBe("Math.random is not in the kernel subset"); - expect(await failure(`${math}const kernel = System.create({ kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { row[position] = Vec3.missing([1, 2, 3]); });`)).toMatch(/^missing isn't exported by .*\/math\/vec3\/public\.ts$/); }); }); describe("imported functions and constants", () => { - const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { ${body} });`; + const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { ${body} });`; const scores = async (source: string, files: { readonly [file: string]: string }) => run(await compile(source, files), 2, { mass: { "[]": [2, 3] } }).field("score"); @@ -1199,7 +1235,7 @@ describe("imported lists", () => { const Unit = ["health", "armor", "tagged"]; const Guard = ["shield", "armor"]; const commanding = (imports: string, body: string) => - `${imports}\nconst kernel = System.create({ kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { ${body} });`; + `${imports}\nconst kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { ${body} });`; test("name the keys of a list imported from another module, by the kernel's bindings or else through the list", async () => { const source = commanding(`import { health, armor } from "./components.js";\nimport { Unit, Guard } from "./archetypes.js";`, ` @@ -1238,11 +1274,11 @@ describe("imported lists", () => { test("in a declaration, bind keys the list's module defines to the kernel's imports of them, under any name", async () => { const source = `import { shield, Guard } from "./archetypes.js";\nimport { armor as plate } from "./components.js"; -const kernel = System.create({ kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; +const kernel = System.create({ kernel: true, components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; const kernel = await compile(source, files, { shield: "shield", Guard, plate: "armor" }); expect(run(kernel, 2, { shield: { "[]": [1, 2] }, armor: { "[]": [3, 4] } }).field("score")).toEqual([13, 24]); expect(await failure(`import { shield, Guard } from "./archetypes.js"; -const kernel = System.create({ kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) +const kernel = System.create({ kernel: true, components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) .toBe("Guard[1] is in components read but never read; remove it, or move it to components has to keep requiring it"); }); @@ -1294,14 +1330,14 @@ const kernel = System.create({ kernel: "cpu", components: { read: Guard, write: test("fail the build for a declaration's list computed when its module runs", async () => { expect(await failure(`import { Computed } from "./archetypes.js"; -const kernel = System.create({ kernel: "cpu", components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) +const kernel = System.create({ kernel: true, components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) .toBe("components.read: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); }); }); describe("declared access", () => { const kernel = (declaration: string, body: string) => - `const kernel = System.create({ kernel: "cpu", ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; + `const kernel = System.create({ kernel: true, ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; const system = (declaration: string, body: string) => `const system = System.create({ ${declaration} }, ${body});`; test("a kernel uses every component and resource it lists", async () => { @@ -1322,7 +1358,7 @@ describe("declared access", () => { test("a kernel reads every component it lists in up.read, and up holds only those", async () => { const up = (kind: string, read: string, body: string) => - `const kernel = System.create({ kernel: "${kind}", components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; + `const kernel = System.create({ kernel: ${kind === "gpu" ? `"gpu"` : "true"}, components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; const propagate = "row[score] = (up === null ? 0 : up[score]) + row[mass];"; expect(await failure(up("cpu", "score, position", propagate))).toBe("position is in up.read but never read; remove it"); expect(await failure(up("cpu", "score", "row[score] = up === null ? row[mass] : up[position];"))).toBe("up is indexed by a component in up.read"); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index c73bde2d..bb5beaca 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -7,7 +7,7 @@ import { isPending, type Link, type Linker } from './link.js'; import { createModule, locateIn, type Module } from './module-scope.js'; import type { PackageCopy } from './paths.js'; import { spawnSites } from './spawns.js'; -import { kernelOf } from './signature.js'; +import { kernelOf, type KernelRequest } from './signature.js'; import { usedBy } from './body-access.js'; import { requireExact } from './exact.js'; import { createLists } from './lists.js'; @@ -110,10 +110,10 @@ const uniqueName = (code: string, base: string): string => { }; /** Compiles a kernel, loading what it imports as compiling reaches it. */ -const compileLinked = async (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu", link: Link | null): Promise => { +const compileLinked = async (declaration: ObjectExpression, body: Node, module: Module, request: KernelRequest, link: Link | null): Promise => { for (;;) { try { - return compileKernel(declaration, body, module, target, link); + return compileKernel(declaration, body, module, request.target, link, !request.required); } catch (error) { if (!isPending(error)) throw error; await error.pending; @@ -121,7 +121,32 @@ const compileLinked = async (declaration: ObjectExpression, body: Node, module: } }; -type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly target: "cpu" | "gpu" }; +type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly request: KernelRequest }; + +/** + * The module's `System.create` calls that compile to kernels, required or tried: at module scope, with an inline + * body. Throws for a required one elsewhere or without one; a tried one there runs as written. + */ +const kernelSites = (program: Program, locals: ReadonlyMap): readonly KernelSite[] => { + const sites: KernelSite[] = []; + const visit = (node: Node, nested: boolean): void => { + if (node.type === "CallExpression" && createdBy(node, locals) === "system") { + const [declaration, body] = node.arguments; + // One argument makes a composite, or a system from its body alone: never a kernel. + const request = declaration?.type === "ObjectExpression" && body !== undefined ? kernelOf(declaration) : null; + if (declaration?.type === "ObjectExpression" && request !== null) { + const inline = body !== undefined && isFunction(body); + if (request.required && nested) fail(node, "kernel systems must be declared at module scope"); + if (request.required && !inline) fail(node, "a kernel needs an inline body"); + if (!nested && inline) sites.push({ node, declaration, body, request }); + } + } + const inner = nested || isFunction(node); + for (const child of children(node)) visit(child, inner); + }; + visit(program, false); + return sites; +}; /** * Wraps each kernel `System.create(declaration, body)` call as `attachKernel(call, description)`, and returns each @@ -162,7 +187,8 @@ export const transformKernels = async ( let terminals = false; const edits: Edit[] = []; const kernels: string[] = []; - const sites: KernelSite[] = []; + /** Each compiled kernel's description, by its call, and whether its declaration required it. */ + const compiled = new Map(); /** Module-scope declarations of prepared spawns, appended after everything they name is declared. */ const prepared: string[] = []; let adding = false; @@ -177,11 +203,9 @@ export const transformKernels = async ( const alone = created === "system" && first !== undefined && isFunction(first) && second === undefined; const declaration = alone ? undefined : first; const body = alone ? first : second; - const target = declaration?.type === "ObjectExpression" ? kernelOf(declaration) : null; - if (declaration?.type === "ObjectExpression" && target !== null) { - if (nested) fail(node, "kernel systems must be declared at module scope"); - if (body === undefined || body.type === "SpreadElement") return fail(node, "a kernel needs an inline body"); - sites.push({ node, declaration, body, target }); + const kernel = compiled.get(node); + if (kernel !== undefined) { + edits.push({ start: node.start, end: node.end, text: `${attach}(${code.slice(node.start, node.end)}, ${kernel.description}${kernel.required ? "" : ", true"})` }); return; } const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : null; @@ -221,14 +245,20 @@ export const transformKernels = async ( for (const child of children(node)) visit(child, inner); }; try { - visit(program, false); - for (const { node, declaration, body, target } of sites) { - const compiled = await compileLinked(declaration, body, module, target, link); - const source = `export const create = ${compiled.create};\n`; - const description = compiled.description(kernelUrl(kernels.length, `${compiled.description("")}\n${source}`)); + for (const { node, declaration, body, request } of kernelSites(program, locals)) { + let kernel: CompiledKernel; + try { + kernel = await compileLinked(declaration, body, module, request, link); + } catch (error) { + // A body tried unflagged that leaves the subset runs as written. + if (!request.required && failureOf(error) !== undefined) continue; + throw error; + } + const source = `export const create = ${kernel.create};\n`; + compiled.set(node, { description: kernel.description(kernelUrl(kernels.length, `${kernel.description("")}\n${source}`)), required: request.required }); kernels.push(source); - edits.push({ start: node.start, end: node.end, text: `${attach}(${code.slice(node.start, node.end)}, ${description})` }); } + visit(program, false); } catch (error) { const failure = failureOf(error); if (failure === undefined) throw error; diff --git a/packages/ecs/src/implementation/compiler/unit.ts b/packages/ecs/src/implementation/compiler/unit.ts index c222785b..39024c76 100644 --- a/packages/ecs/src/implementation/compiler/unit.ts +++ b/packages/ecs/src/implementation/compiler/unit.ts @@ -26,6 +26,8 @@ export type Unit = { readonly link: Link | null; /** GPU kernels emit intrinsic regions for calls to functions that match WGSL builtins. */ readonly target: "cpu" | "gpu"; + /** Whether the body is only tried, unflagged: then module data, which may change, isn't inlined. */ + readonly tried: boolean; /** Column descriptions as source text, in `columns` order, indexed by an identity key. */ readonly columns: string[]; readonly columnIndex: Map; @@ -79,13 +81,13 @@ export type Unit = { readonly statement: StatementCompiler; }; -export const createUnit = (signature: Signature, module: Module, lists: Lists, expression: ExpressionCompiler, statement: StatementCompiler, link: Link | null = null, target: "cpu" | "gpu" = "cpu"): Unit => { +export const createUnit = (signature: Signature, module: Module, lists: Lists, expression: ExpressionCompiler, statement: StatementCompiler, link: Link | null = null, target: "cpu" | "gpu" = "cpu", tried = false): Unit => { let counter = 0; const names = new Map(); const up = signature.up === null ? null : counter++; if (up !== null) names.set(up, "up"); return { - signature, module, lists, link, target, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), + signature, module, lists, link, target, tried, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), used: { read: new Set(), write: new Set(), resources: new Set(), lookups: new Map(), up: new Set() }, up, upColumns: [], upColumnIndex: new Map(), upBooleans: new Set(), probes: [], rowLeaves: [], handles: new Set(), next: () => counter++, expression, statement, diff --git a/packages/ecs/src/implementation/world/attach-kernel.test.ts b/packages/ecs/src/implementation/world/attach-kernel.test.ts index a2d42565..971cc3db 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.test.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.test.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; +import { kernelOf } from "./attach-kernel.js"; it("rejects a kernel lookup of a string, which kernels don't read, where the kernel is declared", async () => { await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); @@ -8,3 +9,9 @@ it("rejects a kernel lookup of a string, which kernels don't read, where the ker it("rejects a compiled kernel that doesn't match its schemas where the kernel is declared", async () => { await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`"kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); }); + +it("compiles an unflagged system whose body fits, and runs as written one whose kernel wouldn't match its schemas", async () => { + const { doubled, labels } = await import("./kernel-tried.test-helper.js"); + expect(kernelOf(doubled)).toBeDefined(); + expect(kernelOf(labels)).toBeUndefined(); +}); diff --git a/packages/ecs/src/implementation/world/attach-kernel.ts b/packages/ecs/src/implementation/world/attach-kernel.ts index 2c661ebb..e76e9a5d 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { definitionOf } from '../../interface/registry/index.js'; import type { System } from '../../interface/system/system.js'; -import { checkKernel } from './check-kernel.js'; +import { unreadByKernel } from '../../interface/system/check.js'; +import { checkKernel, kernelMismatch } from './check-kernel.js'; import type { KernelDescription } from './kernel.js'; /** By the system `System.create` returned, so a lookup doesn't depend on which copy of the package defined it. */ @@ -10,13 +11,18 @@ const kernels = new WeakMap, KernelDescription>(); /** * The compiler's only hook into the runtime: wraps each kernel system's `System.create` call. Throws for a kernel that * doesn't match its declaration and schemas (`checkKernel`), and for a key that's a list, which a command names as one - * component only where its build couldn't read the name's module. + * component only where its build couldn't read the name's module. A kernel the build only `tried`, for a system + * without a `kernel` flag, attaches only where it matches, and the system otherwise runs as written. */ -export const attachKernel = >(system: S, description: KernelDescription): S => { - for (const key of description.keys) { - if (Array.isArray(key)) throw new Error(`System "${system.name}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); - } +export const attachKernel = >(system: S, description: KernelDescription, tried = false): S => { + const listed = description.keys.some(key => Array.isArray(key)); const leaf = definitionOf(system); + if (tried) { + if (listed || leaf === undefined || leaf.kind === "composite" || unreadByKernel(leaf) !== null || kernelMismatch(leaf, description) !== null) return system; + kernels.set(system, description); + return system; + } + if (listed) throw new Error(`System "${system.name}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); if (leaf !== undefined && leaf.kind !== "composite") checkKernel(leaf, description); kernels.set(system, description); return system; diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts index 722795e9..92d229bf 100644 --- a/packages/ecs/src/implementation/world/builtins.test.ts +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -31,7 +31,7 @@ const rotation = Quat.fromAxisAngle([0, 1, 0], 0.5); const matrix = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); /** Rotates, transforms and offsets each position, with functions from three math namespaces inlined. */ -const transform = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); @@ -52,7 +52,7 @@ const integerResults = (b: Vec3.U32, s: Vec3.I32): { readonly counts: Vec3.U32; sums: Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))), }); -const integers = System.create({ kernel: "cpu", components: { read: [bits], write: [counts, sums] } }, row => { +const integers = System.create({ kernel: true, components: { read: [bits], write: [counts, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[counts] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))); diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts index 96b86329..82db1534 100644 --- a/packages/ecs/src/implementation/world/change-blocks.fixture.ts +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -28,13 +28,13 @@ const seed = System.create({}, ({ commands }) => { }); /** Writes the rows whose `at` is one of three, stamping row by row. */ -const writeSome = System.create({ kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { +const writeSome = System.create({ kernel: true, components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c) row[value] += 1; }); /** Counts each run of the rows whose value changed. */ -const watch = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { +const watch = System.create({ kernel: true, components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index be1be66f..e19ef0ae 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -75,12 +75,12 @@ const spawnOne = System.create({}, ({ commands }, input: { readonly at: number } commands.spawn(plain)({ [value]: 0, [at]: input.at, [seen]: 0, [group]: 0 }); }); /** Writes the rows whose `at` is in a list of up to four, which stamps row by row. */ -const writeSome = System.create({ kernel: "cpu", components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { +const writeSome = System.create({ kernel: true, components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c || here === input.d) row[value] += 1; }); /** Writes every row, which stamps chunks and blocks once per call. */ -const writeAll = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const writeAll = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] += 1; }); const setGroup = System.create({ target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity }) => { @@ -92,7 +92,7 @@ let found: number[] = []; const onMain = System.create({ components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); const observed = System.create({ observer: true, components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); const ofTwo = System.create({ components: { read: [at], changed: [group, value] } }, row => { found.push(row[at]); }); -const inKernel = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); +const inKernel = System.create({ kernel: true, components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); const inUp = System.create({ components: { read: [at], changed: [value] }, up: { through: parent, read: [value] } }, row => { found.push(row[at]); }); const byGroup = Index.create({ key: [group] }); let grouped: number[] = []; @@ -265,7 +265,7 @@ const addExtra = System.create({}, ({ commands }, input: { readonly entity: Enti commands.add(input.entity, extra, 1); }); const addedExtra = System.create({ components: { read: [at], added: [extra] } }, row => { found.push(row[at]); }); -const raiseHeights = System.create({ kernel: "cpu", components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { +const raiseHeights = System.create({ kernel: true, components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { row[height] = up === null ? 1 : up[height] + 1; }); const heights = System.create({ components: { read: [at], changed: [height] } }, row => { found.push(row[at]); }); diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts index 8ab9f24a..9e11b8b8 100644 --- a/packages/ecs/src/implementation/world/check-kernel.ts +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -25,65 +25,71 @@ const exactly = (layout: Layout | null, leaves: readonly Path[]): boolean => { }; /** - * Checks a compiled kernel against its declaration and the registered schemas, which don't depend on a world, so - * registering the kernel rejects it where it's declared: every column, whole-value write, lookup, `up` read and - * reduction it uses is declared, and names scalar leaves of its component's schema, each spawn site names each of its - * components once, from its keys, and a tuple or struct a command or spawn gives goes to a component stored in linear - * memory. Throws with the first mismatch. + * How a compiled kernel doesn't match its declaration and the registered schemas, or null when it does. They don't + * depend on a world, so registering the kernel rejects it where it's declared: every column, whole-value write, lookup, + * `up` read and reduction it uses is declared, and names scalar leaves of its component's schema, each spawn site names + * each of its components once, from its keys, and a tuple or struct a command or spawn gives goes to a component + * stored in linear memory. The first mismatch. */ -export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { +export const kernelMismatch = (leaf: Leaf, description: KernelDescription): string | null => { const { lookup, components: query, resources, up } = leaf.declaration; - const fail = (message: string): never => { throw new Error(`Kernel "${leaf.name}": ${message}`); }; const reduces = new Set(resources?.reduce); const queried = new Set([...query?.read ?? [], ...query?.write ?? []]); const written = new Set(query?.write); // A condition in `is` is readable too, and the compiler lists it with the reads. const readable = new Set([...resources?.read ?? [], ...resources?.is ?? []]); - for (const key of description.resources) if (!readable.has(key)) fail(`reads resource "${labelOf(key)}", which isn't in resources.read.`); + for (const key of description.resources) if (!readable.has(key)) return `reads resource "${labelOf(key)}", which isn't in resources.read.`; for (const column of description.columns) { if (column.kind === "entity") continue; - if (!queried.has(column.component)) fail(`"${labelOf(column.component)}" isn't in its components' read or write.`); + if (!queried.has(column.component)) return `"${labelOf(column.component)}" isn't in its components' read or write.`; if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks") { - if (!written.has(column.component)) fail(`writes "${labelOf(column.component)}", which isn't in its components' write.`); + if (!written.has(column.component)) return `writes "${labelOf(column.component)}", which isn't in its components' write.`; continue; } const layout = layoutOfKey(column.component); - if (layout !== null && layout.kind !== "linear") fail(`"${labelOf(column.component)}" isn't stored in linear memory.`); - if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + if (layout !== null && layout.kind !== "linear") return `"${labelOf(column.component)}" isn't stored in linear memory.`; + if (!hasLeaf(layout, column.path)) return `"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`; } for (const shape of description.shapes) { - if (!written.has(shape.component)) fail(`writes "${labelOf(shape.component)}", which isn't in its components' write.`); - if (!exactly(layoutOfKey(shape.component), shape.leaves)) fail(`a whole-value write of "${labelOf(shape.component)}" doesn't match its schema.`); + if (!written.has(shape.component)) return `writes "${labelOf(shape.component)}", which isn't in its components' write.`; + if (!exactly(layoutOfKey(shape.component), shape.leaves)) return `a whole-value write of "${labelOf(shape.component)}" doesn't match its schema.`; } - for (const lookupName of description.lookups) if (lookup?.[lookupName] === undefined) fail(`unknown lookup "${lookupName}".`); - if ((description.up === true) !== (up !== undefined)) fail(description.up === true ? "reads up, which its declaration doesn't declare." : "its declaration declares up, which it doesn't read."); + for (const lookupName of description.lookups) if (lookup?.[lookupName] === undefined) return `unknown lookup "${lookupName}".`; + if ((description.up === true) !== (up !== undefined)) return description.up === true ? "reads up, which its declaration doesn't declare." : "its declaration declares up, which it doesn't read."; const upReadable = new Set(up?.read); for (const column of description.upColumns ?? []) { - if (column.kind !== "field" || !upReadable.has(column.component)) fail(`reads "${labelOf(column.kind === "field" ? column.component : column.kind)}" through up, which isn't in up.read.`); - if (column.kind !== "field") continue; + if (column.kind !== "field" || !upReadable.has(column.component)) return `reads "${labelOf(column.kind === "field" ? column.component : column.kind)}" through up, which isn't in up.read.`; const layout = layoutOfKey(column.component); - if (layout !== null && layout.kind !== "linear") fail(`up reads "${labelOf(column.component)}", which isn't stored in linear memory.`); - if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + if (layout !== null && layout.kind !== "linear") return `up reads "${labelOf(column.component)}", which isn't stored in linear memory.`; + if (!hasLeaf(layout, column.path)) return `"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`; } for (const reduced of description.reductions) { - if (!reduces.has(reduced.resource)) fail(`reduces "${labelOf(reduced.resource)}", which isn't in its resources' reduce.`); - if (declarationOf(reduced.resource)?.reduce !== reduced.operator) fail(`reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", but it's declared reduce: "${labelOf(declarationOf(reduced.resource)?.reduce)}"; rebuild.`); - if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) fail(`contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`); + if (!reduces.has(reduced.resource)) return `reduces "${labelOf(reduced.resource)}", which isn't in its resources' reduce.`; + if (declarationOf(reduced.resource)?.reduce !== reduced.operator) return `reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", but it's declared reduce: "${labelOf(declarationOf(reduced.resource)?.reduce)}"; rebuild.`; + if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) return `contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`; } for (const list of description.spawns) { - list.forEach((k, position) => { - if (!Number.isInteger(k) || k < 0 || k >= description.keys.length) fail(`spawns a list naming component ${k} of its ${description.keys.length} keys.`); - if (list.findIndex(other => description.keys[other] === description.keys[k]) < position) fail(`spawns a list naming "${labelOf(description.keys[k])}" twice.`); - }); + for (let position = 0; position < list.length; position++) { + const k = list[position] ?? -1; + if (!Number.isInteger(k) || k < 0 || k >= description.keys.length) return `spawns a list naming component ${k} of its ${description.keys.length} keys.`; + if (list.findIndex(other => description.keys[other] === description.keys[k]) < position) return `spawns a list naming "${labelOf(description.keys[k])}" twice.`; + } } for (const k of description.structuredValues) { const key = description.keys[k]; const layout = key === undefined ? null : layoutOfKey(key); - if (layout !== null && layout.kind !== "linear") fail(`gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`); + if (layout !== null && layout.kind !== "linear") return `gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`; } for (const shape of description.reducedShapes) { if (!reduces.has(shape.component) || !exactly(layoutOfKey(shape.component), shape.leaves)) { - fail(`contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`); + return `contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`; } } + return null; +}; + +/** Throws with the first way a compiled kernel doesn't match its declaration and the registered schemas (`kernelMismatch`). */ +export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { + const mismatch = kernelMismatch(leaf, description); + if (mismatch !== null) throw new Error(`Kernel "${leaf.name}": ${mismatch}`); }; diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index d6a546c6..557f1ab7 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -49,7 +49,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) * of every seventh spawn's row, so a run of a few thousand rows overflows a worker's ring. */ const burst = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index] }, }, (row, { commands }, input: { readonly shift: number }) => { const i = row[index]; @@ -102,7 +102,7 @@ const targets = System.create({}, ({ commands }, input: { readonly count: number /** A spawn and an add for each row, then a read through its target, which throws at the last row, whose target is none. */ const failing = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index, target] }, lookup: { targets: { read: [index] } }, }, (row, { lookup, commands }) => { @@ -131,7 +131,7 @@ export const failed = (count: number): Promise => using(createWorld({ [w /** A spawn for every 64th row: each chunk uses one position of its block of eight and returns the rest. */ const sparse = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index], not: [made] }, }, (row, { commands }) => { if (row[index] % 64 === 0) commands.spawn([made])({ [made]: row[index] }); @@ -155,7 +155,7 @@ export const returned = (count: number): Promise<{ readonly spawned: number; rea /** Object components from a kernel: left out, null, and a number, by add and by spawn. */ const objects = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index] }, }, (row, { commands }) => { const i = row[index]; diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index 997bfc7a..f31d2afc 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -70,13 +70,13 @@ const addLiterals = System.create({ components: { has: [score], not: [health] } commands.add(row.entity, [health, label], { [label]: "x", [health]: 5 }); }); -const spawnFrom = System.create({ kernel: "cpu", components: { read: [score], not: [tag, other] } }, (row, { commands }) => { +const spawnFrom = System.create({ kernel: true, components: { read: [score], not: [tag, other] } }, (row, { commands }) => { commands.spawn(made)({ [score]: row[score] + 10 }); }); -const mark = System.create({ kernel: "cpu", components: { has: [tag], not: [other] } }, (row, { commands }) => { +const mark = System.create({ kernel: true, components: { has: [tag], not: [other] } }, (row, { commands }) => { commands.add(row.entity, marks, {}); }); -const unmark = System.create({ kernel: "cpu", components: { has: [other] } }, (row, { commands }) => { +const unmark = System.create({ kernel: true, components: { has: [other] } }, (row, { commands }) => { commands.remove(row.entity, marks); }); const defaulted = Archetype.create([score, label, tag]); @@ -192,14 +192,14 @@ describe("literal values in a system's body", () => { }); /** Three adds of one component to each row, by systems ordered first to third, the second adding twice. */ -const first = System.create({ kernel: "cpu", components: { has: [position] } }, (row, { commands }) => { +const first = System.create({ kernel: true, components: { has: [position] } }, (row, { commands }) => { commands.add(row.entity, score, 1); }); const second = System.create({ components: { has: [position] }, after: [first] }, (row, { commands }) => { commands.add(row.entity, score, 2); commands.add(row.entity, score, 3); }); -const third = System.create({ kernel: "cpu", components: { has: [position] }, after: [second] }, (row, { commands }) => { +const third = System.create({ kernel: true, components: { has: [position] }, after: [second] }, (row, { commands }) => { commands.add(row.entity, score, 4); }); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index 150cd9dc..edcb57a1 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -27,7 +27,7 @@ describe("composite input", () => { it("rejects a body taking input without the schema the plugin gives from its type, as one not written in place has", () => { const counting = (_context: unknown, input: { readonly count: number }): void => { void input.count; }; - expect(() => System.create({}, counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with the kernel plugin.'); + expect(() => System.create({}, counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with Compiler.plugin().'); }); it("rejects an input schema that isn't an object of named fields", () => { diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index 5a0a1df8..ddeed624 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -34,10 +34,10 @@ const pause = System.create({ resources: { write: [paused] } }, ({ resources }) const tick = System.create({ resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); const afterTick = System.create({ resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); const frame = System.create({ systems: [tick, afterTick] }); -const grow = System.create({ kernel: "cpu", components: { write: [value] }, resources: { not: [paused] } }, row => { +const grow = System.create({ kernel: true, components: { write: [value] }, resources: { not: [paused] } }, row => { row[value] = row[value] + 1; }); -const growWhilePaused = System.create({ kernel: "cpu", components: { write: [value] }, resources: { is: [paused] } }, row => { +const growWhilePaused = System.create({ kernel: true, components: { write: [value] }, resources: { is: [paused] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index 9f0d7516..d2e6eb94 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -62,7 +62,7 @@ const watch = System.create({ /** A kernel whose module can't be imported. */ const missing = attachKernel( - System.create({ kernel: "cpu", components: { has: [velocity] } }, () => {}), + System.create({ kernel: true, components: { has: [velocity] } }, () => {}), { columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "/@missing/kernel.js", program: null, reductions: [], reducedShapes: [] }, ); diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index 0709448d..313064f8 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -178,6 +178,8 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti let disposed = false; const ensured = { version: -1 }; + const minimumChunk = options.minimumChunk ?? defaultMinimumChunk; + /** Starts the pool with as many of the wanted workers as the platform has, or none: then kernels run here. */ const startPool = (): Pool | null => { const ports: WorkerPort[] = []; @@ -190,7 +192,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti if (ports.length === 0) return null; const started = createPool(ports, state.memory); pool = started; - context.workers = { pool: started, minimumChunk: options.minimumChunk ?? defaultMinimumChunk, next: 0 }; + context.workers = { pool: started, minimumChunk, next: 0 }; context.publish = createPublisher(state, started); return started; }; @@ -208,9 +210,21 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti return loadAll(runners); }; - /** Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration reloaded. */ + /** + * Whether some archetype has rows enough for a kernel to run on workers, half a piece as the fastest kernels split + * at: until then kernels run here, and the pool isn't started. + */ + const warranted = (): boolean => { + for (const archetype of state.archetypes) if (archetype.count >= minimumChunk / 2) return true; + return false; + }; + + /** + * Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration + * reloaded, starting the pool once it's warranted. + */ const loadOnWorkers = async (): Promise => { - if (workerCount === 0 || !workersBehind) return; + if (workerCount === 0 || !workersBehind || (pool === null && !warranted())) return; const behind = registrar.kernels().filter(runner => runner.kernel?.staleOnWorkers() === true); if (behind.length === 0) { workersBehind = false; @@ -429,6 +443,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti ensureProgramResources(state, ensured); if (startWatches(state)) await reloadStamped(); if (workersChanged()) await followWorkers(); + if (pool === null && workersBehind && workerCount > 0 && warranted()) await loadOnWorkers(); if (deviceChanged()) await followDevice(); const schedule = registrar.registered(definition) ?? registered; if (schedule === undefined) throw new Error(`System "${definition.name}" didn't register.`); diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts index 2b62e4d8..4c46539e 100644 --- a/packages/ecs/src/implementation/world/dispatch.fixture.ts +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -42,17 +42,17 @@ const scatter = System.create({}, ({ commands }) => { const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); }); -const fastA = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const fastA = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; row[position] = [x + vx, y + vy]; }); // Commands make it a JS kernel, dispatched by message. -const slowB = System.create({ kernel: "cpu", components: { read: [velocity] } }, (row, { commands }) => { +const slowB = System.create({ kernel: true, components: { read: [velocity] } }, (row, { commands }) => { const [vx] = row[velocity]; if (vx > 100) commands.despawn(row.entity); }); -const fastC = System.create({ kernel: "cpu", components: { read: [velocity], write: [offset] } }, row => { +const fastC = System.create({ kernel: true, components: { read: [velocity], write: [offset] } }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -64,11 +64,11 @@ const grow = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 1], [velocity]: [2, i % 3], [offset]: 0 }); }); let observedRows = 0; -const bump = System.create({ kernel: "cpu", components: { write: [offset] } }, row => { +const bump = System.create({ kernel: true, components: { write: [offset] } }, row => { row[offset] = row[offset] + 1; }); const watch = System.create({ components: { changed: [offset] } }, () => { observedRows++; }); -const later = System.create({ kernel: "cpu", components: { read: [velocity], write: [offset] }, after: [watch] }, row => { +const later = System.create({ kernel: true, components: { read: [velocity], write: [offset] }, after: [watch] }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 6f4fae50..37d41892 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -31,7 +31,7 @@ declare module "@adobe/ecs" { * whether its mark changed in the next digit, then whether it has a mark and isn't banned. */ const watchKernel = System.create({ - kernel: "cpu", + kernel: true, components: { read: [follows], write: [kernelSeen] }, lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index 706a9b82..643c86ce 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -93,7 +93,7 @@ export const bump = System.create({ components: { write: [value] } }, row => { }); /** The same as a kernel, whose stamps are muted unless something watches `value`. */ -export const bumpKernel = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +export const bumpKernel = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 1570ecd5..3164825b 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -629,7 +629,7 @@ const nudgeRow = System.create({ components: { write: [position] } }, row => { if (px === nudged) row[position] = [px, py + 1, pz]; }); -const nudgeKernel = System.create({ kernel: "cpu", components: { write: [position] } }, row => { +const nudgeKernel = System.create({ kernel: true, components: { write: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts index 7fb8b305..d2f55c2d 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -20,13 +20,13 @@ const seed = System.create({}, ({ commands }) => { }); /** Spawns and adds through lists another module declares, naming one of their keys only through the list. */ -const ignite = System.create({ kernel: "cpu", components: { read: [heat] } }, (row, { commands }) => { +const ignite = System.create({ kernel: true, components: { read: [heat] } }, (row, { commands }) => { commands.spawn(Spark)({ [ember]: row[heat] * 2 }); commands.add(row.entity, Lists.Marks, { [ember]: row[heat] }); }); /** Declares an imported list, and removes through one. */ -const cool = System.create({ kernel: "cpu", components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { +const cool = System.create({ kernel: true, components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { if (row[ember] > 4) commands.remove(row.entity, Spark); }); diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts index 90fce8f6..6fd52648 100644 --- a/packages/ecs/src/implementation/world/index-buckets.test.ts +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -53,7 +53,7 @@ const readRows = System.create({ lookup: { byGroup: { index: byGroup, read: [ser /** Each reader row's bucket's serials, each times its position plus one: a sum that tells the order. */ const orderSum = System.create({ - kernel: "cpu", + kernel: true, components: { read: [reads], write: [sum] }, lookup: { byGroup: { index: byGroup, read: [serial] } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts index c8aa20b9..4c48d5bc 100644 --- a/packages/ecs/src/implementation/world/index-build.fixture.ts +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -41,7 +41,7 @@ const shuffle = System.create({ components: { write: [cell] } }, row => { }); /** A kernel, so the pool starts, as it does with a world's first kernel. */ -const warm = System.create({ kernel: "cpu", components: { read: [serial], write: [weight] } }, row => { +const warm = System.create({ kernel: true, components: { read: [serial], write: [weight] } }, row => { row[weight] = row[serial] % 13; }); diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index c3300988..c05a0d28 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -61,7 +61,7 @@ const counting = System.create({ lookup: { byGroup: { index: byGroup, changed: t /** Each row's total: how many rows share its group, by hundreds, and its weight; run only when the groups changed. */ const tally = System.create({ - kernel: "cpu", + kernel: true, components: { read: [group, weight], write: [total] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index a1ac011f..fdd507d3 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -43,7 +43,7 @@ const serials = Index.create({ key: [serial], unique: true, components: { has: [ /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } }, }, (row, { lookup }) => { @@ -81,7 +81,7 @@ const neighborsByHand = System.create({ components: { read: [cell], write: [tota /** How many rows of the row's lane have a rank within 5 of its own, and the first of them, through a sorted range. */ const window = System.create({ - kernel: "cpu", + kernel: true, components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } }, }, (row, { lookup }) => { @@ -100,7 +100,7 @@ const windowByHand = System.create({ components: { read: [lane, rank], write: [c /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [total] }, lookup: { bySerial: { index: serials, read: [weight] } }, }, (row, { lookup }) => { @@ -110,7 +110,7 @@ const follow = System.create({ /** Counts runs whose list of the row's cell changed. */ const watch = System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [changes] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { @@ -178,7 +178,7 @@ describe.each([[true], [false]])("kernels looking up through indexes, WASM %s", /** A comparison on a hash index. */ const compared = System.create({ - kernel: "cpu", + kernel: true, components: { read: [serial], write: [count] }, lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index 38be3ee8..5f52e0c6 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -202,7 +202,7 @@ describe("an incrementally refreshed index without a key", () => { /** Each row's tally of the rows of `shown`: how many, and the sum of a window of their weights. */ const tally = System.create({ - kernel: "cpu", + kernel: true, components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } }, }, (row, { lookup }) => { @@ -221,7 +221,7 @@ const tallyByHand = System.create({ components: { read: [color], write: [total, }); /** Counts runs whose list changed, `weight` watched. */ const visit = System.create({ - kernel: "cpu", + kernel: true, components: { write: [visited] }, lookup: { shown: { index: shown, changed: [weight] } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index ddb54f1c..600b8577 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -38,10 +38,10 @@ const visitChanged = System.create({ components: { read: [lane, rank], changed: order.push(row[lane] * 1000 + row[rank]); }); /** A kernel spawning a marker for each row in turn, so the markers' entities, in order, are the rows' order. */ -const markEach = System.create({ kernel: "cpu", components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { +const markEach = System.create({ kernel: true, components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const markChanged = System.create({ kernel: "cpu", components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { +const markChanged = System.create({ kernel: true, components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); const keepTicking = System.create({}, () => {}); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index 826c9bfb..ab436f71 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -51,7 +51,7 @@ const weighted = Index.create({ key: [], components: { has: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } }, }, (row, { lookup }) => { @@ -73,7 +73,7 @@ const neighbors = System.create({ /** Counts runs whose list of the row's own cell changed. */ const watch = System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [changes] }, lookup: { near: { index: grid, changed: [weight] } }, after: [neighbors], @@ -84,7 +84,7 @@ const watch = System.create({ /** How many rows of the row's lane have a rank within 5 of its own, and the first of them. */ const window = System.create({ - kernel: "cpu", + kernel: true, components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } }, after: [watch], @@ -97,7 +97,7 @@ const window = System.create({ /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [total] }, lookup: { bySerial: { index: serials, read: [weight] } }, after: [window], @@ -108,7 +108,7 @@ const follow = System.create({ /** Through an index without a key: how many entities it holds, a stride of their weights, and whether it changed. */ const census = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [tally] }, lookup: { weighted: { index: weighted, read: [weight], changed: [weight] } }, after: [follow], @@ -140,7 +140,8 @@ const stir = System.create({ components: { write: [cell, weight] } }, (row, _con const rerank = System.create({ components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; }); -const churn = System.create({ components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { +// As written: spawned on workers, entities would take ids in an order that depends on the pool, which the census samples by. +const churn = System.create({ kernel: false, components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); if (row[serial] % 101 === input.round) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); @@ -191,7 +192,7 @@ const bands = Index.create({ key: [band], components: { has: [serial] } }); /** Each row's band's serials, each times its position plus one: a sum that tells the order. */ const bandOrder = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [bandSum] }, lookup: { inBand: { index: bands, read: [serial] } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index 592aa624..e3ea4b7a 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -324,7 +324,7 @@ const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity /** A kernel folding the entities of its row's own cell's list, so kernels' lookups are checked against the same index. */ const foldNear = System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [folded] }, lookup: { near: { index: grid } }, }, (row, { lookup }) => { @@ -564,7 +564,7 @@ const rerank = System.create({ /** A kernel folding the entities of its row's lane's range of ranks within 50 of its own, so kernels' sorted lookups are checked against the same index. */ const foldLane = System.create({ - kernel: "cpu", + kernel: true, components: { read: [lane, rank], write: [folded] }, lookup: { lanes: { index: byLaneRank } }, }, (row, { lookup }) => { diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts index 4b4cfa67..891c77d1 100644 --- a/packages/ecs/src/implementation/world/kernel-imports.test.ts +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -23,14 +23,14 @@ const spawn = System.create({}, ({ commands }) => { commands.spawn([position, velocity, target])({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [target]: leader }); }); -// `kernel: "cpu"` compiles the body at build time, to run in chunks across workers as WASM where it can. -const integrate = System.create({ kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +// `kernel: true` compiles the body at build time, to run in chunks across workers as WASM where it can. +const integrate = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); // Kernels use lookups, imported pure functions, commands and imported lists too. const chase = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position, target], write: [velocity] }, lookup: { targets: { read: [position] } }, }, (row, { lookup, commands }) => { @@ -58,7 +58,7 @@ it("runs systems compiled as kernels", async () => { const ignite = System.create({}, ({ commands }) => { commands.spawn(sparks)({ [position]: [0, 0, 0] }); }); -const settle = System.create({ kernel: "cpu", components: { has: [spark] } }, (row, { commands }) => { +const settle = System.create({ kernel: true, components: { has: [spark] } }, (row, { commands }) => { commands.remove(row.entity, spark); }); diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts index 40de366c..c042ee3f 100644 --- a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -17,7 +17,7 @@ declare module "@adobe/ecs" { } export const labels = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [offset] }, // @ts-expect-error kernel lookups read linear values lookup: { named: { read: [label] } }, diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts index e97f501c..c52dbb8b 100644 --- a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -12,7 +12,7 @@ declare module "@adobe/ecs" { } } -export const beyond = System.create({ kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { +export const beyond = System.create({ kernel: true, components: { read: [pair], write: [sum] } }, row => { // @ts-expect-error a pair has no third item row[sum] = row[pair][2]; }); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index e87525d6..a104bc94 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -38,7 +38,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** One spawn per row, from a kernel. */ -const fromKernel = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const fromKernel = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { commands.spawn(spawned)({ [value]: row[index] }); }); @@ -48,14 +48,14 @@ const fromMain = System.create({ before: [fromKernel] }, ({ commands }) => { }); /** Spawns, then adds to and removes from what it spawned, in the same kernel and tick. */ -const spawnAndEdit = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const spawnAndEdit = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { const entity = commands.spawn([value, flag, made])({ [value]: row[index] }); commands.add(entity, extra, row[index] * 10); commands.remove(entity, flag); }); /** A spawn with a tuple, and an object component left to its start. */ -const withObjects = System.create({ kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const withObjects = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { commands.spawn([value, position, name, made])({ [value]: row[index], [position]: [row[index], -row[index]] }); }); diff --git a/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts new file mode 100644 index 00000000..b2583e12 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from '../../interface/entity/entity.js'; +import { System } from '../../interface/system/system.js'; +import type { F32 } from '../../interface/math/index.js'; + +/** Systems without a kernel flag: one that compiles, and one whose lookup reads a string, which runs as written. */ + +const target = Symbol("kernelTriedTest/target"); +const label = Symbol("kernelTriedTest/label"); +const offset = Symbol("kernelTriedTest/offset"); +declare module "@adobe/ecs" { + export interface Components { + [target]: { type: Entity }; + [label]: { type: string }; + [offset]: { type: F32 }; + } +} + +export const doubled = System.create({ components: { write: [offset] } }, row => { + row[offset] = row[offset] * 2; +}); + +export const labels = System.create({ + components: { read: [target], write: [offset] }, + lookup: { named: { read: [label] } }, +}, (row, { lookup: { named } }) => { + const found = named(row[target]); + row[offset] = found === null ? 0 : found[label].length; +}); + +export { label, offset, target }; diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts index b21fe7cf..12e2e277 100644 --- a/packages/ecs/src/implementation/world/kernels-csp.test.ts +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -19,7 +19,7 @@ const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const double = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] * 2; }); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts index 2f7f5dad..e78a93fd 100644 --- a/packages/ecs/src/implementation/world/kernels.test.ts +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { System } from "../../interface/system/system.js"; import { World } from "../../interface/world/world.js"; import { definitionOf } from "../../interface/registry/index.js"; -import { attachKernel } from "./attach-kernel.js"; +import { attachKernel, kernelOf } from "./attach-kernel.js"; import { bindKernel } from "./kernels.js"; import type { KernelDescription } from "./kernel.js"; import { createState } from "./state.js"; @@ -26,12 +26,12 @@ declare module "@adobe/ecs" { const label = Symbol("kernelsTest/label"); /** Reads `value` and `other`, writes nothing, reads no resources. */ -const system = System.create({ kernel: "cpu", components: { read: [value, other] } }, row => { +const system = System.create({ kernel: true, components: { read: [value, other] } }, row => { if (row[value] > row[other]) return; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); -const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const double = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] * 2; }); @@ -67,6 +67,15 @@ describe("attachKernel", () => { expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [0] }))).not.toThrow(); }); + it("attaches nothing, and throws nothing, for a kernel the build tried that doesn't match", () => { + const tried = System.create({ components: { read: [value, other] } }, row => { + if (row[value] > row[other]) return; + }); + expect(attachKernel(tried, describing({ keys: [value, label], structuredValues: [1] }), true)).toBe(tried); + expect(kernelOf(tried)).toBeUndefined(); + expect(attachKernel(tried, describing({}), true)).toBe(tried); + expect(kernelOf(tried)).toBeDefined(); + }); it("rejects at registration a spawn site naming a component outside its keys", () => { expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('spawns a list naming component 1 of its 1 keys.'); }); diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index 04ce3380..11c4ce7b 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -46,7 +46,7 @@ const fill = System.create({}, ({ commands }, input: { readonly count: number }) for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); @@ -76,7 +76,7 @@ const despawnValues = System.create({ components: { has: [value], not: [child] } }); /** Lookups from workers. */ const look = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [seen] }, lookup: { targets: { read: [value] } }, }, (row, { lookup: { targets } }) => { @@ -84,7 +84,7 @@ const look = System.create({ row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { read: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); @@ -119,7 +119,8 @@ const totals = async (world: World): Promise<{ sum: number; count: number }> => describe("the layout directory", () => { for (const wasm of [true, false]) { it(`lets workers' ${wasm ? "WASM" : "JS"} kernels see every row of 100 archetypes grown across sync points, with no layout message after the one creating them`, async () => { - const world = createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm }); + // Pieces of two rows, so the first round's single rows start the pool and its layout message is before the count. + const world = createWorld({ [workers]: 3 }, { minimumChunk: 2, wasm }); disposals.push(world); await world.run(fill, { count: 1 }); await world.run(bump); @@ -166,7 +167,7 @@ describe("the layout directory", () => { for (const wasm of [true, false]) { it(`lets workers' ${wasm ? "WASM" : "JS"} kernels rebuild their views once memory grows between runs`, async () => { - const world = createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm }); + const world = createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm }); disposals.push(world); await world.run(fill, { count: 1 }); await world.run(bump); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index e80572f9..e2986993 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -48,7 +48,7 @@ const fill = System.create({}, ({ commands }, input: { readonly count: number }) for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); @@ -78,7 +78,7 @@ const despawnValues = System.create({ components: { has: [value], not: [child] } }); /** Lookups from workers. */ const look = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [seen] }, lookup: { targets: { read: [value] } }, }, (row, { lookup: { targets } }) => { @@ -86,7 +86,7 @@ const look = System.create({ row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { read: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); @@ -112,7 +112,8 @@ export const rounds: readonly number[] = [16, 32, 64, 128, 256]; /** 100 archetypes grown over `rounds`, each round bumped by workers: layout messages after the first round, and the totals. */ export const grown = async (wasm: boolean): Promise<{ readonly messages: number; readonly totals: { sum: number; count: number } }> => { const counting = countingWorkers(); - return using(createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + // Pieces of two rows, so the first round's single rows start the pool and its layout message is before the count. + return using(createWorld({ [workers]: 3 }, { minimumChunk: 2, wasm, pool: counting.platform }), async world => { await world.run(fill, { count: 1 }); await world.run(bump); const before = counting.posted("layout"); @@ -153,7 +154,7 @@ export const tableGrown = async (): Promise<{ readonly messages: number; readonl /** Workers' kernels once memory grows between runs: how often it grew, layout messages meanwhile, and the totals. */ export const memoryGrown = async (wasm: boolean): Promise<{ readonly grows: number; readonly messages: number; readonly totals: { sum: number; count: number } }> => { const counting = countingWorkers(); - return using(createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + return using(createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm, pool: counting.platform }), async world => { await world.run(fill, { count: 1 }); await world.run(bump); let grows = 0; diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 6f8b9db6..3cdba090 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -37,7 +37,7 @@ const seed = System.create({}, ({ commands }) => { /** A kernel that holds what kernels can: a lookup's directory, reduction partials and its load on each worker. */ const measure = System.create({ - kernel: "cpu", + kernel: true, components: { read: [value, target], write: [out] }, lookup: { targets: { read: [value] } }, resources: { reduce: [total] }, diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index c73e4f7d..9cd1b417 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -87,7 +87,7 @@ const weigh = System.create({ components: { write: [weight], not: [filler] } }, row[weight] += 1; }); const warm = System.create({ components: { write: [heat] } }, row => { row[heat] += 1; }); -const double = System.create({ kernel: "cpu", components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); +const double = System.create({ kernel: true, components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); const addTag = System.create({}, ({ commands }) => { commands.add(target, tag, true); }); const removeTag = System.create({}, ({ commands }) => { commands.remove(target, tag); }); const removeWeight = System.create({}, ({ commands }) => { commands.remove(target, weight); }); @@ -123,7 +123,7 @@ const leave = System.create({}, ({ commands }) => { commands.despawn(roster[1]); const score10 = System.create({ components: { write: [score] } }, row => { row[score] += 10; }); const nudge = System.create({ components: { write: [score] } }, row => { row[score] += 0; }); const invert = System.create({ components: { write: [score] } }, row => { row[score] = -row[score]; }); -const invertKernel = System.create({ kernel: "cpu", components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); +const invertKernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); /** Swaps the bench like `swap`, then despawns more entities than the journal holds, so it drops the swap's entries. */ const swapAndCrowd = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 316bc76f..9858098f 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -43,10 +43,10 @@ const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, watched])({ [value]: i, [watched]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); -const watchChanged = System.create({ kernel: "cpu", components: { write: [watched], changed: [value] }, after: [bump] }, row => { +const watchChanged = System.create({ kernel: true, components: { write: [watched], changed: [value] }, after: [bump] }, row => { row[watched] = row[watched] + 1; }); /** Counts rows whose `value` was added since it last ran. */ diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts index 646099d0..46dac09d 100644 --- a/packages/ecs/src/implementation/world/observed-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -23,11 +23,11 @@ const seed = System.create({}, ({ commands }) => { }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); -const watch = System.create({ kernel: "cpu", components: { write: [seen], changed: [value] }, after: [bump] }, row => { +const watch = System.create({ kernel: true, components: { write: [seen], changed: [value] }, after: [bump] }, row => { row[seen] = row[seen] + 1; }); diff --git a/packages/ecs/src/implementation/world/pool-size.cases.ts b/packages/ecs/src/implementation/world/pool-size.cases.ts index 6de8781b..bdb52dda 100644 --- a/packages/ecs/src/implementation/world/pool-size.cases.ts +++ b/packages/ecs/src/implementation/world/pool-size.cases.ts @@ -14,6 +14,12 @@ export const poolSizeCases = (load: () => Promise) => describe(" const fixture = await load(); expect((await fixture.resized(1, [null])).started).toEqual([1, 1 + fixture.platformCount()]); }); + it("starts the pool only once a kernel has rows enough for workers", async () => { + const fixture = await load(); + const { started, values } = await fixture.whenWarranted(); + expect(started).toEqual([0, fixture.platformCount()]); + expect(values).toEqual([0, 4, 8, 12]); + }); it("sizes the kernel pool at the next sync point, with the same results", async () => { const fixture = await load(); const { started, values } = await fixture.resized(2, [3, 0]); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index 56a8de59..b32604cc 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -19,7 +19,10 @@ declare module "@adobe/ecs" { const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); +const few = System.create({}, ({ commands }) => { + for (let i = 0; i < 16; i++) commands.spawn([value])({ [value]: i }); +}); +const double = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] * 2; }); const resize = System.create({ resources: { write: [workers] } }, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); @@ -64,3 +67,24 @@ export const resized = async (initial: number, sizes: readonly (number | null)[] await world.dispose(); } }; + +/** + * Workers started after a kernel runs over fewer rows than a piece for a worker, then after it runs over enough, and + * the first values it doubled. + */ +export const whenWarranted = async (): Promise<{ readonly started: readonly number[]; readonly values: readonly (number | null)[] }> => { + const counting = countingWorkers(); + const world = createWorld({}, { minimumChunk: 64, pool: counting.platform }); + try { + await world.run(few); + await world.run(double); + const started = [counting.started()]; + await world.run(seed); + await world.run(double); + started.push(counting.started()); + return { started, values: (await readEach(world, await select(world, { required: [value] }), value)).slice(0, 4) }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts index 6e9d9f8e..4bab5430 100644 --- a/packages/ecs/src/implementation/world/ports.fixture.ts +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -22,7 +22,7 @@ const seed = System.create({}, ({ commands }) => { }); /** Long enough per row that a task over 100,000 rows is still running when its worker is killed. */ -const heavy = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +const heavy = System.create({ kernel: true, components: { write: [value] } }, row => { let total = row[value]; for (let k = 0; k < 2000; k++) total = total * 0.5 + k; row[value] = total; diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index 5416d19b..e19465b2 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -36,7 +36,7 @@ const spawn = System.create({}, ({ commands }) => { } }); -const reduce = System.create({ kernel: "cpu", components: { read: [weight] }, resources: { reduce: [count, sum, largest] } }, (row, { reduce }) => { +const reduce = System.create({ kernel: true, components: { read: [weight] }, resources: { reduce: [count, sum, largest] } }, (row, { reduce }) => { reduce[count](1); reduce[sum](row[weight]); reduce[largest](row[weight]); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index ef0bd6d0..2f3729f9 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -55,7 +55,7 @@ const spawn = System.create({}, ({ commands }) => { } }); -const kernel = System.create({ kernel: "cpu", components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { +const kernel = System.create({ kernel: true, components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -74,17 +74,17 @@ const body = System.create({ components: { read: [position, mass] }, resources: reduce[least](row[mass]); }); -const filtered = System.create({ kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { changed: [level], reduce: [total, least] } }, (row, { reduce }) => { +const filtered = System.create({ kernel: true, components: { read: [mass], has: [heavy] }, resources: { changed: [level], reduce: [total, least] } }, (row, { reduce }) => { reduce[total](row[mass]); reduce[least](row[mass]); }); -const flags = System.create({ kernel: "cpu", components: { read: [mass] }, resources: { reduce: [any] } }, (row, { reduce }) => { +const flags = System.create({ kernel: true, components: { read: [mass] }, resources: { reduce: [any] } }, (row, { reduce }) => { reduce[any](row[mass] > 4000); }); /** Two contributors to `total`, the heavy rows and the light ones, and a reader of the finished fold. */ -const heavyMass = System.create({ kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { +const heavyMass = System.create({ kernel: true, components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); }); const lightMass = System.create({ components: { read: [mass], not: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { @@ -96,7 +96,7 @@ const reader = System.create({ resources: { read: [total], write: [seen] } }, ({ const frame = System.create({ systems: [reader, heavyMass, lightMass] }); const weigh = System.create({ components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); -const kernelChanged = System.create({ kernel: "cpu", components: { read: [mass], changed: [mass] }, resources: { reduce: [total] } }, (row, { reduce }) => { +const kernelChanged = System.create({ kernel: true, components: { read: [mass], changed: [mass] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[mass]); }); @@ -249,7 +249,7 @@ describe("reductions", () => { const state = createState(true); const registrar = createRegistrar(state); // A declaration the build doesn't see is a kernel it didn't compile, which only a world's registration finds. - const uncompiled = { name: "reductionsTest/other/6", kernel: "cpu", components: { read: [mass] } } as const; + const uncompiled = { name: "reductionsTest/other/6", kernel: true, components: { read: [mass] } } as const; const both = System.create({ systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); const definition = (system: System) => { const found = definitionOf(system); diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 4639d0ee..62937c27 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -56,10 +56,10 @@ const spawnCaptioned = System.create({}, ({ commands }) => { commands.spawn([point, caption])({ [point]: 1, [caption]: { text: "a caption only the thread gives" } }); }); /** A kernel giving the thread's objects null, which they take, and a number, which they don't. */ -const nullCaptionsKernel = System.create({ kernel: "cpu", components: { has: [caption], read: [point] } }, (row, { commands }) => { +const nullCaptionsKernel = System.create({ kernel: true, components: { has: [caption], read: [point] } }, (row, { commands }) => { if (row[point] > 0) commands.add(row.entity, caption, null); }); -const numberHandlesKernel = System.create({ kernel: "cpu", components: { read: [point] } }, (row, { commands }) => { +const numberHandlesKernel = System.create({ kernel: true, components: { read: [point] } }, (row, { commands }) => { commands.add(row.entity, handle, row[point]); }); /** Reaches its commands, so it isn't terminal: its tier blocks rather than running as a tail. */ diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index cbe476b6..58c7bbd2 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -201,12 +201,12 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { /** Its `lastRun` while it runs, which its lookups' lists and rows compare with. */ const lookupSince: Since = { value: since }; let kernel: BoundKernel | null = null; - if (declaration.kernel !== undefined) { - const description = kernelOf(leaf); - if (description === undefined) { - indexLookups.release(); - throw new Error(`System "${label}" is a kernel but wasn't compiled; add Compiler.plugin() to the Vite config.`); - } + const description = kernelOf(leaf); + if (description === undefined && (declaration.kernel === true || declaration.kernel === "gpu")) { + indexLookups.release(); + throw new Error(`System "${label}" is a kernel but wasn't compiled; add Compiler.plugin() to the Vite config.`); + } + if (description !== undefined) { try { kernel = bindKernel(state, leaf, description, indexLookups.byName, lookupSince); } diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts index f479ea6d..d5708cbb 100644 --- a/packages/ecs/src/implementation/world/semantics.test.ts +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -30,7 +30,7 @@ const seed = System.create({}, ({ commands }) => { commands.spawn([position, target, score])({ [position]: [7, 8], [target]: Number.NaN, [score]: 0 }); }); const chase = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [score] }, lookup: { targets: { read: [position] } }, }, (row, { lookup: { targets } }) => { diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 94f1a69d..1931267e 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -6,6 +6,7 @@ import { layoutOf, type Layout } from '../../interface/registry/layout.js'; import { accessOf, type Schedule } from '../../interface/registry/schedule.js'; import { stampChanged, type Archetype } from './archetype.js'; import { readCell } from './cells.js'; +import { kernelOf } from './attach-kernel.js'; import { indexOf, locate } from './entities.js'; import { failureOf } from './failures.js'; import { ownTag } from './move-log.js'; @@ -38,7 +39,7 @@ export const isTerminal = (leaf: Leaf): boolean => { const known = terminalLeaves.get(leaf); if (known !== undefined) return known; const { declaration } = leaf; - const terminal = leaf.kind === "system" && declaration.kernel === undefined && declaration.up === undefined && provenTerminal(leaf.body) + const terminal = leaf.kind === "system" && kernelOf(leaf) === undefined && declaration.up === undefined && provenTerminal(leaf.body) && Object.keys(declaration.lookup ?? {}).length === 0 && (declaration.resources?.reduce?.length ?? 0) === 0 && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts index 643ca8e8..35b2a968 100644 --- a/packages/ecs/src/implementation/world/up-tree.test.ts +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -25,14 +25,14 @@ declare module "@adobe/ecs" { const propagateFull = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [full], not: [hidden] }, up: { through: parent, read: [full] }, }, (row, { up }) => { row[full] = (up === null ? 0 : up[full]) + row[local]; }); const propagateFiltered = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [filtered], changed: [local], not: [hidden] }, up: { through: parent, read: [filtered] }, }, (row, { up }) => { diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts index f76d5f44..670ab8d6 100644 --- a/packages/ecs/src/implementation/world/up-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { const propagate = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, }, (row, { up }) => { diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index e5c98702..22301dcf 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -54,14 +54,14 @@ const propagateAdded = System.create({ row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChangedKernel = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateKernel = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -99,7 +99,7 @@ const worlds = async (value: (entity: Entity) => Promise): Promis Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); const show = System.create({ - kernel: "cpu", + kernel: true, components: { read: [visible], write: [shown] }, up: { through: parent, read: [shown] }, }, (row, { up }) => { diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 61765631..410e7d31 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -60,7 +60,7 @@ const cull = System.create({ components: { read: [position], has: [bounces] } }, const halve = (value: number) => value * 0.5; const integrate = System.create({ - kernel: "cpu", + kernel: true, components: { write: [position, velocity] }, resources: { read: [seconds] }, }, (row, { resources }) => { @@ -72,7 +72,7 @@ const integrate = System.create({ }); const bounce = System.create({ - kernel: "cpu", + kernel: true, components: { write: [position, velocity, bounces, flipped] }, resources: { read: [floor] }, after: [integrate], @@ -91,7 +91,7 @@ const bounce = System.create({ }); const tally = System.create({ - kernel: "cpu", + kernel: true, components: { read: [bounces, flipped, position], write: [score] }, after: [bounce], }, row => { @@ -106,7 +106,7 @@ const tally = System.create({ }); const scale = System.create({ - kernel: "cpu", + kernel: true, components: { write: [score] }, }, (row, _context, input: { readonly factor: number }) => { row[score] = row[score] * input.factor; @@ -114,7 +114,7 @@ const scale = System.create({ /** Component-wise vector math: one f32x4 operation per row. */ const drift = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position] }, resources: { read: [seconds] }, after: [tally], @@ -127,7 +127,7 @@ const drift = System.create({ /** Runs only for rows whose bounces changed since it last ran. */ const react = System.create({ - kernel: "cpu", + kernel: true, components: { write: [score], changed: [bounces] }, after: [tally], }, row => { @@ -136,7 +136,7 @@ const react = System.create({ /** A lookup per row: found targets pull, missing or stale ones cost a point. */ const chase = System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [score] }, lookup: { targets: { read: [position, flipped] } }, after: [react, drift], @@ -176,7 +176,7 @@ const simulate = async (wasm: boolean, simd = false): Promise => { }; /** Counts the runs of rows whose score changed. */ -const recount = System.create({ kernel: "cpu", components: { write: [bounces], changed: [score] } }, row => { +const recount = System.create({ kernel: true, components: { write: [bounces], changed: [score] } }, row => { row[bounces] += 1; }); const chosen = new Set(); @@ -186,7 +186,7 @@ const rescore = System.create({ components: { write: [score] } }, row => { /** Keeps ticks of `score` without observing its changes, so kernels writing it mute their stamps. */ const scoreAdded = System.create({ components: { added: [score] } }, () => {}); -const raise = System.create({ kernel: "cpu", components: { write: [score] } }, row => { +const raise = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] += 1; }); let raised = 0; diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index 8a76370a..284196bb 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -73,7 +73,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); const integrate = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position] }, resources: { read: [seconds] }, }, (row, { resources }) => { @@ -84,7 +84,7 @@ const integrate = System.create({ /** Structural commands from workers: rows past a threshold spawn a marker and lose their velocity. */ const split = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position], has: [velocity] }, after: [integrate], }, (row, { commands }) => { @@ -97,7 +97,7 @@ const split = System.create({ /** Lookups from workers: offset by the distance to the target. */ const follow = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position, target], write: [offset] }, lookup: { targets: { read: [position] } }, after: [split], @@ -111,7 +111,7 @@ const main = System.create({ systems: [integrate, split, follow] }); /** Every row writes the same entity, so only merge order decides which value survives. */ const stamp = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position] }, }, (row, { commands }, input: { readonly entity: Entity }) => { const [x, y] = row[position]; @@ -120,7 +120,7 @@ const stamp = System.create({ /** Despawns entity 1, the resource entity, since index 0 is never an entity and registration reserves the next first. */ const unlink = System.create({ - kernel: "cpu", + kernel: true, components: { has: [target] }, }, (_, { commands }) => { // @ts-expect-error an untyped caller can pass any number @@ -136,7 +136,7 @@ const sources = System.create({}, ({ commands }) => { /** A spawn per row, from wherever its chunk runs; with `edit`, each spawn is also added to and removed from. */ const emit = System.create({ - kernel: "cpu", + kernel: true, components: { read: [offset], not: [spawned] }, }, (row, { commands }, input: { readonly edit: boolean }) => { const entity = commands.spawn([position, spawned, tagC])({ [position]: [row[offset], -row[offset]] }); @@ -229,8 +229,9 @@ export const unlinked = (): Promise => using(createWorld({ [workers]: 2 /** Workers ended by `dispose`, and what a run after it rejects with. */ export const disposed = async (): Promise<{ readonly started: number; readonly terminated: number; readonly after: string }> => { const counting = countingWorkers(); - const world = createWorld({ [workers]: 2 }, { pool: counting.platform }); - // Workers start with the first kernel. + const world = createWorld({ [workers]: 2 }, { minimumChunk: 64, pool: counting.platform }); + // Workers start with the first kernel run over rows enough for them. + await world.run(seed, { count: 64 }); await world.run(main); await world.dispose(); const after = await world.run(main).then(() => "resolved", (error: unknown) => String(error)); diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index e9c3606c..6e5c4874 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -106,7 +106,7 @@ describe("systems and finalizers looking up through an index", () => { expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, changed: [name] } } })).toThrow(`lookup "near" watches "indexCreateTest/name"`); expect(() => untypedSystem({ name: "bad", components: { write: [cell] }, lookup: { near: { index: grid } } })).toThrow(`goes through an index keyed by "indexCreateTest/cell", which the system writes`); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, lookup: { near: { index: grid, read: [amount] } } })).toThrow(`reads "indexCreateTest/amount", which the system writes`); - expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`lookup "near" reads`); expect(grid.name).toBe("grid"); }); @@ -131,7 +131,7 @@ describe("systems ordering their rows by an index", () => { expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, up: { through: parent }, order: ranked })).toThrow("uses both up and order"); expect(() => untypedSystem({ name: "bad", kernel: "gpu", components: { write: [cell] }, order: ranked })).toThrow("GPU kernels have no order yet"); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, order: ranked })).toThrow(`its rows are ordered by an index keyed by "indexCreateTest/amount", which the system writes`); - expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); }); it("throw for a render system's order by what isn't a sorted index", () => { const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [{ name: "bad", order, shader: { accessors: { amount } } }, () => {}]); diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index 58a36f2c..c4916ccf 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -144,7 +144,7 @@ export const census = System.create({ void lookup.all().at(0)[health]; }); System.create({ - kernel: "cpu", + kernel: true, components: { write: [velocity] }, lookup: { living: { index: living, read: [health] } }, }, () => {}); @@ -188,19 +188,19 @@ System.create({ }, () => {}); System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [velocity] }, lookup: { near: { index: grid, read: [position] } }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [velocity] }, lookup: { near: { index: grid, changed: [position] } }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, components: { write: [velocity] }, // @ts-expect-error kernels look up only through indexes of struct keys lookup: { names: { index: named } }, @@ -217,7 +217,7 @@ System.create({ System.create({ components: { read: [start], write: [playhead] }, order: clips }, row => { row[playhead] = row[start]; }); -System.create({ kernel: "cpu", components: { read: [start], write: [playhead] }, order: clips }, row => { +System.create({ kernel: true, components: { read: [start], write: [playhead] }, order: clips }, row => { row[playhead] = row[start]; }); // A sorted index typed by its declaration is one of `Index<{ readonly sorted: true }>`. @@ -248,7 +248,7 @@ System.create({ order: clips, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, components: { write: [velocity] }, // @ts-expect-error kernels are ordered only by indexes of struct keys order: named, @@ -265,7 +265,7 @@ System.create({ components: { read: [cell] }, lookup: { near: { index: grid, rea void lookup.near(row[cell]).at(0)[position]; }); System.create({ - kernel: "cpu", + kernel: true, components: { read: [cell], write: [velocity] }, lookup: { near: { index: grid, changed: true } }, }, () => {}); diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts index ed107284..11936bf6 100644 --- a/packages/ecs/src/interface/system/check.ts +++ b/packages/ecs/src/interface/system/check.ts @@ -66,6 +66,30 @@ const unreadByKernels = (key: symbol): string | null => { return kind === "object" ? "which isn't stored in linear memory" : kind === "string" ? "a string, which kernels don't read yet" : null; }; +/** + * What of a leaf's ordering index and lookups a kernel can't read, or null: workers read them from shared memory, so + * their values must be linear there too. + */ +export const unreadByKernel = (leaf: Leaf): string | null => { + const { lookup, order } = leaf.declaration; + if (order !== undefined) { + for (const key of orderKeysOf(leaf.declaration)) { + const unread = unreadByKernels(key); + if (unread !== null) return `its rows are ordered by an index keyed by "${labelOf(key)}", ${unread}`; + } + } + const lookups = Object.values(lookup ?? {}); + for (const key of lookups.flatMap(found => found.read ?? [])) { + const unread = unreadByKernels(key); + if (unread !== null) return `a lookup reads "${labelOf(key)}", ${unread}`; + } + for (const key of lookups.flatMap(lookupKeysOf)) { + const unread = unreadByKernels(key); + if (unread !== null) return `a lookup goes through an index keyed by "${labelOf(key)}", ${unread}`; + } + return null; +}; + /** * Throws for what a leaf's kind and placement rule out: `up` without components or through a key that isn't a reference * component, `order` by anything but a sorted index, without components, with `up` or on the GPU, a kernel's lookup of @@ -83,12 +107,6 @@ const checkPlacement = (leaf: Leaf): void => { if (components === undefined) fail("orders its rows without components, so it runs once"); if (up !== undefined) fail("uses both up and order, which both decide the order of its rows"); if (kernel === "gpu") fail("can't run on the GPU: GPU kernels have no order yet"); - if (kernel !== undefined) { - for (const key of orderKeysOf(leaf.declaration)) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", ${unread}.`); - } - } } if (up !== undefined) { if (components === undefined) fail("uses up, which orders rows, without components"); @@ -97,16 +115,9 @@ const checkPlacement = (leaf: Leaf): void => { } } const lookups = Object.values(lookup ?? {}); - if (kernel !== undefined) { - // Workers read lookups from shared memory, so their values must be linear there too. - for (const key of lookups.flatMap(found => found.read ?? [])) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": a lookup reads "${labelOf(key)}", ${unread}.`); - } - for (const key of lookups.flatMap(lookupKeysOf)) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": a lookup goes through an index keyed by "${labelOf(key)}", ${unread}.`); - } + if (kernel === true || kernel === "gpu") { + const unread = unreadByKernel(leaf); + if (unread !== null) throw new Error(`Kernel "${name}": ${unread}.`); } const reduces = reducedBy(leaf).length > 0; if (kernel === "gpu") { @@ -129,7 +140,7 @@ const checkObserver = (leaf: Leaf): void => { const fail = (message: string): never => { throw new Error(`System "${name}" is an observer, so it ${message}.`); }; if (observer !== true) throw new Error(`System "${name}": observer is true or left out.`); if (leaf.kind === "render") fail("records no GPU work"); - if (declaration.kernel !== undefined) fail("isn't a kernel"); + if (declaration.kernel === true || declaration.kernel === "gpu") fail("isn't a kernel"); if ((declaration.components?.write?.length ?? 0) + (declaration.resources?.write?.length ?? 0) > 0) fail("writes nothing"); if (reducedBy(leaf).length > 0) fail("reduces into nothing"); if (leaf.input !== undefined) fail("takes no input"); @@ -190,7 +201,7 @@ const isInputSchema = (value: unknown): value is InputSchema => */ export const inputSchemaOf = (name: string, body: (...parameters: never[]) => void, before: number, input: unknown): InputSchema | undefined => { if (input === undefined) { - if (body.length > before) throw new Error(`System "${name}" takes input, whose schema the plugin gives from the type of its body's parameter: write its body in place and build it with the kernel plugin.`); + if (body.length > before) throw new Error(`System "${name}" takes input, whose schema the plugin gives from the type of its body's parameter: write its body in place and build it with Compiler.plugin().`); return undefined; } if (!isInputSchema(input)) throw new Error(`System "${name}": input is an object schema of named fields.`); @@ -209,7 +220,7 @@ const checkTarget = (leaf: Leaf): void => { const field: Schema | undefined = input !== undefined && "properties" in input ? Reflect.get(input.properties, target) : undefined; if (field === undefined || !("format" in field) || field.format !== "entity") fail(`takes it from an input field "${target}" holding an entity`); if (components === undefined) fail("needs components"); - if (kernel !== undefined) fail("isn't a kernel"); + if (kernel === true || kernel === "gpu") fail("isn't a kernel"); if (up !== undefined || order !== undefined) fail("has no up or order"); if ((resources?.reduce?.length ?? 0) > 0) fail("reduces into nothing"); }; diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts index 14d23b0c..b8e19029 100644 --- a/packages/ecs/src/interface/system/conditions.type-test.ts +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -59,5 +59,5 @@ System.create({ resources: { is: [level], read: [level] } }, () => {}); System.create({ resources: { is: [level], write: [level] } }, () => {}); // Kernels and render systems take conditions too. -System.create({ kernel: "cpu", components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); +System.create({ kernel: true, components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); System.render({ resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 3d49933b..0df4b250 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -107,7 +107,7 @@ describe("System.create", () => { [{ observer: true, components: { write: [position] } }, "is an observer, so it writes nothing"], [{ observer: true, resources: { write: [time] } }, "is an observer, so it writes nothing"], [{ observer: true, components: { has: [position] }, resources: { reduce: [total] } }, "is an observer, so it reduces into nothing"], - [{ observer: true, kernel: "cpu", components: { read: [position] } }, "is an observer, so it isn't a kernel"], + [{ observer: true, kernel: true, components: { read: [position] } }, "is an observer, so it isn't a kernel"], [{ observer: true, components: { read: [position] }, up: { through: parent } }, "is an observer, so it has no up"], [{ observer: true, resources: { read: [time] }, after: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], [{ observer: true, resources: { read: [time] }, before: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 2a59a49e..e4a0722e 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -81,11 +81,11 @@ type GpuKernel = { }; /** - * An observer system only reads, after a sync point: it writes and reduces into nothing, isn't a kernel, has no `up`, + * An observer system only reads, after a sync point: it writes and reduces into nothing, never compiles to a kernel, has no `up`, * and is ordered with nothing. That it takes no input is checked at its body. */ type Observer = { - readonly kernel?: never; + readonly kernel?: false; readonly components?: { readonly write?: never }; readonly resources?: { readonly write?: never; readonly reduce?: never }; readonly up?: never; @@ -96,7 +96,7 @@ type Observer = { /** A system on one entity runs on its row, in no order; that its target is an entity field of its input is checked at its body. */ type Targeted = { - readonly kernel?: never; + readonly kernel?: false; readonly up?: never; readonly order?: never; readonly resources?: { readonly reduce?: never }; @@ -106,10 +106,14 @@ type Validate = Lists & IndexLookups & Order - & (D extends { readonly kernel: "cpu" | "gpu" } ? Kernel : {}) + & (D extends { readonly kernel: true | "gpu" } ? Kernel : {}) & (D extends { readonly kernel: "gpu" } ? GpuKernel : {}) // `up` and `order` order rows, so a system without them has neither, and one has at most one of them. - & (Property extends undefined ? { readonly up?: { readonly [key: string]: never }; readonly order?: { readonly "error: a system without components runs once, in no order": true } } : {}) + & (Property extends undefined ? { + readonly up?: { readonly [key: string]: never }; + readonly order?: { readonly "error: a system without components runs once, in no order": true }; + readonly kernel?: false | "gpu" | { readonly "error: a system without components runs once, so there's nothing to compile": true }; + } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) & (D extends { readonly observer: true } ? Observer : {}) & (D extends { readonly target: string } ? Targeted : {}) diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 624e41c3..5548b1ff 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -12,13 +12,17 @@ import type { System } from './system.js'; */ export type Declaration = { /** - * Compiled to a kernel, which the build rejects if the body leaves the kernel subset. Values in `components` and - * `lookup` must be structs and resources read-only. Without it, the system runs as written, on its thread. - * - `"cpu"`: runs on the kernel pool. - `"gpu"`: also has no lookups, change filters, commands or loops, and only - * 4-byte scalar values, its `up` included. It runs as a compute shader in a world with a GPU, and as a `"cpu"` - * kernel in one without. Every matched row counts as changed in what it writes. + * Whether its body compiles to a kernel, a function the build compiles to run on every matched row, in parallel, on + * the kernel pool. Values in `components` and `lookup` must be structs and resources read-only. + * - Without it, a system with components compiles when its body is in the kernel subset and its declaration allows + * it, and otherwise runs as written. A system without components runs once, as written. + * - `true`: compiles, or the build fails saying why. + * - `false`: runs as written, on its thread. + * - `"gpu"`: also has no lookups, change filters, commands or loops, and only 4-byte scalar values, its `up` + * included. It runs as a compute shader in a world with a GPU, and as a kernel on the pool in one without. Every + * matched row counts as changed in what it writes. */ - readonly kernel?: "cpu" | "gpu"; + readonly kernel?: boolean | "gpu"; /** The entities it runs over, and what it reads and writes of theirs. Omitted for a system that runs once per run. */ readonly components?: ComponentAccess; readonly lookup?: { readonly [name: string]: SystemLookup }; diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 41ed5989..7abd07e0 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -189,31 +189,32 @@ declare module '@adobe/ecs' { // Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity, vector, player, target], write: [position], has: [label] }, resources: { read: [time] }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, // @ts-expect-error kernel values must be linear components: { read: [label] }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, // @ts-expect-error kernel values can't be variable-length arrays components: { write: [path] }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, // @ts-expect-error kernel values can't be sets or maps components: { read: [marks] }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, + components: { read: [velocity] }, // @ts-expect-error kernel lookups must read linear values lookup: { labels: { read: [label] } }, }, () => {}); @@ -222,7 +223,7 @@ declare const byTarget: Index<{ readonly key: readonly [typeof target] }>; // A kernel's lookup by entity matches has and not, as any system's does. System.create({ - kernel: "cpu", + kernel: true, components: { read: [target], write: [position] }, lookup: { players: { read: [position], has: [player], not: [velocity] } }, }, (row, { lookup }) => { @@ -231,13 +232,15 @@ System.create({ }); System.create({ - kernel: "cpu", + kernel: true, + components: { read: [velocity] }, // @ts-expect-error an index lookup matches what its index holds, so it takes no has lookup: { byTarget: { index: byTarget, read: [position], has: [player] } }, }, () => {}); System.create({ - kernel: "cpu", + kernel: true, + components: { read: [velocity] }, // @ts-expect-error kernels can't write resources resources: { write: [score] }, }, () => {}); @@ -279,8 +282,18 @@ System.create({ }); System.create({ - // @ts-expect-error a kernel is "cpu" or "gpu" + // @ts-expect-error a kernel is true, false or "gpu" + kernel: "cpu", +}, () => {}); + +// Without the flag a body compiles when it can; false runs it as written, whatever it uses. +System.create({ components: { read: [label] } }, () => {}); +System.create({ kernel: false, components: { read: [label], write: [path] }, resources: { write: [score] } }, () => {}); + +System.create({ + // @ts-expect-error a system without components runs once, so there's nothing to compile kernel: true, + resources: { read: [time] }, }, () => {}); // Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. @@ -423,7 +436,8 @@ System.create({ observer: true, components: { read: [velocity] }, resources: { r // @ts-expect-error an observer system takes no input System.create({ observer: true, resources: { read: [time] } }, (_context, input: { readonly count: number }) => { void input; }); // @ts-expect-error an observer system isn't a kernel -System.create({ observer: true, kernel: "cpu", components: { read: [position] } }, () => {}); +System.create({ observer: true, kernel: true, components: { read: [position] } }, () => {}); +System.create({ observer: true, kernel: false, components: { read: [position] } }, () => {}); // @ts-expect-error an observer system is ordered with nothing System.create({ observer: true, resources: { read: [time] }, after: [integrate] }, () => {}); // @ts-expect-error an observer system is ordered with nothing @@ -444,4 +458,4 @@ System.create({ target: "entity", components: { write: [position] } }, row => { // @ts-expect-error it runs on a row, so it needs components System.create({ target: "entity" }, (_context, input: OnOne) => { void input; }); // @ts-expect-error not a kernel -System.create({ target: "entity", kernel: "cpu", components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); +System.create({ target: "entity", kernel: true, components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts index 6f6838af..46d0aaf1 100644 --- a/packages/ecs/src/interface/system/up.type-test.ts +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -23,7 +23,7 @@ declare module '@adobe/ecs' { // `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the // system writes. System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -47,7 +47,7 @@ System.create({ }, () => {}); System.create({ - kernel: "cpu", + kernel: true, components: { write: [world] }, // @ts-expect-error a kernel's up reads linear values up: { through: parent, read: [name] }, diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts index 6b2b9044..4a3f0952 100644 --- a/packages/ecs/src/perftest/filters.ts +++ b/packages/ecs/src/perftest/filters.ts @@ -40,7 +40,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) /** Writes the value of the rows whose `at` is `first` plus a multiple of `stride`. */ const write = System.create({ - kernel: "cpu", + kernel: true, components: { read: [at], write: [value] }, }, (row, _context, input: { readonly first: I32; readonly stride: I32 }) => { const offset = row[at] - input.first; @@ -63,10 +63,10 @@ const readersOfTwo = System.create({ /** Kernels each filtering on `value`'s changes, writing an output of its own. */ const kernels = System.create({ systems: [ - System.create({ kernel: "cpu", components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), - System.create({ kernel: "cpu", components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), - System.create({ kernel: "cpu", components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), - System.create({ kernel: "cpu", components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), + System.create({ kernel: true, components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), + System.create({ kernel: true, components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), + System.create({ kernel: true, components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), + System.create({ kernel: true, components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), ], }); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 37f48c47..65b816a2 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -43,6 +43,6 @@ export const spawnEach = System.create({}, ({ commands }) => { }); /** A kernel over every row of the hundred archetypes. */ -export const bumpEach = System.create({ kernel: "cpu", components: { write: [value] } }, row => { +export const bumpEach = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index ad7019c6..78573c97 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -25,7 +25,7 @@ const taggedChild = Archetype.create([local, world, parent, leaf]); /** Each row's world value is its parent's plus its own. */ export const propagate = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world] }, up: { through: parent, read: [world] }, }, (row, { up }) => { @@ -34,7 +34,7 @@ export const propagate = System.create({ /** The same, only for rows whose own or parent's values changed since it last ran. */ export const propagateChanged = System.create({ - kernel: "cpu", + kernel: true, components: { read: [local], write: [world], changed: [local] }, up: { through: parent, read: [world] }, }, (row, { up }) => { diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index 5f81e361..2b9ac180 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -75,7 +75,7 @@ const threads = navigator.hardwareConcurrency - 1; const pooled = (): World => createWorld({ [workers]: threads }); /** A kernel touching nothing, so a world's pool starts, as it does with its first kernel. */ -const startPool = System.create({ kernel: "cpu", components: { has: [marked] } }, () => {}); +const startPool = System.create({ kernel: true, components: { has: [marked] } }, () => {}); /** Indexes `index idle` keeps, each over its own thousand entities. */ const idleIndexes = 1000; @@ -124,7 +124,7 @@ const seedParticles = System.create({}, ({ commands }) => { } }); /** The same on a kernel: on the pool, each row's 27 probes in straight-line code. */ -const neighborsKernel = System.create({ kernel: "cpu", components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighborsKernel = System.create({ kernel: true, components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -152,12 +152,12 @@ const seedKernelParticles = System.create({}, ({ commands }) => { /** Particles of `rekey 1M`, drifting through cells of 4 units. */ const drifting = million; -const drift = System.create({ kernel: "cpu", components: { write: [place] } }, row => { +const drift = System.create({ kernel: true, components: { write: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 0.25, y, z]; }); /** The cell a particle is in, written only when it crosses into another, so the index's work is the crossings. */ -const rekeyCells = System.create({ kernel: "cpu", components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { +const rekeyCells = System.create({ kernel: true, components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { const [x, y, z] = row[place]; const [cx, cy, cz] = row[cell]; const nx = Math.floor(x / 4); @@ -281,7 +281,7 @@ const spawnAndDespawn = System.create({}, ({ commands }) => { spawnedLast = spawned; }); /** Writes every entity's amount, which the index holds by but doesn't watch. */ -const writeAmounts = System.create({ kernel: "cpu", components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); +const writeAmounts = System.create({ kernel: true, components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); /** Reads the index's length and whether it changed, as a count kept by a system would. */ const lengthReads = 100_000; const readLengths = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index a8809202..4244ae57 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -37,7 +37,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank. */ -const advance = System.create({ kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); /** A system no entity matches: a run that changes nothing. */ const idle = System.create({ components: { has: [unmatched] } }, () => {}); diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 456fe004..412f7afc 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -46,7 +46,7 @@ export const spawn = System.create({}, ({ commands }, input: { readonly count: n }); export const move = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position], has: [visible, enabled] }, }, row => { const [px, py, pz] = row[position]; diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index d4bd44eb..d860b497 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -35,7 +35,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ -const advance = System.create({ kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); /** The entities `look` and `lookChanged` look up, each run. */ let looked: readonly Entity[] = []; diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index d877bbf3..81c071f8 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -39,7 +39,7 @@ export const seedRanks = System.create({}, ({ commands }, input: { readonly coun for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); const flip = System.create({ resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); -const reverse = System.create({ kernel: "cpu", components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { +const reverse = System.create({ kernel: true, components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { row[order] = row[rank] * resources[sign]; }); /** Reverses the entities' order every tick. */ diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index aca06794..994fce34 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -81,7 +81,7 @@ export const spawnSixteen = System.create({}, ({ commands }, input: { readonly c }); /** A spawn per row, from a kernel. */ -export const spawnKernel = System.create({ kernel: "cpu", components: { read: [index], not: [spawned] } }, (row, { commands }) => { +export const spawnKernel = System.create({ kernel: true, components: { read: [index], not: [spawned] } }, (row, { commands }) => { commands.spawn(made)({ [index]: row[index], [value]: 0 }); }); @@ -96,7 +96,7 @@ export const despawnAll = System.create({ components: { has: [index], not: [spaw }); /** The same, from a kernel. */ -export const despawnKernel = System.create({ kernel: "cpu", components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnKernel = System.create({ kernel: true, components: { has: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); @@ -121,11 +121,11 @@ export const removeFlag = System.create({ components: { has: [flag] } }, (row, { commands.remove(row.entity, flag); }); -export const addFlagKernel = System.create({ kernel: "cpu", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlagKernel = System.create({ kernel: true, components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlagKernel = System.create({ kernel: "cpu", components: { has: [flag] } }, (row, { commands }) => { +export const removeFlagKernel = System.create({ kernel: true, components: { has: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index bc1b695a..01cfd945 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -70,7 +70,7 @@ export const retag = System.create({ components: { read: [index] } }, (row, { co /** Writes 1% of positions. */ export const poke = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index], write: [position] }, }, (row, _context, input: { readonly group: I32 }) => { if (row[index] % 100 === input.group) { @@ -81,7 +81,7 @@ export const poke = System.create({ /** Writes 1% of positions, clustered in one contiguous run of rows. */ export const pokeCluster = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index], write: [position] }, }, (row, _context, input: { readonly group: I32 }) => { const group = row[index] - input.group * 10_000; @@ -93,7 +93,7 @@ export const pokeCluster = System.create({ /** Sees only what changed since it last ran. */ export const observe = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position], write: [velocity], changed: [position] }, }, row => { const [x, y, z] = row[position]; @@ -102,7 +102,7 @@ export const observe = System.create({ /** Steers toward the target: one lookup per row. */ export const follow = System.create({ - kernel: "cpu", + kernel: true, components: { read: [position, target], write: [velocity] }, lookup: { targets: { read: [position] } }, }, (row, { lookup: { targets } }) => { @@ -116,7 +116,7 @@ export const follow = System.create({ /** Issues commands from a kernel: a tenth of the rows record a hit. */ export const emit = System.create({ - kernel: "cpu", + kernel: true, components: { read: [index, position] }, }, (row, { commands }) => { if (row[index] % 10 === 0) { @@ -126,27 +126,27 @@ export const emit = System.create({ }); /** Six dependent kernels: one tier each, so dispatch cost shows. */ -export const chain1 = System.create({ kernel: "cpu", components: { write: [velocity] } }, row => { +export const chain1 = System.create({ kernel: true, components: { write: [velocity] } }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain2 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain1] }, row => { +export const chain2 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain1] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain3 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain2] }, row => { +export const chain3 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain2] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain4 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain3] }, row => { +export const chain4 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain3] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain5 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain4] }, row => { +export const chain5 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain4] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain6 = System.create({ kernel: "cpu", components: { write: [velocity] }, after: [chain5] }, row => { +export const chain6 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain5] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); @@ -187,7 +187,7 @@ export const seeded = async (create: () => World, count: number): Promise }; /** Folds every row's index into a sum and a maximum. */ -export const reduce = System.create({ kernel: "cpu", components: { read: [index] }, resources: { reduce: [indexSum, highest] } }, (row, { reduce }) => { +export const reduce = System.create({ kernel: true, components: { read: [index] }, resources: { reduce: [indexSum, highest] } }, (row, { reduce }) => { reduce[indexSum](row[index]); reduce[highest](row[index]); }); diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 77d28624..9a880cc6 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -31,7 +31,7 @@ const drag = System.create({ /** Draws nothing: its run uploads what it reads. */ const draw = System.render({ shader: { accessors: { place } }, after: [drag] }, () => {}); const frame = System.create({ systems: [drag, draw] }); -const slide = System.create({ kernel: "cpu", components: { write: [place] } }, row => { +const slide = System.create({ kernel: true, components: { write: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 1, y, z]; }); diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 2ac94eb7..9bd9e50d 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -40,7 +40,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) const showPose = System.create({ components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); /** Commits a pose and an extent per row as commands, values every row already has. */ -const commitPose = System.create({ kernel: "cpu", components: { read: [order] } }, (row, { commands }) => { +const commitPose = System.create({ kernel: true, components: { read: [order] } }, (row, { commands }) => { commands.add(row.entity, pose, { tx: row[order], ty: 1, rotation: 0, sx: 1, sy: 1 }); commands.add(row.entity, extent, [2, 2]); }); diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 4c8d26fd..e89f0e0f 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -63,7 +63,7 @@ export const move = System.create({ /** The same move on the CPU, for comparison. */ export const moveOnCpu = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position], has: [visible, enabled] }, }, row => { row[position] = Vec3.add(row[position], row[velocity]); diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index cf6436d9..e3271664 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -28,7 +28,7 @@ export const spawn = System.create({ // Kernels: linear components and read-only resources, so they can run in chunks across workers. export const gravity = System.create({ - kernel: "cpu", + kernel: true, components: { write: [velocity] }, resources: { read: [delta] }, }, (row, { resources }) => { @@ -37,7 +37,7 @@ export const gravity = System.create({ }); export const integrate = System.create({ - kernel: "cpu", + kernel: true, components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [gravity], From 38c53de99962f3896d0673d413b3cb7adac38fc8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:16:07 -0700 Subject: [PATCH 059/127] ecs: benchmarks keep the path they measure with kernel: false; samples and docs describe kernels by default Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 15 ++++++++------- packages/ecs/followups.md | 5 +---- packages/ecs/src/guide/guide.md | 3 ++- packages/ecs/src/perftest/editor.ts | 2 ++ packages/ecs/src/perftest/finalized.ts | 6 +++--- packages/ecs/src/perftest/hierarchy.ts | 2 +- packages/ecs/src/perftest/indexes.ts | 4 ++-- packages/ecs/src/perftest/observed.ts | 4 ++-- packages/ecs/src/perftest/reads.ts | 2 +- packages/ecs/src/perftest/remote.ts | 2 +- packages/ecs/src/perftest/structural.ts | 18 +++++++++--------- packages/ecs/src/perftest/tails.ts | 2 +- packages/ecs/src/perftest/values.ts | 2 +- .../ecs/src/samples/particles/particles.ts | 4 +--- packages/ecs/threads.md | 3 ++- 15 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 1ffd20c1..637d48ee 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -40,7 +40,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems that touch main-thread-only components are pinned to the main thread.** - **Structural changes become visible only at sync points,** whether they come from the main thread or from workers. - **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. -- **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. +- **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. @@ -55,7 +55,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`SharedArrayBuffer` requires cross-origin isolation** (COOP/COEP headers). Without it, every system runs on the main thread. - **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor. - **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device. - - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. + - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a kernel on the pool in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype; with `order`, a sorted index, one table of every matching row in its order, whose accessors resolve an instance's position through an order block of slot and row to its archetype's columns. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. - **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back. - **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU. @@ -109,11 +109,12 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - `added`/`changed` pass when **any** of the listed components was added or changed. - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - - **`kernel: "cpu"`** opts a system into being compiled, so it can run in chunks across workers; **`kernel: "gpu"`** narrows the subset so it can also run on the GPU. The term comes from CUDA, WebGPU and Taichi. - - It's explicit rather than inferred, so a body that leaves the subset fails the build instead of silently running on the main thread. - - Type-checked: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`, which the plugin reads by name. - - Without `kernel`, a system is plain JS on the main thread. + - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. + - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module object or array of data (which may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles. + - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. + - Type-checked where required: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. + - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. + - **The pool starts once rows warrant it:** while no archetype holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 390a53bf..a5547d8e 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -83,10 +83,7 @@ Open items awaiting a decision or work. Remove an item once it's decided or done The plugin is required, so it can derive what consumers now restate. Each is a contract change, reviewed one at a time; inferred values are emitted as the same literals the runtime takes today, so none costs anything at runtime. -1. **Kernels by default.** `kernel?: boolean | "gpu"`: omitted compiles a row system whose body fits the subset, - `true` asserts it (a build error saying why, including for a per-run system), `false` never compiles, `"gpu"` as - today; per-run systems never compile automatically. Accepts results that aren't bit-exact with plain JS. -2. **Access inferred from bodies, saved for discussion with Kris.** Recommended: option B, membership explicit as +1. **Access inferred from bodies, saved for discussion with Kris.** Recommended: option B, membership explicit as `components: { with: [...], not: [...] }` and access modes inferred from the body, so rows stay typed over `with` and matching never changes behind the author's back; `write` stays as an override for bodies the plugin can't follow. Option A infers the lists entirely, typing rows over every component. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 0a2d20ee..d85e43cd 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -508,7 +508,8 @@ lookup's `changed: true` runs its system only when that happened anywhere in the ### 14. Kernels -Systems compiled to run across workers. To be written. +Systems compiled to run across workers: a system with components compiles when its body allows it, `kernel: true` +requires it and `kernel: false` runs it as written. To be written. ### 15. Reductions diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts index 4549a737..7e12130a 100644 --- a/packages/ecs/src/perftest/editor.ts +++ b/packages/ecs/src/perftest/editor.ts @@ -71,6 +71,7 @@ const load = System.create({}, ({ commands }) => { /** Each node's world position is its parent's plus its own. */ const place = System.create({ + kernel: false, components: { read: [localX, localY], write: [worldX, worldY], changed: [localX, localY] }, up: { through: parent, read: [worldX, worldY] }, }, (row, { up }) => { @@ -79,6 +80,7 @@ const place = System.create({ }); /** Each node's world opacity is its parent's times its own. */ const fade = System.create({ + kernel: false, components: { read: [opacity], write: [worldOpacity], changed: [opacity] }, up: { through: parent, read: [worldOpacity] }, }, (row, { up }) => { diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index f1cc112d..81787604 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -63,7 +63,7 @@ export const releaseMeasured = Finalizer.create({ components: { read: [measured, const spawn = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); -const despawn = System.create({ components: { has: [handle, spot] } }, (row, { commands }) => { +const despawn = System.create({ kernel: false, components: { has: [handle, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); const spawnNoted = System.create({}, ({ commands }) => { @@ -72,10 +72,10 @@ const spawnNoted = System.create({}, ({ commands }) => { const spawnMeasured = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, measured])({ [spot]: [k, 0, 0], [measured]: null }); }); -const despawnMeasured = System.create({ components: { has: [measured, spot] } }, (row, { commands }) => { +const despawnMeasured = System.create({ kernel: false, components: { has: [measured, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const despawnNoted = System.create({ components: { has: [noted, spot] } }, (row, { commands }) => { +const despawnNoted = System.create({ kernel: false, components: { has: [noted, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 78573c97..18be3b5d 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -53,7 +53,7 @@ export const forest = System.create({ const target = { type: "object", properties: { entity: Entity.schema } } as const; /** Changes one row's own value. */ -export const nudge = System.create({ components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { +export const nudge = System.create({ kernel: false, components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { if (row.entity === input.entity) row[local] += 1; }); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index 2b9ac180..aacc1caa 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -199,7 +199,7 @@ const inStorageOrder = System.create({ components: { read: [amount] } }, row => const changedInRankOrder = System.create({ components: { read: [amount], changed: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const bumpMarked = System.create({ components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); +const bumpMarked = System.create({ kernel: false, components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); let pushed = 0; const neighbors = System.create({ components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { @@ -233,7 +233,7 @@ const seedPage = System.create({}, ({ commands }) => { commands.spawn([amount, key, marked])({ [amount]: 0, [key]: 0, [marked]: true }); for (let i = 1; i < pageRows; i++) commands.spawn([amount, key])({ [amount]: (i * 7919) % pageRows, [key]: i }); }); -const nudgeAmount = System.create({ components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); +const nudgeAmount = System.create({ kernel: false, components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); let page: number[] = []; /** The page, recomputed only when what it reads changed: the 20 rows after the first 100 with a key below half, highest amounts first. */ const readPage = System.create({ lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 4244ae57..77f98931 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -34,13 +34,13 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Writes the marked entity's value, so one entity observer is notified and a selection or count is a candidate. */ -const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank. */ const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); /** A system no entity matches: a run that changes nothing. */ -const idle = System.create({ components: { has: [unmatched] } }, () => {}); +const idle = System.create({ kernel: false, components: { has: [unmatched] } }, () => {}); const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index d860b497..dc1f969d 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -32,7 +32,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Writes the marked entity's value. */ -const nudge = System.create({ components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index 81c071f8..fbaeeba0 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -90,7 +90,7 @@ export const seedCaptions = System.create({}, ({ commands }, input: { readonly c const recaption = System.create({ components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[caption] = `Buy milk and bread #${row[rank]}.${resources[counter]}`; }); -const reamount = System.create({ components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const reamount = System.create({ kernel: false, components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[amount] = row[rank] + resources[counter]; }); /** Rewrites 1% of the captions a tick. */ diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index 994fce34..704127ba 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -86,12 +86,12 @@ export const spawnKernel = System.create({ kernel: true, components: { read: [in }); /** Despawns every entity a spawn benchmark made. */ -export const despawnSpawned = System.create({ components: { has: [spawned] } }, (row, { commands }) => { +export const despawnSpawned = System.create({ kernel: false, components: { has: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Despawns every seeded entity. */ -export const despawnAll = System.create({ components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnAll = System.create({ kernel: false, components: { has: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); @@ -113,11 +113,11 @@ export const despawnOne = System.create({}, ({ commands }) => { if (entity !== undefined) commands.despawn(entity); }); -export const addFlag = System.create({ components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlag = System.create({ kernel: false, components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlag = System.create({ components: { has: [flag] } }, (row, { commands }) => { +export const removeFlag = System.create({ kernel: false, components: { has: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); @@ -129,23 +129,23 @@ export const removeFlagKernel = System.create({ kernel: true, components: { has: commands.remove(row.entity, flag); }); -export const addAmount = System.create({ components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { +export const addAmount = System.create({ kernel: false, components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { commands.add(row.entity, amount, row[index]); }); -export const removeAmount = System.create({ components: { has: [amount] } }, (row, { commands }) => { +export const removeAmount = System.create({ kernel: false, components: { has: [amount] } }, (row, { commands }) => { commands.remove(row.entity, amount); }); /** Adds a value every row already has: no archetype moves, only values replaced and marked changed. */ -export const replaceAmount = System.create({ components: { read: [amount] } }, (row, { commands }) => { +export const replaceAmount = System.create({ kernel: false, components: { read: [amount] } }, (row, { commands }) => { commands.add(row.entity, amount, row[amount] + 1); }); -export const addSeveral = System.create({ components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addSeveral = System.create({ kernel: false, components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, several, { [amount]: row[index], [extra]: 1 }); }); -export const removeSeveral = System.create({ components: { has: [flag, amount, extra] } }, (row, { commands }) => { +export const removeSeveral = System.create({ kernel: false, components: { has: [flag, amount, extra] } }, (row, { commands }) => { commands.remove(row.entity, several); }); diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index 684ef5ad..07757da9 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -86,7 +86,7 @@ export const overlapWorldMilliseconds = 2; export const overlapTicks = 100; /** On the world's thread: writes every row's place, which the last tick's tail still reads. */ -const overlapWrite = System.create({ components: { write: [place] } }, row => { +const overlapWrite = System.create({ kernel: false, components: { write: [place] } }, row => { row[place] = [row[place][0], 1, 0]; }); /** On the world's thread, after the writer: the rest of the tick's work, as systems reading what it wrote do. */ diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 9bd9e50d..10789aa9 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -37,7 +37,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Shows each row's pose: a struct read and written whole per row. */ -const showPose = System.create({ components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); +const showPose = System.create({ kernel: false, components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); /** Commits a pose and an extent per row as commands, values every row already has. */ const commitPose = System.create({ kernel: true, components: { read: [order] } }, (row, { commands }) => { diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index e3271664..337b1cfe 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -26,9 +26,8 @@ export const spawn = System.create({ } }); -// Kernels: linear components and read-only resources, so they can run in chunks across workers. +// Kernels: linear components and read-only resources, so the build compiles them to run in chunks across workers. export const gravity = System.create({ - kernel: true, components: { write: [velocity] }, resources: { read: [delta] }, }, (row, { resources }) => { @@ -37,7 +36,6 @@ export const gravity = System.create({ }); export const integrate = System.create({ - kernel: true, components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [gravity], diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 6c411146..df98ce18 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -266,7 +266,8 @@ after anything it reported first, so an owner's report wins over the close its e world's thread, and `start` starts one worker, or returns null when it has no more, so a pool takes as many as it gets. The platform's own is browser workers where `Worker` exists, nested ones on a worker, else `worker_threads`, else none. `WorldOptions`' `pool` gives another, or null for none: `lentWorkers(lent)` for workers another thread - lent. + lent. A world starts its pool once it has a kernel and some archetype holds half a piece of rows for a worker, as + the fastest kernels split, so a small world starts none. - **Lending:** the owning thread starts each worker, posts it the `MessagePort` it talks on as its first message, and keeps a second channel to the thread it's lent to, on which it reports the worker's death and ends it when asked; a lent worker is started once, so a pool resized past what was lent gets fewer workers. `terminate` asks the owner and From 2b1049dc01f895cbd094634400b3a100a3b491ad Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:24:10 -0700 Subject: [PATCH 060/127] ecs: a union of number constants is stored as a number; errors say why a key isn't registered A union of number constants, as an as const object's values give, was stored as an object on the world's thread, so a system reading it beside another thread's objects failed. It's now a number in shared memory: integers at the narrowest width holding them, others f64. A key with no registration now says whether its module wasn't built with Compiler.plugin(), which registers keys, or the key isn't declared. Co-Authored-By: Claude Opus 5.5 --- .../world/constant-unions.test.ts | 33 +++++++++++++++++++ .../ecs/src/implementation/world/create.ts | 4 +-- .../ecs/src/implementation/world/state.ts | 4 +-- .../implementation/world/thread-systems.ts | 6 ++-- .../ecs/src/interface/finalizer/create.ts | 4 +-- packages/ecs/src/interface/indexes/create.ts | 8 ++--- packages/ecs/src/interface/registry/index.ts | 2 +- .../ecs/src/interface/registry/layout.test.ts | 17 ++++++++++ packages/ecs/src/interface/registry/layout.ts | 7 +++- packages/ecs/src/interface/registry/names.ts | 8 +++++ .../ecs/src/interface/registry/schemas.ts | 12 +++++++ .../ecs/src/interface/registry/threads.ts | 4 +-- packages/ecs/src/interface/rule/create.ts | 4 +-- packages/ecs/src/interface/system/check.ts | 4 +-- .../ecs/src/interface/system/create.test.ts | 7 ++++ 15 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 packages/ecs/src/implementation/world/constant-unions.test.ts diff --git a/packages/ecs/src/implementation/world/constant-unions.test.ts b/packages/ecs/src/implementation/world/constant-unions.test.ts new file mode 100644 index 00000000..75e74a27 --- /dev/null +++ b/packages/ecs/src/implementation/world/constant-unions.test.ts @@ -0,0 +1,33 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { readEach, select } from "./reads.test-helper.js"; + +const Frame = { fixed: 0, autoHeight: 1, autoWidth: 2 } as const; +type Frame = (typeof Frame)[keyof typeof Frame]; + +const frame = Symbol("constantUnionsTest/frame"); +const shown = Symbol("constantUnionsTest/shown"); +declare module "@adobe/ecs" { + export interface Components { + [frame]: { type: Frame }; + [shown]: { type: { text: string } | null; thread: "main" }; + } +} + +const spawn = System.create({}, ({ commands }) => { + commands.spawn([frame, shown])({ [frame]: Frame.autoWidth, [shown]: null }); +}); +// Reads the union beside an object of the main thread: the union is a number in shared memory, on no thread. +const show = System.create({ components: { read: [frame], write: [shown] } }, row => { + row[shown] = { text: `frame ${row[frame]}` }; +}); + +it("stores a union of number constants as a number in shared memory, read beside another thread's objects", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(show); + expect(await readEach(world, await select(world, { required: [frame] }), frame)).toEqual([2]); +}); diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index 313064f8..aff9d7a8 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -49,7 +49,7 @@ import { createState, defaultOf, idOf, reside, startTicking, type Key } from './ import { indexOf } from './entities.js'; import { isHeld } from './transfers.js'; import type { ReadonlyValue as ReadonlyResourceValue } from '../../interface/resource/value.js'; -import { labelOf } from '../../interface/registry/index.js'; +import { labelOf, whyUnregistered } from '../../interface/registry/index.js'; type Queue = (task: () => T | Promise) => Promise; @@ -148,7 +148,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti const initial = new Map(); // String keys are never a resource's, so they're ignored, as commands ignore them. for (const key of Object.getOwnPropertySymbols(resources)) { - if (kindOf(key) !== "resource") throw new Error(`"${labelOf(key)}" isn't a registered resource.`); + if (kindOf(key) !== "resource") throw new Error(`"${labelOf(key)}" isn't a registered resource: ${whyUnregistered(key)}.`); if (isWorldWritten(key)) throw new Error(`"${labelOf(key)}" is written only by the world.`); initial.set(key, Reflect.get(resources, key)); } diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 2fc22f66..6efe3b92 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { createHolds, type Holds } from './stop-lists.js'; import type { Entity } from '../../interface/entity/entity.js'; -import { declarationOf, firstKeyOf, kindOf, labelOf } from '../../interface/registry/index.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf, whyUnregistered } from '../../interface/registry/index.js'; import { createArchetype, startTicks, type Archetype, type Storage } from './archetype.js'; import type { SpawnBatch } from './staging.js'; import type { CommandBuffer } from './buffer.js'; @@ -367,7 +367,7 @@ export const idOf = (state: State, key: Key): number => { return id; } const declaration = declarationOf(key); - if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); + if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); const { structure } = state; // A host gets its id first, so stratifying with the new edge can name it; a cycle throws before anything is // recorded. diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 44c16d1c..67493b64 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -3,7 +3,7 @@ import type { Entity } from '../../interface/entity/entity.js'; import type { Commands } from '../../interface/system/commands.js'; import type { Leaf } from '../../interface/system/leaf.js'; import type { SystemLookup } from '../../interface/system/access.js'; -import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy } from '../../interface/registry/index.js'; +import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy, whyUnregistered } from '../../interface/registry/index.js'; import { layoutOf, objectLayout, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; import type { StringStore } from './strings.js'; import { stampChanged, writeOf } from './archetype.js'; @@ -147,7 +147,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return known; } const declaration = declarationOf(key); - if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); + if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); if (declaration.derived === true) throw new Error(`"${labelOf(key)}" is derived, so only rules add and remove it.`); const layout = layoutOf(declaration.type); const own = ownObject(key); @@ -705,7 +705,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const threadIndex = createThreadIndex(host.rows, host.archetypeAt, host.replaced, { layouts: index.declaration.key.map(key => { const layout = host.layoutOf(key); - if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: declare it beside its key, in a module the plugin builds.`); + if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); return layout; }), sorted: keptSorted(index.declaration), diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index dcef0612..01549100 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { firstItemOf, nameOf } from '../registry/items.js'; import { addFinalizer, programFinalizers } from '../registry/program.js'; @@ -119,7 +119,7 @@ export const changes = ( for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys); } for (const key of declaration.watch ?? []) { - if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component.`); + if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`); if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`); } const thread = threadOf({ components: { read: declaration.watch }, resources: declaration.resources, lookup: declaration.lookup }, "Finalizer", name); diff --git a/packages/ecs/src/interface/indexes/create.ts b/packages/ecs/src/interface/indexes/create.ts index 2748308c..97edc350 100644 --- a/packages/ecs/src/interface/indexes/create.ts +++ b/packages/ecs/src/interface/indexes/create.ts @@ -5,7 +5,7 @@ import { takeIdOfDeclaration } from '../registry/ids.js'; import { addIndex } from '../registry/indexes.js'; import { nameOf } from '../registry/items.js'; import { layoutOf } from '../registry/layout.js'; -import { declarationOf, firstKeyOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import type { Schema } from '../schema/schema.js'; import type { Listed, Property } from '../system/property.js'; import type { Created } from './created.js'; @@ -55,7 +55,7 @@ export const create = (declaration: D & Validate for (const part of key) { const found = declarationOf(part); const type = found?.type; - if (kindOf(part) !== "component" || type === undefined) fail(`has key part "${labelOf(part)}", which isn't a registered component of data`); + if (kindOf(part) !== "component" || type === undefined) fail(`has key part "${labelOf(part)}", which isn't a registered component of data: ${whyUnregistered(part)}`); else { if (parts.has(firstKeyOf(part))) fail(`lists key part "${labelOf(part)}" twice`); parts.add(firstKeyOf(part)); @@ -69,11 +69,11 @@ export const create = (declaration: D & Validate if (threads.size > 1) fail(`has key parts on threads ${[...threads].map(thread => `"${thread}"`).join(" and ")}; its object parts live on one thread, the world's beside a string`); const held = new Set(parts); for (const component of components?.has ?? []) { - if (kindOf(component) !== "component") fail(`has "${labelOf(component)}", which isn't a registered component`); + if (kindOf(component) !== "component") fail(`has "${labelOf(component)}", which isn't a registered component: ${whyUnregistered(component)}`); held.add(firstKeyOf(component)); } for (const component of components?.not ?? []) { - if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component`); + if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component: ${whyUnregistered(component)}`); if (held.has(firstKeyOf(component))) fail(`excludes "${labelOf(component)}", which it holds by`); } const index: Created = { kind: "index", name, declaration }; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts index 35b44dfc..1529e034 100644 --- a/packages/ecs/src/interface/registry/index.ts +++ b/packages/ecs/src/interface/registry/index.ts @@ -3,7 +3,7 @@ export type { AnyDeclaration } from './declarations.js'; export { schemaOf, threadOfDeclaration } from './declarations.js'; export type { Kind } from './names.js'; -export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf } from './names.js'; +export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js'; export type { Item } from './program.js'; export { definitionOf, programFinalizers, programRules, programVersion, requireDefinition } from './program.js'; export type { Schedule } from './schedule.js'; diff --git a/packages/ecs/src/interface/registry/layout.test.ts b/packages/ecs/src/interface/registry/layout.test.ts index 5b419131..a0020015 100644 --- a/packages/ecs/src/interface/registry/layout.test.ts +++ b/packages/ecs/src/interface/registry/layout.test.ts @@ -35,6 +35,23 @@ describe("layoutOf", () => { expect(placement({ type: "object", properties: { flag: { type: "boolean" }, weight: { type: "number" }, mass: f32 } })).toEqual({ stride: 24, offsets: [0, 8, 16] }); }); + it("stores a union of number constants as a number column: integers at the narrowest width holding them", () => { + const views = (schema: Schema) => { + const layout = layoutOf(schema); + return layout.kind === "linear" ? layout.leaves.map(leaf => leaf.view) : layout.kind; + }; + expect(views({ anyOf: [{ const: 0 }, { const: 1 }, { const: 2 }] })).toEqual(["u32"]); + expect(views({ anyOf: [{ const: 1 }, { const: -1 }] })).toEqual(["i32"]); + expect(views({ anyOf: [{ const: 0.5 }, { const: 1 }] })).toEqual(["f64"]); + expect(views({ anyOf: [{ const: 0 }, { const: 2 ** 40 }] })).toEqual(["f64"]); + expect(views({ type: "object", properties: { mode: { anyOf: [{ const: 0 }, { const: 1 }] }, at: f32 } })).toEqual(["u32", "f32"]); + expect(views({ anyOf: [{ const: 0 }, { const: "a" }] })).toBe("object"); + expect(views({ anyOf: [{ const: 0 }, { type: "null" }] })).toBe("object"); + // A struct, so it lives in shared memory, on no thread. + expect(isStruct({ anyOf: [{ const: 0 }, { const: 1 }] })).toBe(true); + expect(threadOfDeclaration({ type: { anyOf: [{ const: 0 }, { const: 1 }] } })).toBe(null); + }); + it("stores sets and maps as objects, even of numbers, and nothing in them as a struct", () => { const set: Schema = { type: "set", items: { type: "number" } }; const map: Schema = { type: "map", keys: { type: "integer", minimum: 0, maximum: 9 }, values: { type: "boolean" } }; diff --git a/packages/ecs/src/interface/registry/layout.ts b/packages/ecs/src/interface/registry/layout.ts index ac995bec..2abcaf9e 100644 --- a/packages/ecs/src/interface/registry/layout.ts +++ b/packages/ecs/src/interface/registry/layout.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Schema } from '../schema/schema.js'; -import { isString } from './schemas.js'; +import { isString, numberConstants } from './schemas.js'; /** A path from a value to one scalar leaf: `[]` for a scalar, `[1]` for a tuple element, `["x"]` for a struct field. */ export type Path = readonly (number | string)[]; @@ -83,6 +83,11 @@ const integerScalar = ({ minimum, maximum }: { readonly minimum?: number; readon /** `null` if the schema isn't linear: numbers, booleans, fixed-length tuples and structs of those. */ const shapeOf = (schema: Schema): Shape | null => { + // A union of number constants is a number: integers at the narrowest width holding them. + const constants = numberConstants(schema); + if (constants !== null) { + return { kind: "scalar", scalar: constants.every(Number.isInteger) ? integerScalar({ minimum: Math.min(...constants), maximum: Math.max(...constants) }) : "f64" }; + } if (!("type" in schema)) return null; switch (schema.type) { case "number": return { kind: "scalar", scalar: schema.precision === 1 ? "f32" : "f64" }; diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts index 6c6f5b20..146dbd6f 100644 --- a/packages/ecs/src/interface/registry/names.ts +++ b/packages/ecs/src/interface/registry/names.ts @@ -135,3 +135,11 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration) /** The symbol last registered under an id, by which a world gives values, or undefined where none was. */ export const keyOfId = (id: string): symbol | undefined => identified.get(id)?.newest; + +/** + * Why a key isn't registered, for errors: the build registers keys from their declarations, so either its module + * wasn't built with the plugin, which gives keys ids, or it isn't declared. + */ +export const whyUnregistered = (key: unknown): string => typeof key === "symbol" && idOfKey(key) === undefined + ? "the build's Compiler.plugin() registers keys from their declarations, and didn't build the module making it" + : "declare it beside its key, in interface Components or Resources"; diff --git a/packages/ecs/src/interface/registry/schemas.ts b/packages/ecs/src/interface/registry/schemas.ts index 9bff811a..428a67a9 100644 --- a/packages/ecs/src/interface/registry/schemas.ts +++ b/packages/ecs/src/interface/registry/schemas.ts @@ -6,9 +6,21 @@ const isStructValue = (value: unknown): boolean => typeof value === "number" || typeof value === "boolean" || (Array.isArray(value) ? value.every(isStructValue) : typeof value === "object" && value !== null && Object.values(value).every(isStructValue)); +/** The values of a union of number constants, which is stored as a number, or null for any other schema. */ +export const numberConstants = (schema: Schema): readonly number[] | null => { + if (!("anyOf" in schema) || schema.anyOf.length === 0) return null; + const values: number[] = []; + for (const option of schema.anyOf) { + if (!("const" in option) || typeof option.const !== "number") return null; + values.push(option.const); + } + return values; +}; + /** Whether a schema describes a struct: numbers, booleans, entities, fixed-length tuples and structs of those. */ export const isStruct = (schema: Schema): boolean => { if ("const" in schema) return isStructValue(schema.const); + if (numberConstants(schema) !== null) return true; if (!("type" in schema)) return false; switch (schema.type) { case "number": diff --git a/packages/ecs/src/interface/registry/threads.ts b/packages/ecs/src/interface/registry/threads.ts index c8bc9c93..8cac442e 100644 --- a/packages/ecs/src/interface/registry/threads.ts +++ b/packages/ecs/src/interface/registry/threads.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { threadOfDeclaration } from './declarations.js'; import { lookupKeysOf, orderKeysOf } from './indexes.js'; -import { declarationOf, kindOf, labelOf } from './names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from './names.js'; type Key = symbol; type Lists = { @@ -28,7 +28,7 @@ export type Placed = { export const requireRegistered = (what: "System" | "Finalizer", name: string, kind: "component" | "resource", keys: readonly Key[] | undefined): void => { for (const key of keys ?? []) { if (declarationOf(key) === undefined || kindOf(key) !== kind) { - throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}: declare it beside its key, in a module the plugin builds.`); + throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}: ${whyUnregistered(key)}.`); } } }; diff --git a/packages/ecs/src/interface/rule/create.ts b/packages/ecs/src/interface/rule/create.ts index 6d2b3407..09d65950 100644 --- a/packages/ecs/src/interface/rule/create.ts +++ b/packages/ecs/src/interface/rule/create.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { addRule } from '../registry/program.js'; @@ -16,7 +16,7 @@ export const create = (declaration: D): { readonly const name = nameOf(declaration, id, "rule"); const { components, derive } = declaration; for (const key of [...components.has ?? [], ...components.not ?? [], ...derive]) { - if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component.`); + if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`); } for (const key of derive) { if (declarationOf(key)?.derived !== true) throw new Error(`Rule "${name}" derives "${labelOf(key)}", which isn't declared derived.`); diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts index 11936bf6..76ee7bca 100644 --- a/packages/ecs/src/interface/system/check.ts +++ b/packages/ecs/src/interface/system/check.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { layoutOf } from '../registry/layout.js'; -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { definitionOf } from '../registry/program.js'; import { reducedBy, scheduleOf } from '../registry/schedule.js'; import { conditional } from '../registry/schemas.js'; @@ -50,7 +50,7 @@ const checkConditions = (leaf: Leaf): void => { const listed = new Set([...resources?.read ?? [], ...resources?.write ?? []]); for (const key of [...is, ...not]) { const declaration = declarationOf(key); - if (kindOf(key) !== "resource" || declaration === undefined) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which isn't a registered resource.`); + if (kindOf(key) !== "resource" || declaration === undefined) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which isn't a registered resource: ${whyUnregistered(key)}.`); const { type } = declaration; if (type !== undefined && !conditional(type)) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which is never null or false.`); } diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 0df4b250..afa1147d 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -133,6 +133,13 @@ describe("System.create", () => { expect(() => create({ components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); expect(() => create({ components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); }); + it("says why a key isn't registered: undeclared, or its module not built with the plugin", () => { + const create = (declaration: unknown) => Reflect.apply(System.create, undefined, [declaration, () => {}]); + expect(() => create({ components: { read: [unregistered] } })).toThrow("declare it beside its key, in interface Components or Resources"); + // Made in a function, so the build gave it no id, as one made in a module the plugin didn't build has none. + const unbuilt = Symbol("unbuilt"); + expect(() => create({ components: { read: [unbuilt] } })).toThrow("Compiler.plugin() registers keys from their declarations, and didn't build the module making it"); + }); }); describe("System.create of a composite", () => { From f63cf1ccae990f8a0140f9512cbcc05615da5dd3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:27:01 -0700 Subject: [PATCH 061/127] ecs: an application's build reads a library's reduction operators from the registrations its dist ships; tests pass no names Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../implementation/compiler/augmentations.ts | 25 +++++++++++++++ .../implementation/compiler/plugin.test.ts | 31 +++++++++++++++++++ .../ecs/src/implementation/compiler/plugin.ts | 16 +++++++--- .../implementation/compiler/transform.test.ts | 26 ++++++++-------- .../world/composite-input.test.ts | 2 +- .../implementation/world/gpu.browser.test.ts | 6 ++-- .../world/index-changed.test.ts | 4 +-- 8 files changed, 87 insertions(+), 25 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 637d48ee..00ce32fb 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -210,7 +210,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. - **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. - - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system lists the resource, `resources: { reduce: [total] }`, and folds with its trait's operator, which a kernel reads at build time from the module declaring the resource; changing a resource's operator changes every contributor. + - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system lists the resource, `resources: { reduce: [total] }`, and folds with its trait's operator, which a kernel reads at build time from the module declaring the resource: its augmentation in TypeScript, or in JavaScript, as a library's `dist` ships it, the registration its own build gave it; changing a resource's operator changes every contributor. - **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers. - **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`. - **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value. diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index 692fccc8..dfd6442a 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -56,6 +56,31 @@ export const reductionsIn = (program: TypedProgram): ReadonlyMap return found; }; +/** + * The operator each resource a module's generated registrations, `declareResource$(key, { ..., reduce })`, give, by its + * key's binding: what a library's build in library mode leaves of its declarations in its JavaScript. + */ +export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap => { + const locals = new Set(); + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration") continue; + for (const specifier of statement.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "declareResource$") locals.add(specifier.local.name); + } + } + const found = new Map(); + for (const statement of program.body) { + if (locals.size === 0 || statement.type !== "ExpressionStatement" || statement.expression.type !== "CallExpression") continue; + const { callee, arguments: [key, declaration] } = statement.expression; + if (callee.type !== "Identifier" || !locals.has(callee.name) || key?.type !== "Identifier" || declaration?.type !== "ObjectExpression") continue; + for (const property of declaration.properties) { + if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "reduce") continue; + if (property.value.type === "Literal" && typeof property.value.value === "string") found.set(key.name, property.value.value); + } + } + return found; +}; + type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>; type TypeName = Extract["typeName"]; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 561c285e..b3b9e4cc 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -351,6 +351,37 @@ export const description = kernelOf(kernel); expect(description === undefined ? [] : runs(module, description)).toEqual([6, 8]); }); +test("an application's build compiles a library's reducing kernel, with the operator the library's registration gives", async () => { + const project = join(directory, "reducing"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "reducing-app", type: "module" })); + mkdirSync(join(project, "node_modules/reducing-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/reducing-library/package.json"), JSON.stringify({ name: "reducing-library", type: "module", exports: "./dist/index.js" })); + // As its build in library mode left it: types stripped, the registrations it gave in their place. + writeFileSync(join(project, "node_modules/reducing-library/dist/index.js"), `import { System } from "@adobe/ecs"; +import { declareComponent$, declareResource$ } from "@adobe/ecs/declare"; +const score = Symbol(); +declareComponent$(score, { type: { type: "number" } }); +const largest = Symbol(); +declareResource$(largest, { type: { type: "number", default: 0 }, reduce: "max" }); +export const kernel = System.create({ kernel: true, components: { read: [score] }, resources: { reduce: [largest] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +`); + const application = join(project, "application.js"); + writeFileSync(application, `import { kernelOf } from ${JSON.stringify(join(copy, "dist/implementation/world/attach-kernel.js"))}; +import { kernel } from "reducing-library"; +export const description = kernelOf(kernel); +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === application); + const { description }: { description: KernelDescription | undefined } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(description?.reductions.map(reduced => reduced.operator)).toEqual(["max"]); +}); + test("a build registers the keys a module declares, through the copy of the package the module resolves", async () => { const project = join(directory, "declaring"); const copy = await installCopy(project); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index f5b2d71b..47aa6cd3 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,7 +9,7 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { augmentationInsertions, reductionsIn, type AugmentationHost } from './augmentations.js'; +import { augmentationInsertions, reductionsIn, registeredReductionsIn, type AugmentationHost } from './augmentations.js'; import { inputInsertions, type InputHost } from './input-schemas.js'; import { copyForImporter, isInPackageCopy, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; import { moduleOf, type TypedProgram } from './type-schemas.js'; @@ -59,10 +59,14 @@ let parseTypes: typeof import("vite").parseAst | null = null; const noReductions: ReadonlyMap = new Map(); -/** The `reduce` traits a TypeScript file's declarations give, read from its source. */ +/** + * The `reduce` traits a file's declarations give: read from a TypeScript file's source, and from the registrations a + * JavaScript file's build gave them, as a library's `dist` ships them. + */ const reductionsOf = (file: string): ReadonlyMap => { - const lang = loaders[file.slice(file.lastIndexOf(".") + 1)]; - if (parseTypes === null || (lang !== "ts" && lang !== "tsx")) return noReductions; + const extension = file.slice(file.lastIndexOf(".") + 1); + const lang = loaders[extension] ?? (extension === "js" || extension === "mjs" ? "js" : undefined); + if (parseTypes === null || lang === undefined) return noReductions; let code: string; try { code = readFileSync(file, "utf8"); @@ -70,7 +74,9 @@ const reductionsOf = (file: string): ReadonlyMap => { catch { return noReductions; } - return code.includes("reduce") && code.includes("declare module") ? reductionsIn(parseTypes(code, { lang }, file)) : noReductions; + if (!code.includes("reduce")) return noReductions; + if (lang === "ts" || lang === "tsx") return code.includes("declare module") ? reductionsIn(parseTypes(code, { lang }, file)) : noReductions; + return code.includes("declareResource$") ? registeredReductionsIn(parseTypes(code, { lang }, file)) : noReductions; }; /** Resolves imports through the build, from whichever transform runs. */ diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 0e1bf0cc..d614b485 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -372,7 +372,7 @@ describe("particles", () => { const integrate = await compile(`const kernel = System.create({ kernel: true, - components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{ name: "particles/gravity" }], + components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{}], }, (row, { resources }) => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; @@ -881,24 +881,24 @@ describe("subset", () => { test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { expect(await transform(`const s = System.create({ components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); - expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: true }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); + expect(await transformKernels(`import { System } from "other";\nSystem.create({ kernel: true }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); describe("terminal bodies", () => { /** Whether the transform marks a main-thread system's body as one that never reaches its commands. */ - const marked = async (parameters: string, body: string, declaration = `{ name: "a" }`): Promise => { + const marked = async (parameters: string, body: string, declaration = `{}`): Promise => { const result = await transform(`const system = System.create(${declaration}, ${parameters} => { ${body} });`); if (result !== null && "failure" in result) throw new Error(result.failure.message); const code = result?.code ?? ""; return code.includes("terminal$((") && code.includes("function terminal$(body)"); }; test.each([ - ["no context", "(row)", "console.log(row);", `{ name: "a", components: { has: [mass] } }`], - ["a context destructured without commands", "({ resources })", "console.log(resources);", `{ name: "a" }`], - ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ name: "a", resources: { read: [delta] } }`], - ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ name: "a", components: { has: [mass] } }`], - ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ name: "a", resources: { read: [delta] } }`], + ["no context", "(row)", "console.log(row);", `{ components: { has: [mass] } }`], + ["a context destructured without commands", "({ resources })", "console.log(resources);", `{}`], + ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ resources: { read: [delta] } }`], + ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { has: [mass] } }`], + ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ resources: { read: [delta] } }`], ])("mark a body with %s", async (_, parameters, body, declaration) => { expect(await marked(parameters, body, declaration)).toBe(true); }); @@ -973,7 +973,7 @@ describe("index lookups in main-thread systems", () => { describe("commands in main-thread systems", () => { /** Runs the transformed system's body once, recording each spawn's and add's keys and values as the runtime hooks receive them. */ - const spawned = async (body: string, parameters = "({ commands })", declaration = `{ name: "a" }`): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { + const spawned = async (body: string, parameters = "({ commands })", declaration = `{}`): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { const source = `const system = System.create(${declaration}, ${parameters} => { ${body} });`; const result = await transform(source); if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; @@ -1017,10 +1017,10 @@ describe("commands in main-thread systems", () => { }); test.each([ - ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{ name: "a" }`], - ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{ name: "a" }`], - ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ name: "a", components: { has: [mass] } }`], - ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ name: "a", components: { has: [mass] } }`], + ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{}`], + ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{}`], + ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ kernel: false, components: { has: [mass] } }`], + ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ kernel: false, components: { has: [mass] } }`], ])("pass values positionally through %s", async (_, body, parameters, declaration) => { const { calls } = await spawned(body, parameters, declaration); expect(calls).toEqual([["prepared", ["score"], ["score"], [1]]]); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index edcb57a1..052777bd 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -32,6 +32,6 @@ describe("composite input", () => { it("rejects an input schema that isn't an object of named fields", () => { const create: (...parameters: unknown[]) => unknown = System.create; - expect(() => create({ name: "scalar" }, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); + expect(() => create({}, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); }); }); diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 3164825b..6f921e71 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -567,10 +567,10 @@ describe("render systems", () => { it("name accessors anything WGSL allows, and only that", async () => { const own = await device(); - const named = (name: "count" | "target", accessor: string) => System.render({ shader: { accessors: { [accessor]: position } } }, () => {}); - const module = own.createShaderModule({ code: `${Gpu.wgsl(named("count", "count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); + const named = (accessor: string) => System.render({ shader: { accessors: { [accessor]: position } } }, () => {}); + const module = own.createShaderModule({ code: `${Gpu.wgsl(named("count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); - expect(() => Gpu.wgsl(named("target", "target"))).toThrow(/isn't usable as a WGSL function name/); + expect(() => Gpu.wgsl(named("target"))).toThrow(/isn't usable as a WGSL function name/); }); it("read their components on the GPU through the generated accessors", async () => { diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index c05a0d28..5084ba97 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -226,9 +226,9 @@ describe("an index lookup's changed: true", () => { it("is only for a system's index lookups", () => { const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [declaration, () => {}]); - expect(() => untypedSystem({ name: "byEntity", lookup: { weights: { read: [weight], changed: true } } })) + expect(() => untypedSystem({ lookup: { weights: { read: [weight], changed: true } } })) .toThrow(`lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); - expect(() => untypedFinalizer({ name: "finalizer", components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) + expect(() => untypedFinalizer({ components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) .toThrow(`lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); }); }); From 0d8249ac7e6aa78d385215b742c641982ce04823 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:53:44 -0700 Subject: [PATCH 062/127] ecs: kernel commands write their buffer directly, so a reoptimized kernel doesn't box each entity through a second call Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/buffer.ts | 38 +++++++++++++++++-- packages/ecs/src/implementation/world/perf.md | 3 ++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/ecs/src/implementation/world/buffer.ts b/packages/ecs/src/implementation/world/buffer.ts index 946bfdf4..43dd5dbd 100644 --- a/packages/ecs/src/implementation/world/buffer.ts +++ b/packages/ecs/src/implementation/world/buffer.ts @@ -101,8 +101,13 @@ export const pushCommand = (buffer: CommandBuffer, kind: number, entity: number) buffer.count++; }; +/** Room for more component ids, outlined as `growCommands` is. */ +const growIds = (buffer: CommandBuffer): void => { + buffer.ids = grown(buffer.ids, length => new Int32Array(length)); +}; + export const pushComponent = (buffer: CommandBuffer, id: number, value: unknown): void => { - if (buffer.named === buffer.ids.length) buffer.ids = grown(buffer.ids, length => new Int32Array(length)); + if (buffer.named === buffer.ids.length) growIds(buffer); buffer.ids[buffer.named] = id; buffer.values[buffer.named] = value; buffer.named++; @@ -140,7 +145,32 @@ export const endOf = (buffer: CommandBuffer, command: number): number => */ export const createKernelCommands = (target: { readonly buffer: CommandBuffer }, ids: readonly number[], starts: readonly unknown[], spawners: readonly Positional[]): KernelCommands => ({ spawners, - begin: (kind, entity) => pushCommand(target.buffer, kind, entity), - value: (key, value) => pushComponent(target.buffer, ids[key], value === undefined ? starts[key] : value), - component: key => pushComponent(target.buffer, ids[key], undefined), + // Each writes the buffer itself rather than through pushCommand and pushComponent: a kernel's row loop inlines one + // call, and a second one left as a call boxes every entity, which ids past 2^32 make a double. + begin: (kind, entity) => { + const buffer = target.buffer; + const count = buffer.count; + if (count === buffer.kinds.length) growCommands(buffer); + buffer.kinds[count] = kind; + buffer.shapes[count] = -1; + buffer.entities[count] = entity; + buffer.starts[count] = buffer.named; + buffer.count = count + 1; + }, + value: (key, value) => { + const buffer = target.buffer; + const named = buffer.named; + if (named === buffer.ids.length) growIds(buffer); + buffer.ids[named] = ids[key]; + buffer.values[named] = value === undefined ? starts[key] : value; + buffer.named = named + 1; + }, + component: key => { + const buffer = target.buffer; + const named = buffer.named; + if (named === buffer.ids.length) growIds(buffer); + buffer.ids[named] = ids[key]; + buffer.values[named] = undefined; + buffer.named = named + 1; + }, }); diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 92b3489b..568cb020 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -200,6 +200,9 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ - Commands as parallel typed arrays, a reusable typed plan, and cached archetype edges: create 198 → 68 ms, churn 22 → 10 ms, retag 24 → 11.7 ms, emit 7.3 → 3.1 ms (vitest). - `Object.keys` instead of `for…in`, and buffers reused across runs: small gains. - Allocation-free kernel commands (`begin`/`value`/`component`/`spawn`): emit 3.2 → 1.98 ms (vitest). +- Kernel commands write the buffer themselves, not through `pushCommand` and `pushComponent`: once V8 reoptimized a + kernel, the second call stayed a call and boxed each entity, a double past 2^32. `despawn bulk kernel` 4.60 → 4.12 ms; + `despawn bulk` compiled 4.55 ms against 4.32 as written before, 4.10 after. - Inline value writes instead of closures, an 8-shape spawn cache, edges indexed by component id, archetypes grown once per sync point: create 38 → 27.6 ms. **Change filters.** From 4d0f6be6cb9ca36b3a1231c6d3185dc6dceb21c7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:55:31 -0700 Subject: [PATCH 063/127] ecs: an unflagged body reading module data made by any expression runs as written, not just object and array literals Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/src/implementation/compiler/module-value.ts | 5 +++-- packages/ecs/src/implementation/compiler/transform.test.ts | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 00ce32fb..2318a61b 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -110,7 +110,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. - - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module object or array of data (which may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles. + - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module value but primitive constants, functions and the interface's own values (an object or array holding data, however it's made, may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles. - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. - Type-checked where required: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. diff --git a/packages/ecs/src/implementation/compiler/module-value.ts b/packages/ecs/src/implementation/compiler/module-value.ts index 242da483..331bb260 100644 --- a/packages/ecs/src/implementation/compiler/module-value.ts +++ b/packages/ecs/src/implementation/compiler/module-value.ts @@ -104,8 +104,9 @@ const definition = (node: Node, name: string, binding: ModuleBinding, module: Mo const compile = (): Value => unit.expression(init, rootScope(unit, true, module), out); const value = module === unit.module ? compile() : within(node, frameIn(module, display), compile); unit.active.delete(identity); - // An object or array of data may be written after the build, which inlined it; the interface's own are fixed. - if (unit.tried && (init.type === "ObjectExpression" || init.type === "ArrayExpression") && holdsData(value) && packagePathOf(module.file)?.startsWith("interface/") !== true) { + // An object or array holding data may be written after the build, whatever made it, and a kernel would have + // inlined it; a primitive const can't change, and the interface's own objects are fixed. + if (unit.tried && value.kind !== "scalar" && holdsData(value) && packagePathOf(module.file)?.startsWith("interface/") !== true) { return fail(node, `${display} holds values that may change, which a kernel would inline`); } return value; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index d614b485..5ff08fb0 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -339,6 +339,10 @@ describe("kernels by default", () => { test("runs as written a body reading data of a module object or array, which may change, and compiles one reading constants and functions", async () => { expect(await attached(`export const scene = { subject: 0 }; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe(false); expect(await attached(`const values = [1, 2]; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = values[0]; });`)).toBe(false); + expect(await attached(`const make = () => ({ speed: 2 }); export const settings = make(); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = settings.speed; });`)).toBe(false); + expect(await attached(`const pair = (a, b) => [a, b]; export const range = pair(1, 2); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = range[1]; });`)).toBe(false); + expect(await attached(`const wide = true; export const shape = wide ? { size: 2 } : { size: 1 }; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = shape.size; });`)).toBe(false); + expect(await attached(`const make = () => 3; const three = make(); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = three; });`)).toBe(true); expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); }); From 0698d928ce457c2283d274da4662414a0dc5715c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 18:59:21 -0700 Subject: [PATCH 064/127] ecs: a kernel compiled unflagged computes at the precision of its body as written; only kernel: true may use f32 lanes Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../implementation/compiler/compile-kernel.ts | 1 + .../ecs/src/implementation/world/kernel.ts | 5 ++ .../ecs/src/implementation/world/kernels.ts | 3 +- .../implementation/world/precision.test.ts | 56 +++++++++++++++++++ packages/ecs/src/implementation/world/wasm.ts | 5 +- .../ecs/src/interface/system/declaration.ts | 6 +- 7 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 packages/ecs/src/implementation/world/precision.test.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 2318a61b..11637cf3 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -233,7 +233,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. 1. **Kernel subset.** Kernels compile to efficient JS or WASM. Main-thread systems run as normal JS with every feature available. - **Parser:** the bundler's own (`this.parse`, ESTree, in both Vite and Rolldown); the compiler validates the subset on that tree, and an ESLint rule can share the validator since ESLint also produces ESTree. A second JS parser would only add size and upkeep. - **Closed world:** every input comes from the ECS (query rows, resources). - - **Precision:** arithmetic has at least the storage precision. Results are not bit-exact across backends (JS, WASM, GPU). + - **Precision:** a CPU kernel compiled without `kernel: true` computes as its body as written does, in f64, rounding only where it stores, in JS and WASM alike, so compiling it never changes a result. A kernel the declaration asks for may compute at storage precision, as WASM's f32 SIMD lanes do, rounding each step: about 4.4 times faster for `move` and `chain` than one row at a time in f64, and not bit-exact with the body as written. GPU kernels compute in f32. - **Imported functions:** kernels call pure functions and use constants from any module the build resolves to a file, the kernel's own module included: user TypeScript or JavaScript, other packages' ESM, and the package's math types. The plugin reads each module a kernel reaches, with types stripped, follows its imports and re-exports, and inlines each call compiled in its own module's scope, so tuples are scalarized and never allocate, on JS, WASM and the GPU alike. A function outside the subset fails the build at the kernel's call, with a frame naming the file and line of each inlined function down to the failure. Recursion fails, also across modules. A module's constants must be known at build time: a `let`, or a mutation at runtime, isn't seen. Editing an inlined file compiles its kernels again, in dev too. - **Math types:** the package exports math types that are also namespaces of functions: `Vec2`, `Vec3`, `Vec4` (each `F32`, with `I32` and `U32` nested beside it), `Quat`, `Mat4x4`, and the scalars `F32`, `I32`, `U32`, each with a `schema`. They follow WGSL's semantics, and where WGSL leaves a result indeterminate they do what is fastest: integer vectors wrap, `round` rounds halfway values to even, `mod` is `x - m * floor(x / m)`, and `normalize` of a zero vector is NaN. Integer functions are defined for integers in range, and `pow` for a positive base; other inputs give indeterminate results, which may differ between the CPU and the GPU. The few that throw (`Mat4x4.inverse`, `Mat4x4.perspective`, `Mat4x4.lookAt`) can't be used in kernels. - **WGSL builtins:** a math function whose WGSL builtin matches it declares that explicitly, in its own file (`intrinsic({ wgsl: "normalize", parameters: ["vec3f"], result: "vec3f" }, implementation)`). In a GPU kernel a call to it becomes an intrinsic region holding its operands and its inlined body: WGSL emits the builtin on vector and matrix types, nesting calls as builtins do, and JS and WASM run the body, so CPU output is unchanged. Locals keep their JS names in WGSL, and a region's result is named after its function. diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index a173d223..0809fe32 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -105,6 +105,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: `url: ${url},`, `program: ${serialize(program)},`, `reducedShapes: [${[...unit.reducedShapes].join(", ")}],`, + ...unit.tried ? [] : ["storagePrecision: true,"], `reductions: [${unit.accumulators.map(entry => { const { resource, operator } = reduced[entry.reduced] ?? fail(declaration, "unknown reduction"); return `{ resource: ${resource}, operator: ${JSON.stringify(operator)}, path: ${JSON.stringify(entry.path)} }`; diff --git a/packages/ecs/src/implementation/world/kernel.ts b/packages/ecs/src/implementation/world/kernel.ts index e6344376..0941afbc 100644 --- a/packages/ecs/src/implementation/world/kernel.ts +++ b/packages/ecs/src/implementation/world/kernel.ts @@ -243,6 +243,11 @@ export type KernelDescription = { readonly reductions: readonly Reduced[]; /** The leaves of each value contributed whole, by resource. Registration checks they are all of the schema's leaves. */ readonly reducedShapes: readonly Shape[]; + /** + * Whether its arithmetic may round each step to storage precision, as SIMD lanes do: only for a kernel the + * declaration asks for. A body compiled unflagged computes as the body as written does. + */ + readonly storagePrecision?: true; }; /** What a kernel module at `KernelDescription.url` exports. */ diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index 1cbc3ad1..e5723a5b 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -253,7 +253,8 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript const filterCount = up !== undefined ? 0 : (query?.added?.length ?? 0) + (query?.changed?.length ?? 0); const generate = (muted: readonly boolean[]): WasmKernel | null => useWasm && description.program !== null ? generateWasm(description.program, wasmColumns.map((column, k) => muted[k] ? { ...column, muted: true } : column), scalarType, state.simd, filterCount, wasmLookups, upWasmColumns, - description.reductions.map(reduction => reduction.operator), indexes.map(bound => bound?.wasm ?? null), (description.probes ?? []).map(probe => probe.lookup)) + description.reductions.map(reduction => reduction.operator), indexes.map(bound => bound?.wasm ?? null), (description.probes ?? []).map(probe => probe.lookup), + description.storagePrecision === true) : null; let muted = mutedNow(); let index = state.kernelIndexes.get(description.url); diff --git a/packages/ecs/src/implementation/world/precision.test.ts b/packages/ecs/src/implementation/world/precision.test.ts new file mode 100644 index 00000000..f71472fc --- /dev/null +++ b/packages/ecs/src/implementation/world/precision.test.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { createWorld } from "./create.js"; + +const a = Symbol("precisionTest/a"); +const c = Symbol("precisionTest/c"); +const difference = Symbol("precisionTest/difference"); + +declare module "@adobe/ecs" { + export interface Components { + [a]: { type: F32 }; + [c]: { type: F32 }; + [difference]: { type: F32 }; + } +} + +// a * a is 1 + 2⁻²² + 2⁻⁴⁶, which f32 rounds to c, so only arithmetic at f64 keeps the 2⁻⁴⁶. +const seed = System.create(({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([a, c, difference])({ [a]: 1 + 2 ** -23, [c]: 1 + 2 ** -22, [difference]: 0 }); +}); +const tried = System.create({ components: { read: [a, c], write: [difference] } }, row => { + row[difference] = row[a] * row[a] - row[c]; +}); +const written = System.create({ kernel: false, components: { read: [a, c], write: [difference] } }, row => { + row[difference] = row[a] * row[a] - row[c]; +}); +const seen: number[] = []; +const read = System.create({ kernel: false, components: { read: [difference] } }, row => { + seen.push(row[difference]); +}); + +const differenceAfter = async (system: typeof tried, wasm: boolean): Promise => { + const world = createWorld({ [workers]: 0 }, { wasm }); + try { + await world.run(seed); + await world.run(system); + seen.length = 0; + await world.run(read); + return seen[0]; + } + finally { + await world.dispose(); + } +}; + +it("computes an unflagged kernel at the precision the body as written does, in JS and WASM", async () => { + expect(kernelOf(tried)).toBeDefined(); + const expected = await differenceAfter(written, true); + expect(expected).toBe(Math.fround(2 ** -46)); + expect(await differenceAfter(tried, true)).toBe(expected); + expect(await differenceAfter(tried, false)).toBe(expected); +}); diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts index 167ce43c..d1d5c661 100644 --- a/packages/ecs/src/implementation/world/wasm.ts +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -210,6 +210,8 @@ export const generateWasm = ( indexLookups: readonly (WasmIndexLookup | null)[] = [], /** By probe, its lookup: `KernelDescription.probes[k].lookup`. */ probeLookups: readonly number[] = [], + /** Whether SIMD lanes may compute at storage precision, as only an explicit kernel's may: f32 rounds each step. */ + storagePrecision = true, ): WasmKernel | null => { const program = withoutIntrinsics(source); const scalars: WasmScalar[] = []; @@ -2117,7 +2119,8 @@ export const generateWasm = ( // Across-rows mode doesn't filter rows, so filtered kernels fuse within rows instead. // Accumulators carry from row to row, which neither SIMD mode keeps. // Listed rows aren't consecutive, so `up` programs run one row at a time. - let mode: "rows" | "lanes" | "scalar" = !simdEnabled || accumulators.length > 0 || upMode ? "scalar" : filters > 0 ? "lanes" : "rows"; + // Lanes compute in f32, so a kernel computing at the body's own precision runs one row at a time in f64. + let mode: "rows" | "lanes" | "scalar" = !simdEnabled || !storagePrecision || accumulators.length > 0 || upMode ? "scalar" : filters > 0 ? "lanes" : "rows"; try { generate(0, mode); } diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 5548b1ff..79bb41f8 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -15,8 +15,10 @@ export type Declaration = { * Whether its body compiles to a kernel, a function the build compiles to run on every matched row, in parallel, on * the kernel pool. Values in `components` and `lookup` must be structs and resources read-only. * - Without it, a system with components compiles when its body is in the kernel subset and its declaration allows - * it, and otherwise runs as written. A system without components runs once, as written. - * - `true`: compiles, or the build fails saying why. + * it, and otherwise runs as written, computing as the body as written does. A system without components runs once, + * as written. + * - `true`: compiles, or the build fails saying why. Its arithmetic may round each step to storage precision, as + * WASM's f32 SIMD lanes do, so its results may differ from the body as written. * - `false`: runs as written, on its thread. * - `"gpu"`: also has no lookups, change filters, commands or loops, and only 4-byte scalar values, its `up` * included. It runs as a compute shader in a world with a GPU, and as a kernel on the pool in one without. Every From 7ccf18a7974432c5b7e231bb7aa3def79456c087 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:02:19 -0700 Subject: [PATCH 065/127] ecs: kernels failing to load on the pool a run starts are a failure in its report, and run here from then on Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../ecs/src/implementation/world/create.ts | 29 +++++++++++++- .../implementation/world/pool-size.cases.ts | 8 ++++ .../implementation/world/pool-size.fixture.ts | 39 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 11637cf3..e75e322a 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -114,7 +114,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. - Type-checked where required: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. - - **The pool starts once rows warrant it:** while no archetype holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. + - **The pool starts once rows warrant it:** while no archetype holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index aff9d7a8..c1f633ec 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -219,12 +219,36 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti return false; }; + /** Whether kernels failed to load on the workers a run started: then they run here, and no pool starts again. */ + let workersFailed = false; + + /** + * Starts the pool for a run once it's warranted. Kernels failing to load on it are a `"thread"` failure in the run's + * report, and the pool ends, so kernels run here from then on rather than every run trying again. + */ + const startWorkers = async (): Promise => { + try { + await loadOnWorkers(); + } + catch (error) { + workersFailed = true; + workersBehind = false; + const failed = pool; + pool = null; + context.workers = null; + context.publish = () => {}; + for (const runner of registrar.kernels()) runner.kernel?.unloadedOnWorkers(); + if (failed !== null) void failed.terminate(); + (observerFailures ??= []).push(failureOf("thread", { name: "workers", id: null }, error)); + } + }; + /** * Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration * reloaded, starting the pool once it's warranted. */ const loadOnWorkers = async (): Promise => { - if (workerCount === 0 || !workersBehind || (pool === null && !warranted())) return; + if (workerCount === 0 || !workersBehind || (pool === null && (workersFailed || !warranted()))) return; const behind = registrar.kernels().filter(runner => runner.kernel?.staleOnWorkers() === true); if (behind.length === 0) { workersBehind = false; @@ -347,6 +371,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti const wanted = platform === null ? 0 : workersOf(platform, readResource(workers)); if (wanted === workerCount) return; workerCount = wanted; + workersFailed = false; state.workers = wanted; const old = pool; pool = null; @@ -443,7 +468,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti ensureProgramResources(state, ensured); if (startWatches(state)) await reloadStamped(); if (workersChanged()) await followWorkers(); - if (pool === null && workersBehind && workerCount > 0 && warranted()) await loadOnWorkers(); + if (pool === null && workersBehind && !workersFailed && workerCount > 0 && warranted()) await startWorkers(); if (deviceChanged()) await followDevice(); const schedule = registrar.registered(definition) ?? registered; if (schedule === undefined) throw new Error(`System "${definition.name}" didn't register.`); diff --git a/packages/ecs/src/implementation/world/pool-size.cases.ts b/packages/ecs/src/implementation/world/pool-size.cases.ts index bdb52dda..9a504f6f 100644 --- a/packages/ecs/src/implementation/world/pool-size.cases.ts +++ b/packages/ecs/src/implementation/world/pool-size.cases.ts @@ -20,6 +20,14 @@ export const poolSizeCases = (load: () => Promise) => describe(" expect(started).toEqual([0, fixture.platformCount()]); expect(values).toEqual([0, 4, 8, 12]); }); + it("reports a kernel that fails to load on workers as the pool starts, and runs it here from then on", async () => { + const fixture = await load(); + const { failures, loads, values } = await fixture.failedLoad(); + expect(failures[0]).toEqual([expect.stringMatching(/^thread: .*no kernel here/)]); + expect(failures[1]).toEqual([]); + expect(loads[1]).toBe(loads[0]); + expect(values).toEqual([0, 4, 8, 12]); + }); it("sizes the kernel pool at the next sync point, with the same results", async () => { const fixture = await load(); const { started, values } = await fixture.resized(2, [3, 0]); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index b32604cc..a72be624 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -5,6 +5,8 @@ import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { Observe } from "../../observe/index.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import { reportOf } from "./reports.test-helper.js"; +import type { WorkerPlatform } from "./worker-platform.js"; import type { F32 } from "../../interface/math/index.js"; /** Scenarios of the `workers` resource sizing the pool, run built in node and from source in chromium. */ @@ -88,3 +90,40 @@ export const whenWarranted = async (): Promise<{ readonly started: readonly numb await world.dispose(); } }; + +/** The platform's workers, failing every kernel load: each kernel's module is one that throws. */ +const failingLoads = (): { readonly platform: WorkerPlatform; readonly loads: () => number } => { + const counting = countingWorkers(); + const broken = "data:text/javascript,throw new Error('no kernel here')"; + const start = () => { + const port = counting.platform.start(); + if (port === null) return null; + return { ...port, post: ((message, transfer) => port.post(message.kind === "kernels" ? { ...message, kernels: message.kernels.map(kernel => ({ ...kernel, url: broken })) } : message, transfer)) satisfies typeof port.post }; + }; + return { platform: { count: counting.platform.count, start }, loads: () => counting.posted("kernels") }; +}; + +/** + * A kernel whose load fails on workers when the pool starts: the failures of that run's report and of the next, the + * kernel loads posted after each, and the values its runs doubled, here. + */ +export const failedLoad = async (): Promise<{ readonly failures: readonly (readonly string[])[]; readonly loads: readonly number[]; readonly values: readonly (number | null)[] }> => { + const failing = failingLoads(); + const world = createWorld({ [workers]: 2 }, { minimumChunk: 64, pool: failing.platform }); + try { + // Registered while no archetype warrants workers, so the pool starts in a later run. + await world.run(double); + await world.run(seed); + const failures: string[][] = []; + const loads: number[] = []; + for (let i = 0; i < 2; i++) { + const run = await world.run(double); + failures.push((await reportOf(world, run)).failures.map(failure => `${failure.kind}: ${failure.message}`)); + loads.push(failing.loads()); + } + return { failures, loads, values: (await readEach(world, await select(world, { required: [value] }), value)).slice(0, 4) }; + } + finally { + await world.dispose(); + } +}; From 86616e979e02c3db5a974858cf59f2abff89cd20 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:06:10 -0700 Subject: [PATCH 066/127] ecs: the pool starts for rows a kernel matches, which kernels report as they run, with no scan of archetypes per run Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../ecs/src/implementation/world/create.ts | 37 ++++++++++++------- .../implementation/world/pool-size.cases.ts | 3 ++ .../implementation/world/pool-size.fixture.ts | 26 +++++++++++++ .../ecs/src/implementation/world/runner.ts | 6 ++- .../ecs/src/implementation/world/state.ts | 8 ++++ packages/ecs/threads.md | 2 +- 7 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index e75e322a..51796d15 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -114,7 +114,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. - Type-checked where required: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. - - **The pool starts once rows warrant it:** while no archetype holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes. + - **The pool starts once rows warrant it:** while no archetype a kernel matches holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. A kernel registering looks at the archetypes it matches; one running here over that many rows starts the pool after its run's sync point, so nothing scans archetypes per run. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index c1f633ec..bd4b28a1 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -189,11 +189,15 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti ports.push(port); } workerCount = state.workers = ports.length; - if (ports.length === 0) return null; + if (ports.length === 0) { + followPoolThreshold(); + return null; + } const started = createPool(ports, state.memory); pool = started; context.workers = { pool: started, minimumChunk, next: 0 }; context.publish = createPublisher(state, started); + followPoolThreshold(); return started; }; @@ -210,20 +214,24 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti return loadAll(runners); }; - /** - * Whether some archetype has rows enough for a kernel to run on workers, half a piece as the fastest kernels split - * at: until then kernels run here, and the pool isn't started. - */ - const warranted = (): boolean => { - for (const archetype of state.archetypes) if (archetype.count >= minimumChunk / 2) return true; + /** Whether kernels failed to load on the workers a run started: then they run here, and no pool starts again. */ + let workersFailed = false; + + /** Whether an archetype a kernel matches has rows enough for workers, as kernels loading look before the pool starts. */ + const kernelsWarrant = (): boolean => { + for (const runner of registrar.kernels()) for (const archetype of runner.matched()) if (archetype.count >= state.poolThreshold) return true; return false; }; - /** Whether kernels failed to load on the workers a run started: then they run here, and no pool starts again. */ - let workersFailed = false; + /** Lets kernels running here want the pool, at half a piece, while one could start; once started, none want it. */ + const followPoolThreshold = (): void => { + state.poolThreshold = pool === null && workerCount > 0 && !workersFailed ? minimumChunk / 2 : Infinity; + state.poolWanted = false; + }; + followPoolThreshold(); /** - * Starts the pool for a run once it's warranted. Kernels failing to load on it are a `"thread"` failure in the run's + * Starts the pool after a run whose kernel ran here over rows enough for workers. Kernels failing to load on it are a `"thread"` failure in the run's * report, and the pool ends, so kernels run here from then on rather than every run trying again. */ const startWorkers = async (): Promise => { @@ -241,14 +249,15 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti if (failed !== null) void failed.terminate(); (observerFailures ??= []).push(failureOf("thread", { name: "workers", id: null }, error)); } + followPoolThreshold(); }; /** * Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration - * reloaded, starting the pool once it's warranted. + * reloaded, starting the pool once a kernel ran here over rows enough for workers. */ const loadOnWorkers = async (): Promise => { - if (workerCount === 0 || !workersBehind || (pool === null && (workersFailed || !warranted()))) return; + if (workerCount === 0 || !workersBehind || (pool === null && !state.poolWanted && !kernelsWarrant())) return; const behind = registrar.kernels().filter(runner => runner.kernel?.staleOnWorkers() === true); if (behind.length === 0) { workersBehind = false; @@ -379,6 +388,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti context.publish = () => {}; for (const runner of registrar.kernels()) runner.kernel?.unloadedOnWorkers(); workersBehind = true; + followPoolThreshold(); // The old pool releases its control state once its workers have left their windows; runs don't wait for it. if (old !== null) void old.terminate(); await loadOnWorkers(); @@ -468,7 +478,6 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti ensureProgramResources(state, ensured); if (startWatches(state)) await reloadStamped(); if (workersChanged()) await followWorkers(); - if (pool === null && workersBehind && !workersFailed && workerCount > 0 && warranted()) await startWorkers(); if (deviceChanged()) await followDevice(); const schedule = registrar.registered(definition) ?? registered; if (schedule === undefined) throw new Error(`System "${definition.name}" didn't register.`); @@ -498,6 +507,8 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti context.tails = null; if (deviceChanged()) await followDevice(); } + // A kernel that ran here over rows enough for workers asked for the pool, which the next run's kernels use. + if (state.poolWanted && workersBehind) await startWorkers(); const values = valuesOf(run, started, report.dropped); if (!pass) { tailing = tails; diff --git a/packages/ecs/src/implementation/world/pool-size.cases.ts b/packages/ecs/src/implementation/world/pool-size.cases.ts index 9a504f6f..3db1e80e 100644 --- a/packages/ecs/src/implementation/world/pool-size.cases.ts +++ b/packages/ecs/src/implementation/world/pool-size.cases.ts @@ -20,6 +20,9 @@ export const poolSizeCases = (load: () => Promise) => describe(" expect(started).toEqual([0, fixture.platformCount()]); expect(values).toEqual([0, 4, 8, 12]); }); + it("starts no pool for rows no kernel runs over", async () => { + expect(await (await load()).unmatched()).toBe(0); + }); it("reports a kernel that fails to load on workers as the pool starts, and runs it here from then on", async () => { const fixture = await load(); const { failures, loads, values } = await fixture.failedLoad(); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index a72be624..5babfab4 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -18,6 +18,16 @@ declare module "@adobe/ecs" { } } +const other = Symbol("poolSizeTest/other"); +declare module "@adobe/ecs" { + export interface Components { + [other]: { type: F32 }; + } +} +const others = System.create({}, ({ commands }) => { + for (let i = 0; i < 4096; i++) commands.spawn([other])({ [other]: i }); +}); + const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); }); @@ -127,3 +137,19 @@ export const failedLoad = async (): Promise<{ readonly failures: readonly (reado await world.dispose(); } }; + +/** Workers started after a kernel runs over few rows in a world whose largest archetype no kernel matches. */ +export const unmatched = async (): Promise => { + const counting = countingWorkers(); + const world = createWorld({}, { minimumChunk: 64, pool: counting.platform }); + try { + await world.run(others); + await world.run(few); + await world.run(double); + await world.run(double); + return counting.started(); + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 58c7bbd2..2c54c688 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -962,6 +962,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { return chunks; } if (up !== null) { + if (workers === null && kernel !== null) for (const archetype of matches) if (archetype.count >= state.poolThreshold) state.poolWanted = true; const parallel = runUp(since, values, args, tick, workers, index); if (parallel !== null) chunks.push(parallel); return chunks; @@ -978,7 +979,10 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { chunks.push(...postChunks(workers, index, archetype, from, values, args, tick)); chunks.push(nextChunk()); } - else runKernelLocally(kernel, archetype, from, values, args, tick); + else { + if (archetype.count >= state.poolThreshold) state.poolWanted = true; + runKernelLocally(kernel, archetype, from, values, args, tick); + } } return chunks; } diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 6efe3b92..9cc7987e 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -165,6 +165,12 @@ export type State = { /** Whether kernels with a program run as WASM, and as SIMD where they can; tests turn these off to compare with JS. */ readonly wasm: boolean; readonly simd: boolean; + /** + * Rows of an archetype a kernel runs here at which it wants the pool, while none is started: infinite with a pool, + * or without workers to start. A kernel reaching it sets `poolWanted`, and the next run starts the pool. + */ + poolThreshold: number; + poolWanted: boolean; /** The sync point's plan, kept from planning to applying. */ readonly plan: Plan; /** A sync point between planning and applying: its queued buffers, reservations and tick. */ @@ -298,6 +304,8 @@ export const createState = (shared?: boolean, wasm = true, simd = true, gpu = fa rows: new Map(), wasm, simd, + poolThreshold: Infinity, + poolWanted: false, plan: createPlan(), syncing: null, carried: null, diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index df98ce18..cb942708 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -266,7 +266,7 @@ after anything it reported first, so an owner's report wins over the close its e world's thread, and `start` starts one worker, or returns null when it has no more, so a pool takes as many as it gets. The platform's own is browser workers where `Worker` exists, nested ones on a worker, else `worker_threads`, else none. `WorldOptions`' `pool` gives another, or null for none: `lentWorkers(lent)` for workers another thread - lent. A world starts its pool once it has a kernel and some archetype holds half a piece of rows for a worker, as + lent. A world starts its pool once an archetype some kernel matches holds half a piece of rows for a worker, as the fastest kernels split, so a small world starts none. - **Lending:** the owning thread starts each worker, posts it the `MessagePort` it talks on as its first message, and keeps a second channel to the thread it's lent to, on which it reports the worker's death and ends it when asked; a From 4ad8494b876f7c2cd92bedc666b2ab19209e93b0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:09:44 -0700 Subject: [PATCH 067/127] ecs: a library's reduction operators are read however its bundle wrote the registrations; a dist without them says how to build it Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 18 ++++++- .../implementation/compiler/augmentations.ts | 50 +++++++++++++++---- .../ecs/src/implementation/compiler/lists.ts | 11 ++-- .../implementation/compiler/plugin.test.ts | 22 ++++++++ .../ecs/src/implementation/compiler/plugin.ts | 3 +- .../src/implementation/compiler/signature.ts | 10 +++- .../implementation/compiler/transform.test.ts | 2 +- 7 files changed, 96 insertions(+), 20 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 6d5a6425..f14e5a9f 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, test } from 'vitest'; -import { augmentationInsertions } from './augmentations.js'; +import { parseAst } from 'vite'; +import { augmentationInsertions, registeredReductionsIn } from './augmentations.js'; import { insertInto } from './source-map.js'; import { hostOf } from './type-host.test-helper.js'; import { moduleOf } from './type-schemas.js'; @@ -200,3 +201,18 @@ const after = 1;`); expect(await passed(block(`interface Resources { "time": { type: number; default: 0 } }`))).toContain("a resource's key is a `Symbol()` constant"); }); }); + +describe("registeredReductionsIn", () => { + const read = (code: string) => Object.fromEntries(registeredReductionsIn(parseAst(code, { lang: "js" }))); + test("reads the operators a library's registrations give, however its bundle wrote the calls", () => { + expect(read(`import { declareResource$ } from "@adobe/ecs/declare"; export const total = Symbol(); declareResource$(total, { type: {}, reduce: "+" });`)).toEqual({ total: "+" }); + expect(read(`import { declareResource$ as d } from "@adobe/ecs/declare"; const t = Symbol(), m = Symbol(); d(t, { reduce: "+" }), d(m, { reduce: "min" }); export { t as total, m as low };`)).toEqual({ t: "+", m: "min" }); + expect(read(`import { declareResource$ as d } from "@adobe/ecs/declare"; const t = Symbol(); void d(t, { reduce: "+" });`)).toEqual({ t: "+" }); + expect(read(`import * as e from "@adobe/ecs/declare"; const t = Symbol(); e.declareResource$(t, { reduce: "+" });`)).toEqual({ t: "+" }); + expect(read(`import { a as d } from "./chunk-x.js"; const t = Symbol(); d(t, { reduce: "+" });`)).toEqual({ t: "+" }); + }); + test("ignores calls of other functions on keys that aren't symbols", () => { + expect(read(`import { a as d } from "./chunk-x.js"; const t = 1; d(t, { reduce: "+" });`)).toEqual({}); + expect(read(`import * as e from "./other.js"; const t = Symbol(); e.other(t, { reduce: "+" });`)).toEqual({}); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index dfd6442a..bbbaa0f7 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -56,26 +56,56 @@ export const reductionsIn = (program: TypedProgram): ReadonlyMap return found; }; +type Expression = Extract["expression"]; +type Call = Extract; + +/** A call's statement forms as a bundle leaves them: alone, in a sequence, or `void`ed. */ +const callsOf = (expression: Expression): readonly Call[] => { + if (expression.type === "CallExpression") return [expression]; + if (expression.type === "SequenceExpression") return expression.expressions.flatMap(callsOf); + if (expression.type === "UnaryExpression" && expression.operator === "void") return callsOf(expression.argument); + return []; +}; + /** * The operator each resource a module's generated registrations, `declareResource$(key, { ..., reduce })`, give, by its - * key's binding: what a library's build in library mode leaves of its declarations in its JavaScript. + * key's binding: what a library's build in library mode leaves of its declarations in its JavaScript. The call may be + * named or namespace-imported, under any local name, or, as a bundle that renamed its import leaves it, any call of a + * top-level `Symbol()` and a declaration giving `reduce`. */ export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap => { const locals = new Set(); + const namespaces = new Set(); + const symbols = new Set(); for (const statement of program.body) { - if (statement.type !== "ImportDeclaration") continue; - for (const specifier of statement.specifiers) { - if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "declareResource$") locals.add(specifier.local.name); + if (statement.type === "ImportDeclaration") { + for (const specifier of statement.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "declareResource$") locals.add(specifier.local.name); + if (specifier.type === "ImportNamespaceSpecifier") namespaces.add(specifier.local.name); + } + } + const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue; + for (const declarator of declaration.declarations) { + const init = declarator.init; + if (declarator.id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") symbols.add(declarator.id.name); } } + /** Whether a call registers a resource: through the import, under any name, or, renamed by a bundle, of a `Symbol()` key. */ + const declares = ({ callee }: Call, key: string): boolean => + callee.type === "Identifier" ? locals.has(callee.name) || symbols.has(key) + : callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && namespaces.has(callee.object.name) + && callee.property.type === "Identifier" && callee.property.name === "declareResource$"; const found = new Map(); for (const statement of program.body) { - if (locals.size === 0 || statement.type !== "ExpressionStatement" || statement.expression.type !== "CallExpression") continue; - const { callee, arguments: [key, declaration] } = statement.expression; - if (callee.type !== "Identifier" || !locals.has(callee.name) || key?.type !== "Identifier" || declaration?.type !== "ObjectExpression") continue; - for (const property of declaration.properties) { - if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "reduce") continue; - if (property.value.type === "Literal" && typeof property.value.value === "string") found.set(key.name, property.value.value); + if (statement.type !== "ExpressionStatement") continue; + for (const call of callsOf(statement.expression)) { + const [key, declaration] = call.arguments; + if (key?.type !== "Identifier" || declaration?.type !== "ObjectExpression" || !declares(call, key.name)) continue; + for (const property of declaration.properties) { + if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "reduce") continue; + if (property.value.type === "Literal" && typeof property.value.value === "string") found.set(key.name, property.value.value); + } } } return found; diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts index 3fc90fdc..d17d1509 100644 --- a/packages/ecs/src/implementation/compiler/lists.ts +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -25,9 +25,10 @@ export type Lists = { readonly component: (node: Node) => Named; /** * The operator the resource the kernel's module names `name` reduces with, its declaration's `reduce` trait, read - * through the build from the module declaring it; undefined where the build can't read one. + * through the build from the module declaring it, undefined where the build can't read one, and that module's file; + * null for a name the module doesn't bind. */ - readonly reduction: (name: string) => string | undefined; + readonly reduction: (name: string) => { readonly operator: string | undefined; readonly file: string } | null; }; /** What a command's component argument names, or why the compiler can't tell. */ @@ -204,12 +205,12 @@ export const createLists = (kernel: Module, link: Link | null, nodes: readonly N // its definition where it has one. return { kind: "key", name: node.type === "Identifier" || found.key === null ? reference : nameOf(found.key, reference) }; }; - const reduction = (name: string): string | undefined => { + const reduction = (name: string): { readonly operator: string | undefined; readonly file: string } | null => { const binding = kernel.bindings.get(name); const identity = binding === undefined ? null : definition(kernel, name, binding, link); - if (identity === null) return undefined; + if (identity === null) return null; const [file = "", defined = ""] = identity.split("\0"); - return link?.reductionOf?.(file, defined); + return { operator: link?.reductionOf?.(file, defined), file }; }; return { names, component, reduction }; }; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index b3b9e4cc..25bd9e33 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -382,6 +382,28 @@ export const description = kernelOf(kernel); expect(description?.reductions.map(reduced => reduced.operator)).toEqual(["max"]); }); +test("an application's build says so when a library's dist doesn't register a resource it reduces", async () => { + const project = join(directory, "unregistered"); + await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "unregistered-app", type: "module" })); + mkdirSync(join(project, "node_modules/unregistered-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/unregistered-library/package.json"), JSON.stringify({ name: "unregistered-library", type: "module", exports: "./dist/index.js" })); + // As a build without library mode leaves it: the types gone and no registrations in their place. + writeFileSync(join(project, "node_modules/unregistered-library/dist/index.js"), `import { System } from "@adobe/ecs"; +const score = Symbol(); +const largest = Symbol(); +export const kernel = System.create({ kernel: true, components: { read: [score] }, resources: { reduce: [largest] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +`); + const application = join(project, "application.js"); + writeFileSync(application, `export { kernel } from "unregistered-library"; +`); + const built = build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir: join(project, "dist"), minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + await expect(built).rejects.toThrow("which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"); +}); + test("a build registers the keys a module declares, through the copy of the package the module resolves", async () => { const project = join(directory, "declaring"); const copy = await installCopy(project); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 47aa6cd3..a2e48002 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -76,7 +76,8 @@ const reductionsOf = (file: string): ReadonlyMap => { } if (!code.includes("reduce")) return noReductions; if (lang === "ts" || lang === "tsx") return code.includes("declare module") ? reductionsIn(parseTypes(code, { lang }, file)) : noReductions; - return code.includes("declareResource$") ? registeredReductionsIn(parseTypes(code, { lang }, file)) : noReductions; + // A bundle may have renamed the registrations' import, so any JavaScript giving `reduce` is read. + return registeredReductionsIn(parseTypes(code, { lang }, file)); }; /** Resolves imports through the build, from whichever transform runs. */ diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index df00ec84..8ad975c3 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -65,8 +65,14 @@ const keyLists = (node: Expression | Pattern, what: string, allowed: readonly st const reductions = (node: Expression | Pattern, lists: Lists): readonly Reduced[] => { const names = identifiers(node, "resources.reduce", lists); return names.map((resource): Reduced => { - const operator = lists.reduction(resource); - if (!isReduction(operator)) return fail(node, `${resource} is reduced, so the build reads its operator from its declaration's reduce trait, ${operator === undefined ? "which it can't find: declare the resource with reduce in the module that makes its key" : `"${operator}", which isn't one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`}`); + const found = lists.reduction(resource); + const operator = found?.operator; + if (!isReduction(operator)) { + const missing = found !== null && /\.m?js$/.test(found.file) + ? `which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })` + : "which it can't find: declare the resource with reduce in the module that makes its key"; + return fail(node, `${resource} is reduced, so the build reads its operator from its declaration's reduce trait, ${operator === undefined ? missing : `"${operator}", which isn't one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`}`); + } return { resource, operator }; }).map((entry, index, all) => all.findIndex(other => other.resource === entry.resource) === index ? entry : fail(node, `${entry.resource} is reduced twice`)); }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 5ff08fb0..ccb2ab52 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -477,7 +477,7 @@ const kernel = System.create({ kernel: true, components: { read: ${read} }, reso test("that can't be compiled fail with the reason", async () => { expect(await failure(reducing("total", "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); expect(await failure(reducing("total", "reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); - expect(await failure(reducing("total", "reduce[total](1);"), {}, {})).toBe("total is reduced, so the build reads its operator from its declaration's reduce trait, which it can't find: declare the resource with reduce in the module that makes its key"); + expect(await failure(reducing("total", "reduce[total](1);"), {}, {})).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, ${"which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"}`); expect(await failure(reducing("total", "reduce[total](1);").replace(`kernel: true`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); expect(await failure(reducing("total", "reduce[total](1);").replace("resources: { reduce:", "resources: { read: [total], reduce:"))).toBe("total is reduced, so it can't also be read or filtered on"); expect(await failure(reducing("total, total", "reduce[total](1);"))).toBe("total is reduced twice"); From f9389343acc5cefedf4a3102201fd306155f0fb3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:11:57 -0700 Subject: [PATCH 068/127] ecs: a union of number constants holding -0 stores as f64, and one without a default starts at its first constant Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 1 + .../src/implementation/compiler/augmentations.test.ts | 2 ++ .../src/implementation/world/constant-unions.test.ts | 10 ++++++++++ packages/ecs/src/implementation/world/state.ts | 6 +++++- packages/ecs/src/interface/registry/layout.test.ts | 2 ++ packages/ecs/src/interface/registry/layout.ts | 5 +++-- 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 51796d15..70939ba9 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -27,6 +27,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Structure of Arrays component storage. - Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them. +- **A union of number constants** (`0 | 1 | 2`, or an `as const` object's values) is a number: an integer column of the narrowest of u32 and i32 holding every constant, else f64, as with `-0`, which integers can't hold. A resource of one without a default starts at its first constant. A union with `null`, as `number | null` is, isn't a struct: it's stored as an object. - **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)). - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads, from copies in the stop list, as it reads its linear resources, so every resource as of before its stops' sync point applied, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. - **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there. diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index f14e5a9f..98dde657 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -189,6 +189,8 @@ const after = 1;`); expect(await passed(`import { elsewhere } from "./keys.js";\n${block(`interface Components { [elsewhere]: { type: number } }`)}`, files)).toContain(`elsewhere is imported from "./keys.js": declare it in the module that declares the key`); expect(await passed(`import type { layer } from "./keys.js";\n${keys("key")}${block(`interface Components { [key]: { type: true; [layer]: true } }`)}`, files)).toContain("layer, imported as a type only"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: number } }`)}`)).toContain("default"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain("declares the value it starts at"); + expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("declares the value it starts at"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); expect(await passed(`import type { Entity as E } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Entity; default: typeof E.none } }`)}`)).toContain("type only"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`); diff --git a/packages/ecs/src/implementation/world/constant-unions.test.ts b/packages/ecs/src/implementation/world/constant-unions.test.ts index 75e74a27..d86fe618 100644 --- a/packages/ecs/src/implementation/world/constant-unions.test.ts +++ b/packages/ecs/src/implementation/world/constant-unions.test.ts @@ -4,6 +4,8 @@ import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld } from "./create.js"; import { readEach, select } from "./reads.test-helper.js"; +import { declareResource$ } from "../../interface/registry/declare.js"; +import { defaultOf } from "./state.js"; const Frame = { fixed: 0, autoHeight: 1, autoWidth: 2 } as const; type Frame = (typeof Frame)[keyof typeof Frame]; @@ -31,3 +33,11 @@ it("stores a union of number constants as a number in shared memory, read beside await world.run(show); expect(await readEach(world, await select(world, { required: [frame] }), frame)).toEqual([2]); }); + +// Registered by hand without a default, as only code the build didn't generate can. +const mode = Symbol("constantUnionsTest/mode"); +declareResource$(mode, { type: { anyOf: [{ const: 1 }, { const: 2 }, { const: 4 }] } }); + +it("starts a resource of a union of number constants without a default at its first constant, a value of its type", () => { + expect(defaultOf(mode)).toBe(1); +}); diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 9cc7987e..00e50118 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -15,6 +15,7 @@ import type { FinalizerRunner, Thrown } from './finalizers.js'; import { createPlan, type Plan } from './plan.js'; import { createStructure, owner, restratify, weak, type Structure } from './structure.js'; import { layoutOf, objectLayout, type Layout } from '../../interface/registry/layout.js'; +import { numberConstants } from '../../interface/registry/schemas.js'; import { createMemory, type Memory } from './memory.js'; import { createStrings, type StringStore } from './strings.js'; import { createLayoutDirectory, type LayoutDirectory } from './layout-directory.js'; @@ -429,7 +430,10 @@ export const defaultOf = (key: Key): unknown => { if (declaration === undefined) return null; if ("default" in declaration) return declaration.default; const { type } = declaration; - return type !== undefined && "default" in type ? type.default : null; + if (type === undefined) return null; + if ("default" in type) return type.default; + // A union of number constants starts at its first, so its value is always one of them. + return numberConstants(type)?.[0] ?? null; }; /** diff --git a/packages/ecs/src/interface/registry/layout.test.ts b/packages/ecs/src/interface/registry/layout.test.ts index a0020015..fa798865 100644 --- a/packages/ecs/src/interface/registry/layout.test.ts +++ b/packages/ecs/src/interface/registry/layout.test.ts @@ -44,6 +44,8 @@ describe("layoutOf", () => { expect(views({ anyOf: [{ const: 1 }, { const: -1 }] })).toEqual(["i32"]); expect(views({ anyOf: [{ const: 0.5 }, { const: 1 }] })).toEqual(["f64"]); expect(views({ anyOf: [{ const: 0 }, { const: 2 ** 40 }] })).toEqual(["f64"]); + // An integer column would store -0 as 0. + expect(views({ anyOf: [{ const: -0 }, { const: 1 }] })).toEqual(["f64"]); expect(views({ type: "object", properties: { mode: { anyOf: [{ const: 0 }, { const: 1 }] }, at: f32 } })).toEqual(["u32", "f32"]); expect(views({ anyOf: [{ const: 0 }, { const: "a" }] })).toBe("object"); expect(views({ anyOf: [{ const: 0 }, { type: "null" }] })).toBe("object"); diff --git a/packages/ecs/src/interface/registry/layout.ts b/packages/ecs/src/interface/registry/layout.ts index 2abcaf9e..a9dc33a9 100644 --- a/packages/ecs/src/interface/registry/layout.ts +++ b/packages/ecs/src/interface/registry/layout.ts @@ -83,10 +83,11 @@ const integerScalar = ({ minimum, maximum }: { readonly minimum?: number; readon /** `null` if the schema isn't linear: numbers, booleans, fixed-length tuples and structs of those. */ const shapeOf = (schema: Schema): Shape | null => { - // A union of number constants is a number: integers at the narrowest width holding them. + // A union of number constants is a number: integers at the narrowest width holding them, and with -0, which an + // integer can't hold, f64. const constants = numberConstants(schema); if (constants !== null) { - return { kind: "scalar", scalar: constants.every(Number.isInteger) ? integerScalar({ minimum: Math.min(...constants), maximum: Math.max(...constants) }) : "f64" }; + return { kind: "scalar", scalar: constants.every(constant => Number.isInteger(constant) && !Object.is(constant, -0)) ? integerScalar({ minimum: Math.min(...constants), maximum: Math.max(...constants) }) : "f64" }; } if (!("type" in schema)) return null; switch (schema.type) { From 304f6acdd1b96fc57521040c5ea3ae412c7e4a51 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:13:00 -0700 Subject: [PATCH 069/127] ecs: kernel: "gpu" on a system without components is a type error, as the build already said Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/interface/system/create.ts | 2 +- packages/ecs/src/interface/system/system.type-test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index e4a0722e..4af69e51 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -112,7 +112,7 @@ type Validate = & (Property extends undefined ? { readonly up?: { readonly [key: string]: never }; readonly order?: { readonly "error: a system without components runs once, in no order": true }; - readonly kernel?: false | "gpu" | { readonly "error: a system without components runs once, so there's nothing to compile": true }; + readonly kernel?: false | { readonly "error: a system without components runs once, so there's nothing to compile": true }; } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) & (D extends { readonly observer: true } ? Observer : {}) diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 7abd07e0..2b5ad03c 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -295,6 +295,11 @@ System.create({ kernel: true, resources: { read: [time] }, }, () => {}); +System.create({ + // @ts-expect-error a system without components runs once, so there's nothing to compile, for the GPU either + kernel: "gpu", + resources: { read: [time] }, +}, () => {}); // Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. System.create({ From 35668e0121ba072cfceb399a85ac85225052efa1 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:13:19 -0700 Subject: [PATCH 070/127] ecs: docs say the plugin's worker.format default applies to every worker the app bundles Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/src/guide/guide.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 70939ba9..13fa758a 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -45,7 +45,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - It finds systems by resolving the import binding of the system factory. - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. + - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index d85e43cd..b6a96b37 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -45,7 +45,9 @@ Each component and resource is declared once, in the module that makes its key, `Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its precision or width. Traits of your own augment `Traits`. A library builds with Vite and -`Compiler.plugin({ library: true })`, so its build ships its registrations. The examples create worlds with +`Compiler.plugin({ library: true })`, so its build ships its registrations. Kernels run on workers as separate +chunks, so the plugin sets Vite's `worker.format` to `"es"` when the config doesn't set it, for every worker the app +bundles, its own included, which then load as module workers (`{ type: "module" }`). The examples create worlds with `World.create()`. ## Basics From 501d0ce13bc39e2b6535d3ca279f25680ef2883b Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:14:23 -0700 Subject: [PATCH 071/127] ecs: calls made in another's arguments get labels too, and labels wait in a WeakMap, keeping no declaration alive Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/identify.test.ts | 19 ++++++++++++++----- .../src/implementation/compiler/identify.ts | 8 +++----- packages/ecs/src/interface/registry/ids.ts | 7 ++++--- packages/ecs/src/observe/world-state.ts | 4 ++-- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index 3f7b43a3..73630ef8 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -134,14 +134,23 @@ describe("identify, for systems, rules and finalizers", () => { ]); expect(result?.code).not.toContain("identify$"); }); - test("keeps a label already given, as an internal factory gives its own", async () => { - const result = await identify(`import { System } from "@adobe/ecs";\nfunction make(given) { return System.create(given); }`); + test("labels a call made in another's arguments by the name nearest it", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nfunction make() { return System.create({ systems: [System.create({ tag: "nested" })] }); }`); + expect([...(result?.code ?? "").matchAll(/label\$\((\{ tag[^}]*\}), "(\w+)"\)/g)].map(match => [match[1], match[2]])).toEqual([[`{ tag: "nested" }`, "systems"]]); + }); + test("keeps a label already given, as an internal factory gives its own, in a map holding declarations weakly", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nfunction make(given) { return System.create(given); }\nfunction fresh() { return System.create({}); }`); const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); - const labels = new Map([[{}, "given"]]); - const [declaration] = labels.keys(); + const declaration = {}; + const labels = new WeakMap([[declaration, "given"]]); Reflect.set(globalThis, Symbol.for("@adobe/ecs/labels@1"), labels); - new Function(`${evaluated}\nreturn make;`)()(declaration); + const [make] = new Function(`${evaluated}\nreturn [make, fresh];`)(); + make(declaration); expect(labels.get(declaration)).toBe("given"); + // A declaration whose constructor throws before taking its label isn't kept alive by it. + Reflect.deleteProperty(globalThis, Symbol.for("@adobe/ecs/labels@1")); + new Function(`${evaluated}\nreturn fresh;`)()(); + expect(Reflect.get(globalThis, Symbol.for("@adobe/ecs/labels@1"))).toBeInstanceOf(WeakMap); }); test("fails for a module outside a named package, which has nothing to name its ids by", async () => { const result = await identifyModule(`export const position = Symbol();`, null, parse, resolve); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index bb6d2b02..b147b92c 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -162,7 +162,7 @@ export const identify = async (code: string, id: string | null, parse: Parse, re for (const { value, label } of labeled) insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(label)})` }); const labels = JSON.stringify(labelsName); // A label already given, as an internal factory gives its own, stands. - insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, label) { const labels = Symbol.for(${labels}); const map = globalThis[labels] || (globalThis[labels] = new Map()); if (!map.has(value)) map.set(value, label); return value; }\n` }); + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, label) { const labels = Symbol.for(${labels}); const map = globalThis[labels] || (globalThis[labels] = new WeakMap()); if (!map.has(value)) map.set(value, label); return value; }\n` }); } return insertInto(code, insertions, id ?? ""); }; @@ -187,10 +187,8 @@ const labelsOf = (program: Program, namespaces: ReadonlyMap, const visit = (node: Node, names: readonly string[]): void => { const declaration = declarationOf(node, namespaces, attach); const label = names.at(-1); - if (declaration !== undefined && !identified.has(declaration) && label !== undefined) { - labeled.push({ value: declaration, label }); - return; - } + // Calls in its arguments are labeled too, by the names nearest them. + if (declaration !== undefined && !identified.has(declaration) && label !== undefined) labeled.push({ value: declaration, label }); const name = nameIn(node); const inner = name === null ? names : [...names, name]; for (const child of children(node)) visit(child, inner); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts index c536577a..cb9db587 100644 --- a/packages/ecs/src/interface/registry/ids.ts +++ b/packages/ecs/src/interface/registry/ids.ts @@ -31,8 +31,9 @@ export const takeIdOfDeclaration = (declaration: object): string | undefined => }; /** - * The `Symbol.for` name of the global `Map` from a declaration to the label the plugin gave a constructor call that - * isn't a top-level binding's, by the binding or function it's in, or an internal factory gave its declaration. + * The `Symbol.for` name of the global `WeakMap` from a declaration to the label the plugin gave a constructor call that + * isn't a top-level binding's, by the binding or function it's in, or an internal factory gave its declaration. Weak, + * so a declaration whose constructor threw before taking its label isn't kept. */ export const labelsName = "@adobe/ecs/labels@1"; @@ -41,7 +42,7 @@ const labels = Symbol.for(labelsName); /** The label given the declaration a constructor call passes, if any, taken out of the map. */ export const takeLabelOfDeclaration = (declaration: object): string | undefined => { const map: unknown = Reflect.get(globalThis, labels); - if (!(map instanceof Map)) return undefined; + if (!(map instanceof WeakMap || map instanceof Map)) return undefined; const label: unknown = map.get(declaration); if (label === undefined) return undefined; map.delete(declaration); diff --git a/packages/ecs/src/observe/world-state.ts b/packages/ecs/src/observe/world-state.ts index b6535ec9..57722ec3 100644 --- a/packages/ecs/src/observe/world-state.ts +++ b/packages/ecs/src/observe/world-state.ts @@ -97,8 +97,8 @@ const labels = Symbol.for("@adobe/ecs/labels@1"); /** Labels a declaration of a system or index the factories make, and returns it. */ export const labelled = (declaration: D, label: string): D => { let map: unknown = Reflect.get(globalThis, labels); - if (!(map instanceof Map)) Reflect.set(globalThis, labels, map = new Map()); - if (map instanceof Map) map.set(declaration, label); + if (!(map instanceof WeakMap || map instanceof Map)) Reflect.set(globalThis, labels, map = new WeakMap()); + if (map instanceof WeakMap || map instanceof Map) map.set(declaration, label); return declaration; }; From 2d537e2d7569403383a4f99088468a46e5fb592d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:16:30 -0700 Subject: [PATCH 072/127] ecs: suites of the row path run again with unflagged bodies as written, so both kernels and bodies stay tested Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/compiler/plugin.ts | 13 +++++++++--- .../implementation/compiler/transform.test.ts | 12 +++++++---- .../src/implementation/compiler/transform.ts | 12 ++++++----- packages/ecs/vitest.config.ts | 21 +++++++++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index a2e48002..d3c769fd 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -214,9 +214,16 @@ const inputPlugin = (): Plugin => { * schemas its types give, which an application's build, reading only JavaScript there, can't make, and leaves its * kernels as source and its keys without ids, which the application's plugin compiles and gives. */ -export const plugin = (options: { readonly library?: boolean } = {}): Plugin[] => options.library === true ? [inputPlugin()] : [inputPlugin(), compilePlugin()]; +export const plugin = (options: { readonly library?: boolean } = {}): Plugin[] => pluginWith(options); -const compilePlugin = (): Plugin => { +/** + * The plugin, for this package's tests: with `automatic: false`, unflagged systems run as written, so suites of the + * row path run their bodies that way too. + */ +export const pluginWith = ({ library = false, automatic = true }: { readonly library?: boolean; readonly automatic?: boolean }): Plugin[] => + library ? [inputPlugin()] : [inputPlugin(), compilePlugin(automatic)]; + +const compilePlugin = (automatic: boolean): Plugin => { let serve = true; /** Whether an earlier instance of the plugin is in the same bundle. */ let repeated = false; @@ -334,7 +341,7 @@ const compilePlugin = (): Plugin => { if (!serve) return `import.meta.ROLLUP_FILE_URL_${emitChunk(this, buildId(id, index), hash)}`; // Served to node, a kernel's module is its url, so a pool's workers, which import it outside the server, read it too. return JSON.stringify(ssr ? inlinePlaceholder(index, hash) : serveUrl(id, index, hash)); - }, linker); + }, linker, automatic); const result = ssr && transformed !== null && !("failure" in transformed) ? { ...transformed, code: inlineKernels(transformed.code, transformed.kernels, hashes) } : transformed; const parent = id.split("?")[0] ?? id; if (result === null) { diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index ccb2ab52..56c35f2c 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -55,8 +55,8 @@ const host = (files: { readonly [file: string]: string }, reduced: Reductions = }, }); -const transform = (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions) => transformKernels(`${header}${source}`, "/test.js", parse, - async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files, reduced), parse)); +const transform = (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions, automatic = true) => transformKernels(`${header}${source}`, "/test.js", parse, + async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files, reduced), parse), automatic); type Compiled = KernelDescription & KernelModule; @@ -321,8 +321,8 @@ const vec3 = (x: readonly number[], y: readonly number[], z: readonly number[]) describe("kernels by default", () => { /** Whether the transform attached a kernel to the module's system, and the failure if it failed. */ - const attached = async (source: string): Promise => { - const result = await transform(source); + const attached = async (source: string, automatic = true): Promise => { + const result = await transform(source, {}, reductions, automatic); if (result === null) return false; return "failure" in result ? result.failure.message : result.code.includes("attachKernel$("); }; @@ -346,6 +346,10 @@ describe("kernels by default", () => { expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); }); + test("tries none unflagged when automatic compilation is off, as the suites of bodies as written turn it", async () => { + expect(await attached(`const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(true); + }); test("never compiles a system without components or with kernel: false", async () => { expect(await attached(`const kernel = System.create({ resources: { read: [delta] } }, ({ resources }) => { void resources[delta]; });`)).toBe(false); expect(await attached(`const kernel = System.create({ kernel: false, components: { read: [mass], write: [score] } }, row => { row[score] = row[mass]; });`)).toBe(false); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index bb5beaca..80c352cf 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -124,10 +124,10 @@ const compileLinked = async (declaration: ObjectExpression, body: Node, module: type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly request: KernelRequest }; /** - * The module's `System.create` calls that compile to kernels, required or tried: at module scope, with an inline - * body. Throws for a required one elsewhere or without one; a tried one there runs as written. + * The module's `System.create` calls that compile to kernels, required or, when `automatic`, tried: at module scope, + * with an inline body. Throws for a required one elsewhere or without one; a tried one there runs as written. */ -const kernelSites = (program: Program, locals: ReadonlyMap): readonly KernelSite[] => { +const kernelSites = (program: Program, locals: ReadonlyMap, automatic: boolean): readonly KernelSite[] => { const sites: KernelSite[] = []; const visit = (node: Node, nested: boolean): void => { if (node.type === "CallExpression" && createdBy(node, locals) === "system") { @@ -138,7 +138,7 @@ const kernelSites = (program: Program, locals: ReadonlyMap => { if ((!code.includes("System") && !code.includes("Finalizer")) || id.includes("\0")) return null; const program = parse(code); @@ -245,7 +247,7 @@ export const transformKernels = async ( for (const child of children(node)) visit(child, inner); }; try { - for (const { node, declaration, body, request } of kernelSites(program, locals)) { + for (const { node, declaration, body, request } of kernelSites(program, locals, automatic)) { let kernel: CompiledKernel; try { kernel = await compileLinked(declaration, body, module, request, link); diff --git a/packages/ecs/vitest.config.ts b/packages/ecs/vitest.config.ts index 3321223b..b5c053c5 100644 --- a/packages/ecs/vitest.config.ts +++ b/packages/ecs/vitest.config.ts @@ -2,6 +2,7 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; import { Compiler } from "./src/implementation/compiler/index.ts"; +import { pluginWith } from "./src/implementation/compiler/plugin.ts"; import { isolation, sources } from "./vitest.plugins.ts"; /** @@ -10,6 +11,16 @@ import { isolation, sources } from "./vitest.plugins.ts"; */ const threads = "src/implementation/world/threads.test.ts"; +/** + * Suites of what row systems do, run a second time with unflagged systems as written, since the build compiles every + * body in the subset: so both the kernels and the bodies as written of their systems are tested. + */ +const rowPath = [ + "up", "up-tree", "index-order", "index-sorted", "index-hash", "index-keyless", "index-changed", "indexes", "entity-lookups", + "change-blocks", "conditions", "commands", "rows", "target", "remove", "strings", "synchronize", "ticks", "observer", + "schedule", "reductions", "loop", +].map(name => `src/implementation/world/${name}.test.ts`); + /** A test's timeout only guards against a hang, which no test asserts on: long enough for a machine busy elsewhere. */ const timeouts = { testTimeout: 30000, hookTimeout: 30000 }; @@ -36,6 +47,16 @@ export default defineConfig({ sequence: { groupOrder: 0 }, }, }, + { + plugins: [pluginWith({ automatic: false })], + test: { + ...timeouts, + execArgv, + name: "as written", + include: rowPath, + sequence: { groupOrder: 0 }, + }, + }, { plugins: [Compiler.plugin()], test: { From e5bf76fee400d3abf5533874726c32b4cfe56257 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:19:01 -0700 Subject: [PATCH 073/127] ecs: follow-up on f64 lanes for automatic kernels over f32 columns Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a5547d8e..beb67266 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -113,6 +113,9 @@ time; inferred values are emitted as the same literals the runtime takes today, ## Performance +- **Automatic kernels over f32 columns lose SIMD,** since they compute in f64 to match the body as written: `move` + 0.091 → 0.398 ms, `chain` 1.56 → 6.48 ms, against `kernel: true`'s f32 lanes. f64x2 lanes would give exact results + near SIMD speed; build and measure them. - **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing waits, and measure with a sampling heap profile. From d5d70fafca04990909446c7089d6118e05829eee Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 19:56:47 -0700 Subject: [PATCH 074/127] ecs: memory records the minimal, abstract surface the interface aims for Co-Authored-By: Claude Opus 5.5 --- packages/ecs/memory.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ecs/memory.md b/packages/ecs/memory.md index be03816b..35a2484d 100644 --- a/packages/ecs/memory.md +++ b/packages/ecs/memory.md @@ -5,6 +5,10 @@ decisions about ecs here rather than in a personal memory, so every machine and ## Interface +- **A minimal, abstract surface, as WebGPU's is.** Expose the smallest common surface that says what consumers mean, + and nothing about how it's done, so the implementation stays free to change and optimize. Prefer what the build can + derive over what consumers write; never expose a mechanism consumers could call instead of declaring intent, such + as registration. - **Humans own the interface, agents own the implementation** (`src/interface/CLAUDE.md`). Agents add or widen nothing public: no exported types, built-in components or resources, fields, options, union members or observable behaviour. Any change that can't be avoided is proposed for review before it lands. From c4aa06cb34d73d65b90c665f5494f07d18700ca5 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 20:06:53 -0700 Subject: [PATCH 075/127] ecs: kernels computing at the body's precision run f64 lanes across rows, results bit for bit the body's Promoted from f32 on load and demoted on store: move 0.399 -> 0.177 ms, chain 6.48 -> 3.70 ms, move 10K 16.2 -> 7.7 us against one row at a time in f64; kernel: true keeps f32 lanes. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 3 - packages/ecs/src/implementation/world/perf.md | 2 + .../implementation/world/precision.test.ts | 61 +++++++- .../ecs/src/implementation/world/wasm.test.ts | 84 +++++++++++ packages/ecs/src/implementation/world/wasm.ts | 134 ++++++++++++------ 5 files changed, 238 insertions(+), 46 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index beb67266..a5547d8e 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -113,9 +113,6 @@ time; inferred values are emitted as the same literals the runtime takes today, ## Performance -- **Automatic kernels over f32 columns lose SIMD,** since they compute in f64 to match the body as written: `move` - 0.091 → 0.398 ms, `chain` 1.56 → 6.48 ms, against `kernel: true`'s f32 lanes. f64x2 lanes would give exact results - near SIMD speed; build and measure them. - **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing waits, and measure with a sampling heap profile. diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 568cb020..015ec12e 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -264,6 +264,8 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ **Main thread.** Row getters chosen once, and tuple reads and writes in one loop: 50 systems 3.2 → 1.63 ms (vitest). - Accessor rows each get their own prototype. Built from `{}`, every row after the first that defines an accessor with another getter went to V8 dictionary properties. One row per set of reads and writes, shared across systems and archetypes and rebound per pass, so a body sees one shape; f32-lane tuples read as array literals, and scalars and f32 lanes written directly. Resource cells in a context are resolved once per run. On node: 50 systems 1.1 → 0.45 ms, churn 55 → 32 ms, retag 74 → 59 ms; 50 distinct bodies over three row shapes 1.75 → 0.53 ms; two writing systems over 100K 19.5 → 1.9 ms; 20 systems reading a resource per row 1.26 → 0.22 ms. +**f64 lanes for kernels computing at the body's precision.** A kernel compiled without `kernel: true` computes in f64, as its body does, so it can't use f32 lanes. Across rows it runs f64x2 lanes instead: each f32 pair loaded with `load64_zero` and promoted, each operation the f64 one, demoted only on store, so results are the body's bit for bit (`wasm.test.ts`, `precision.test.ts`). Medians of five, node, against one row at a time in f64 and against `kernel: true`'s f32 lanes: `move` 0.399 → 0.177 ms (f32 lanes 0.090), `chain` 6.48 → 3.70 ms (1.55), `move 10K` 16.2 → 7.7 µs (4.5). Filtered kernels and reductions run a row at a time in both, so `sparse changed` and `reduce` are unchanged; a padded vec3's elements aren't whole vectors, so it does too. + ## GPU particles The sample (`pnpm dev`, the GPU Particles tab, or `/particles/` alone) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): diff --git a/packages/ecs/src/implementation/world/precision.test.ts b/packages/ecs/src/implementation/world/precision.test.ts index f71472fc..512f39a3 100644 --- a/packages/ecs/src/implementation/world/precision.test.ts +++ b/packages/ecs/src/implementation/world/precision.test.ts @@ -2,19 +2,23 @@ import { expect, it } from "vitest"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; -import type { F32 } from "../../interface/math/index.js"; +import type { F32, Vec3 } from "../../interface/math/index.js"; import { kernelOf } from "./attach-kernel.js"; import { createWorld } from "./create.js"; const a = Symbol("precisionTest/a"); const c = Symbol("precisionTest/c"); const difference = Symbol("precisionTest/difference"); +const position = Symbol("precisionTest/position"); +const velocity = Symbol("precisionTest/velocity"); declare module "@adobe/ecs" { export interface Components { [a]: { type: F32 }; [c]: { type: F32 }; [difference]: { type: F32 }; + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; } } @@ -54,3 +58,58 @@ it("computes an unflagged kernel at the precision the body as written does, in J expect(await differenceAfter(tried, true)).toBe(expected); expect(await differenceAfter(tried, false)).toBe(expected); }); + +/** Values where f32 arithmetic loses what f64 keeps: cancellation, rounding at each step, NaN, infinities and -0. */ +const hard = (i: number): number => [1 + 2 ** -23, 1 + 2 ** -22, 3.4e38, -0, NaN, Infinity, 1e-45, 0.1, -7.25, 16777217][i % 10] * (1 + (i % 7) * 2 ** -20); +// Rows that leave a partial group of lanes at the end. +const rows = 1027; +const seedMany = System.create(({ commands }) => { + for (let i = 0; i < rows; i++) commands.spawn([a, c, difference, position, velocity])({ [a]: hard(i), [c]: hard(i + 1), [difference]: 0, [position]: [hard(i + 2), hard(i + 3), hard(i + 4)], [velocity]: [hard(i + 5), hard(i + 6), hard(i)] }); +}); +const touch = System.create({ kernel: false, components: { write: [c] } }, row => { + if (row.entity % 3 === 0) row[c] = row[c] * 3; +}); +const pairs = [ + [ + System.create({ components: { read: [a, c], write: [difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + System.create({ kernel: false, components: { read: [a, c], write: [difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + ], + [ + System.create({ components: { read: [velocity], write: [position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + System.create({ kernel: false, components: { read: [velocity], write: [position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + ], + [ + System.create({ components: { read: [a, c], write: [difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + System.create({ kernel: false, components: { read: [a, c], write: [difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + ], +] as const; +const values: number[] = []; +const readAll = System.create({ kernel: false, components: { read: [difference, position] } }, row => { + values.push(row[difference], ...row[position]); +}); + +/** Every value after a seed, a change to some rows and `system`, as bits, so NaN and -0 compare exactly. */ +const bitsAfter = async (system: (typeof pairs)[number][number], wasm: boolean): Promise => { + const world = createWorld({ [workers]: 0 }, { wasm }); + try { + await world.run(seedMany); + await world.run(system); + await world.run(touch); + await world.run(system); + values.length = 0; + await world.run(readAll); + return [...new Uint32Array(new Float32Array(values).buffer)]; + } + finally { + await world.dispose(); + } +}; + +it("gives an unflagged kernel's results bit for bit as written, for dense columns, whole elements and change filters", async () => { + for (const [index, [tried, written]] of pairs.entries()) { + expect([index, kernelOf(tried) !== undefined]).toEqual([index, true]); + const expected = await bitsAfter(written, true); + expect([index, "wasm", await bitsAfter(tried, true)]).toEqual([index, "wasm", expected]); + expect([index, "js", await bitsAfter(tried, false)]).toEqual([index, "js", expected]); + } +}); diff --git a/packages/ecs/src/implementation/world/wasm.test.ts b/packages/ecs/src/implementation/world/wasm.test.ts index dcf80767..a0fc85d8 100644 --- a/packages/ecs/src/implementation/world/wasm.test.ts +++ b/packages/ecs/src/implementation/world/wasm.test.ts @@ -473,3 +473,87 @@ describe("generateWasm reductions", () => { } }); }); + +describe("generateWasm with f64 lanes, as kernels computing at the body's precision run", () => { + const column = (component: number, stride: number, offset: number): WasmColumn => ({ view: "f32", stride, offset, boolean: false, component }); + const ticks = (component: number): WasmColumn => ({ view: "f64", stride: 8, offset: 0, boolean: false, component }); + const read = (k: number): Expression => ({ kind: "column", column: k }); + const number = (value: number): Expression => ({ kind: "number", value }); + const binary = (operator: "+" | "-" | "*" | "/" | ">" | "<=", left: Expression, right: Expression): Expression => ({ kind: "binary", operator, left, right }); + const math = (name: string, ...parameters: Expression[]): Expression => ({ kind: "math", name, arguments: parameters }); + + /** + * Runs `program` over rows `start` to `end` with f64 lanes, and as scalar WASM, which is JS's semantics; returns + * whether the lanes ran, and each run's memory as bits, so NaN and -0 compare exactly. + */ + const both = (program: Program, columns: readonly WasmColumn[], fill: (floats: readonly Float32Array[]) => void, start: number, end: number) => { + const results = [true, false].map(simd => { + const kernel = generateWasm(program, columns, () => "number", simd, 0, [], [], [], [], [], false); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + // f32 components at 4096 × (component + 1); ticks, then their chunks, then their blocks, after them. + const baseOf = (info: WasmColumn): number => info.view === "f64" ? 4096 * (8 - info.component) : 4096 * (info.component + 1); + const floats = [0, 1, 2].map(component => new Float32Array(memory.buffer, 4096 * (component + 1), 1024)); + new Float64Array(memory.buffer, 4096 * 9, 1536).fill(1); + fill(floats); + const run: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(start, end, 7, -1, ...columns.map(baseOf), 0.75); + return { vectorized: kernel.vectorized, bits: [...new Uint32Array(memory.buffer, 4096, 4096 * 3)] }; + }); + return { vectorized: results[0].vectorized, same: results[0].bits.every((bits, i) => bits === results[1].bits[i]) }; + }; + /** Values where f32 arithmetic loses what f64 keeps: cancellation, rounding at each step, NaN, infinities and -0. */ + const hard = (i: number): number => [1 + 2 ** -23, 1 + 2 ** -22, 3.4e38, -0, NaN, Infinity, 1e-45, 0.1, -7.25, 16777217][i % 10] * (1 + (i % 7) * 2 ** -20); + + it("computes dense f32 columns two rows at a time in f64, as the body does, through the tail", () => { + // out = a * a - b, which cancels in f32; and out2 = sqrt(|a|) / b + min(a, args) * fround(b) - floor(a). + const program: Program = { + locals: 0, + body: [ + { kind: "store", column: 2, value: binary("-", binary("*", read(0), read(0)), read(1)) }, + { kind: "store", column: 3, value: binary("-", binary("+", binary("/", math("sqrt", math("abs", read(0))), read(1)), binary("*", math("min", read(0), { kind: "args", path: [] }), math("fround", read(1)))), math("floor", read(0))) }, + ], + }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0), column(2, 4, 2048)]; + for (const [start, end] of [[0, 300], [3, 22], [1, 2]] as const) { + expect([start, end, both(program, columns, ([a, b]) => { for (let i = 0; i < 1024; i++) { a[i] = hard(i); b[i] = hard(i + 1); } }, start, end)]).toEqual([start, end, { vectorized: true, same: true }]); + } + }); + + it("blends stores and stamps under an if per lane, in f64", () => { + const program: Program = { + locals: 1, + body: [ + { kind: "set", local: 0, value: binary("*", read(0), read(0)) }, + { kind: "if", test: binary(">", { kind: "local", local: 0 }, read(1)), consequent: [{ kind: "store", column: 2, value: binary("-", { kind: "local", local: 0 }, read(1)) }, { kind: "stamp", column: 3, chunks: 4, blocks: 5 }], alternate: [{ kind: "store", column: 2, value: { kind: "conditional", test: binary("<=", read(0), number(0)), consequent: number(-1), alternate: read(1) } }] }, + ], + }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0), ticks(-1), ticks(-2), ticks(-3)]; + expect(both(program, columns, ([a, b]) => { for (let i = 0; i < 1024; i++) { a[i] = hard(i); b[i] = hard(i + 3); } }, 5, 133)).toEqual({ vectorized: true, same: true }); + }); + + // A padded vec3's elements aren't whole vectors, and f64 lanes don't fuse within a row, so it runs a row at a time. + for (const [name, stride, leaves, vectorized] of [["vec2", 8, 2, true], ["tight vec3", 12, 3, true], ["padded vec3", 16, 3, false]] as const) { + it(`computes ${name} elements ${vectorized ? "in f64 lanes" : "a row at a time"}, as the body does`, () => { + // p = p * v - v * args, leaf by leaf, with p.x and v.x reading cancelling values. + const all = Array.from({ length: leaves }, (_, leaf) => leaf); + const program: Program = { locals: 0, body: all.map(leaf => ({ kind: "store", column: leaf, value: binary("-", binary("*", read(leaf), read(leaves + leaf)), binary("*", read(leaves + leaf), { kind: "args", path: [] })) })) }; + const columns = [...all.map(leaf => column(0, stride, 4 * leaf)), ...all.map(leaf => column(1, stride, 4 * leaf))]; + expect(both(program, columns, ([p, v]) => { for (let i = 0; i < 1024; i++) { p[i] = hard(i); v[i] = hard(i + 1); } }, 1, 63)).toEqual({ vectorized, same: true }); + }); + } + + it("leaves explicit kernels' f32 lanes, which round each step", () => { + const program: Program = { locals: 0, body: [{ kind: "store", column: 2, value: binary("-", binary("*", read(0), read(0)), read(1)) }] }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0)]; + const kernel = generateWasm(program, columns, () => "number", true, 0, [], [], [], [], [], true); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + new Float32Array(memory.buffer, 4096, 4).fill(1 + 2 ** -23); + new Float32Array(memory.buffer, 8192, 4).fill(1 + 2 ** -22); + const run: unknown = kernel === null ? null : new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, 4, 7, -1, 4096, 8192, 12288); + expect([kernel?.vectorized, new Float32Array(memory.buffer, 12288, 1)[0]]).toEqual([true, 0]); + }); +}); diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts index d1d5c661..fc2fcb83 100644 --- a/packages/ecs/src/implementation/world/wasm.ts +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -78,7 +78,7 @@ export type WasmKernel = { readonly scalars: readonly WasmScalar[]; /** The JS functions it imports, by name. */ readonly imports: readonly string[]; - /** Whether it runs four rows at a time with SIMD, at storage precision. */ + /** Whether it runs rows several at a time with SIMD: in f32 at storage precision, or in f64 as the body computes. */ readonly vectorized: boolean; }; @@ -210,7 +210,10 @@ export const generateWasm = ( indexLookups: readonly (WasmIndexLookup | null)[] = [], /** By probe, its lookup: `KernelDescription.probes[k].lookup`. */ probeLookups: readonly number[] = [], - /** Whether SIMD lanes may compute at storage precision, as only an explicit kernel's may: f32 rounds each step. */ + /** + * Whether SIMD lanes may compute at storage precision, as only an explicit kernel's may: f32 rounds each step. + * Without it lanes are f64, two values a vector, promoted from f32 and demoted only on store, as the body computes. + */ storagePrecision = true, ): WasmKernel | null => { const program = withoutIntrinsics(source); @@ -1299,7 +1302,7 @@ export const generateWasm = ( } }; - // ---- SIMD. Lanes compute in f32, at storage precision, so results may differ from the scalar path in the last bit. ---- + // ---- SIMD. f32 lanes compute at storage precision, so results may differ from the scalar path; f64 lanes don't. ---- const v128Type = 0x7b; const simd = (operation: number): number[] => [0xfd, ...unsigned(operation)]; @@ -1308,39 +1311,70 @@ export const generateWasm = ( f32Eq: 65, f32Ne: 66, f32Lt: 67, f32Gt: 68, f32Le: 69, f32Ge: 70, f64Gt: 74, not: 77, and: 78, andNot: 79, or: 80, bitselect: 82, anyTrue: 83, store32Lane: 90, store64Lane: 91, load32Zero: 92, load64Zero: 93, - f32Ceil: 103, f32Floor: 104, f32Trunc: 105, i64ExtendLowS: 199, i64ExtendHighS: 200, + f64Eq: 71, f64Ne: 72, f64Lt: 73, f64Le: 75, f64Ge: 76, f32DemoteF64Zero: 94, f64PromoteLowF32: 95, + f32Ceil: 103, f32Floor: 104, f32Trunc: 105, f64Ceil: 116, f64Floor: 117, f64Trunc: 122, i64ExtendLowS: 199, i64ExtendHighS: 200, f32Abs: 224, f32Neg: 225, f32Sqrt: 227, f32Add: 228, f32Sub: 229, f32Mul: 230, f32Div: 231, f32Min: 232, f32Max: 233, + f64Abs: 236, f64Neg: 237, f64Sqrt: 239, f64Add: 240, f64Sub: 241, f64Mul: 242, f64Div: 243, f64Min: 244, f64Max: 245, } as const; + /** f64 lanes: each vector holds two values, each operation the f64 one, so lanes compute as the scalar path does. */ + const wide = !storagePrecision; + /** Bytes of f32 storage one vector covers: four lanes, or two promoted to f64. */ + const laneBytes = wide ? 8 : 16; const float32 = (value: number): number[] => [...new Uint8Array(new Float32Array([value]).buffer)]; - const vectorConstant = (values: readonly number[]): number[] => [...simd(vop.const), ...[0, 1, 2, 3].flatMap(lane => float32(values[lane] ?? 0))]; + const vectorConstant = (values: readonly number[]): number[] => wide + ? [...simd(vop.const), ...[0, 1].flatMap(lane => float64(values[lane] ?? 0))] + : [...simd(vop.const), ...[0, 1, 2, 3].flatMap(lane => float32(values[lane] ?? 0))]; const splatConstant = (value: number): number[] => vectorConstant([value, value, value, value]); - const arithmeticVector = { "+": vop.f32Add, "-": vop.f32Sub, "*": vop.f32Mul, "/": vop.f32Div } as const; - const unaryVector: { readonly [name: string]: number | undefined } = { abs: vop.f32Abs, sqrt: vop.f32Sqrt, floor: vop.f32Floor, ceil: vop.f32Ceil, trunc: vop.f32Trunc }; + const arithmeticVector = wide + ? { "+": vop.f64Add, "-": vop.f64Sub, "*": vop.f64Mul, "/": vop.f64Div } + : { "+": vop.f32Add, "-": vop.f32Sub, "*": vop.f32Mul, "/": vop.f32Div }; + const unaryVector: { readonly [name: string]: number | undefined } = wide + ? { abs: vop.f64Abs, sqrt: vop.f64Sqrt, floor: vop.f64Floor, ceil: vop.f64Ceil, trunc: vop.f64Trunc } + : { abs: vop.f32Abs, sqrt: vop.f32Sqrt, floor: vop.f32Floor, ceil: vop.f32Ceil, trunc: vop.f32Trunc }; + const negateVector = wide ? vop.f64Neg : vop.f32Neg; + const comparisonVector: { readonly [operator: string]: number | undefined } = wide + ? { "<": vop.f64Lt, ">": vop.f64Gt, "<=": vop.f64Le, ">=": vop.f64Ge, "==": vop.f64Eq, "!=": vop.f64Ne, "===": vop.f64Eq, "!==": vop.f64Ne } + : { "<": vop.f32Lt, ">": vop.f32Gt, "<=": vop.f32Le, ">=": vop.f32Ge, "==": vop.f32Eq, "!=": vop.f32Ne, "===": vop.f32Eq, "!==": vop.f32Ne }; + /** Loads the vector of f32 storage at the address on the stack plus `offset`, as lanes. */ + const loadLanes = (offset: number): void => { + if (wide) code.push(...simd(vop.load64Zero), 2, ...unsigned(offset), ...simd(vop.f64PromoteLowF32)); + else code.push(...simd(vop.load), 2, ...unsigned(offset)); + }; + /** Stores lanes, on the stack after the address, to f32 storage at that address plus `offset`. */ + const storeLanes = (offset: number): void => { + if (wide) code.push(...simd(vop.f32DemoteF64Zero), ...simd(vop.store64Lane), 2, ...unsigned(offset), 0); + else code.push(...simd(vop.store), 2, ...unsigned(offset)); + }; const isArithmetic = (operator: string): operator is keyof typeof arithmeticVector => operator in arithmeticVector; - /** A value that's the same for every lane, splatted: demoted to f32, as a lane computes. */ + /** A value that's the same for every lane, splatted: demoted to f32 for f32 lanes, as a lane computes. */ const emitSplat = (expression: Expression): void => { emit(expression); - code.push(op.f32DemoteF64, ...simd(vop.f32Splat)); + if (wide) code.push(...simd(vop.f64Splat)); + else code.push(op.f32DemoteF64, ...simd(vop.f32Splat)); }; - /** `Math` functions with an f32x4 equivalent; `fround` is the identity in f32. */ + /** `Math` functions with a vector equivalent; `fround` is the identity in f32 and a demotion and promotion in f64. */ const emitVectorMath = (functionName: string, count: number): void => { if (functionName === "min" || functionName === "max") { - for (let i = 1; i < count; i++) code.push(...simd(functionName === "min" ? vop.f32Min : vop.f32Max)); + const operation = functionName === "min" ? (wide ? vop.f64Min : vop.f32Min) : (wide ? vop.f64Max : vop.f32Max); + for (let i = 1; i < count; i++) code.push(...simd(operation)); return; } + if (functionName === "fround" && wide) return void code.push(...simd(vop.f32DemoteF64Zero), ...simd(vop.f64PromoteLowF32)); const unary = unaryVector[functionName]; if (unary !== undefined) code.push(...simd(unary)); }; const vectorMath = (functionName: string, count: number): boolean => ((functionName === "min" || functionName === "max") && count > 0) || ((unaryVector[functionName] !== undefined || functionName === "fround") && count === 1); - // ---- Across rows: four rows at a time, for straight-line programs whose columns are all dense f32. ---- + // ---- Across rows: four rows at a time in f32 lanes, two in f64, for programs whose columns are all dense f32. ---- /** The mask of the enclosing `if`s, or null outside any. */ let mask: number | null = null; const rowsLocals: number[] = []; const rowsLocal = (index: number): number => rowsLocals[index] ??= scratch(v128Type); + /** Rows a pass of the across-rows loops runs at once, which stamps cover. */ + let groupRows = 4; const denseAddress = (column: number): void => { const { view, stride, boolean, component } = columns[column]; @@ -1350,12 +1384,12 @@ export const generateWasm = ( const vectorTruthy = (type: Type): void => { if (type === "boolean") { - code.push(...splatConstant(0), ...simd(vop.f32Ne)); + code.push(...splatConstant(0), ...simd(comparisonVector["!="] ?? vop.f32Ne)); return; } const value = scratch(v128Type); - code.push(op.localTee, ...unsigned(value), ...splatConstant(0), ...simd(vop.f32Ne)); - code.push(op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), ...simd(vop.f32Eq), ...simd(vop.and)); + code.push(op.localTee, ...unsigned(value), ...splatConstant(0), ...simd(comparisonVector["!="] ?? vop.f32Ne)); + code.push(op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), ...simd(comparisonVector["=="] ?? vop.f32Eq), ...simd(vop.and)); }; /** A lane mask as a value: 1 where set, 0 elsewhere. */ const maskValue = (): void => { @@ -1370,11 +1404,11 @@ export const generateWasm = ( case "resource": case "args": return emitSplat(expression); case "column": denseAddress(expression.column); - code.push(...simd(vop.load), 2, ...unsigned(columns[expression.column].offset)); + loadLanes(columns[expression.column].offset); return; case "unary": rowsEmit(expression.operand); - if (expression.operator === "-") code.push(...simd(vop.f32Neg)); + if (expression.operator === "-") code.push(...simd(negateVector)); else if (expression.operator === "!") { vectorTruthy(typeOf(expression.operand)); code.push(...simd(vop.not)); @@ -1393,7 +1427,7 @@ export const generateWasm = ( return; } } - const comparison = { "<": vop.f32Lt, ">": vop.f32Gt, "<=": vop.f32Le, ">=": vop.f32Ge, "==": vop.f32Eq, "!=": vop.f32Ne, "===": vop.f32Eq, "!==": vop.f32Ne }[operator as string]; + const comparison = comparisonVector[operator]; if (!isArithmetic(operator) && comparison === undefined) throw notVectorizable; rowsEmit(left); rowsEmit(right); @@ -1465,29 +1499,33 @@ export const generateWasm = ( } code.push(op.localSet, ...unsigned(value)); denseAddress(current.column); - code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(columns[current.column].offset)); + code.push(op.localGet, ...unsigned(value)); + storeLanes(columns[current.column].offset); return; } case "stamp": { - // Ticks are dense f64: four rows are two f64x2 stores; a chunk column stays when its row column is muted. + // Ticks are dense f64: each two rows are an f64x2 store; a chunk column stays when its row column is muted. + // f64 lanes' masks are already a lane per row; f32 lanes' are extended, two rows a store. if (columns[current.column].muted !== true) { - for (let half = 0; half < 2; half++) { + for (let half = 0; half < groupRows / 2; half++) { const address = (): void => { code.push(op.localGet, ...unsigned(columnParameter(current.column)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); }; address(); code.push(op.localGet, 2, ...simd(vop.f64Splat)); if (mask !== null) { address(); code.push(...simd(vop.load), 3, ...unsigned(16 * half)); - code.push(op.localGet, ...unsigned(mask), ...simd(half === 0 ? vop.i64ExtendLowS : vop.i64ExtendHighS), ...simd(vop.bitselect)); + code.push(op.localGet, ...unsigned(mask)); + if (!wide) code.push(...simd(half === 0 ? vop.i64ExtendLowS : vop.i64ExtendHighS)); + code.push(...simd(vop.bitselect)); } code.push(...simd(vop.store), 3, ...unsigned(16 * half)); } } // The group may straddle two chunks or blocks. Under a mask this can stamp one no lane changed, which only costs a scan. stampDense(current.blocks, blockShift, 0); - stampDense(current.blocks, blockShift, 3); + stampDense(current.blocks, blockShift, groupRows - 1); stampDense(current.chunks, chunkShift, 0); - stampDense(current.chunks, chunkShift, 3); + stampDense(current.chunks, chunkShift, groupRows - 1); return; } case "if": { @@ -1519,15 +1557,16 @@ export const generateWasm = ( } }; - /** Rows from `start` in groups of four, up to the last whole group; leaves the row local at the first row not done. */ + /** Rows from `start` in groups of a vector's lanes, up to the last whole group; leaves the row local at the first row not done. */ const rowsLoop = (): void => { + groupRows = laneBytes / 4; const vectorEnd = scratch(i32Type); - code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); + code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-groupRows), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); code.push(op.block, 0x40, op.loop, 0x40); code.push(op.localGet, ...unsigned(rowLocal), op.localGet, ...unsigned(vectorEnd), op.i32GeS, op.brIf, 1); mask = null; for (const current of program.body) rowsStatement(current); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, 4, op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(groupRows), op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); code.push(op.end, op.end); }; @@ -1771,7 +1810,7 @@ export const generateWasm = ( return; case "negate": emitVectorExpression(expression.operand); - code.push(...simd(vop.f32Neg)); + code.push(...simd(negateVector)); return; case "math": for (const parameter of expression.arguments) emitVectorExpression(parameter); @@ -1805,7 +1844,7 @@ export const generateWasm = ( return groups.size > 0; }; - // ---- Across rows for interleaved elements: four rows of a `stride`-byte element are `stride / 4` whole vectors. ---- + // ---- Across rows for interleaved elements: four rows of a `stride`-byte element are `4 * stride / laneBytes` whole vectors. ---- type LanePlan = ReturnType; /** Whether a value is the same for every row. */ @@ -1863,9 +1902,9 @@ export const generateWasm = ( } case "load": elementAddress(expression.column); - code.push(...simd(vop.load), 2, ...unsigned(16 * vector)); + loadLanes(laneBytes * vector); return; - case "constant": code.push(...vectorConstant([0, 1, 2, 3].map(lane => expression.values[((16 * vector + 4 * lane) % stride) / 4]))); return; + case "constant": code.push(...vectorConstant([0, 1, 2, 3].map(lane => expression.values[((laneBytes * vector + 4 * lane) % stride) / 4] ?? 0))); return; case "splat": return emitSplat(expression.expression); case "binary": emitElementExpression(expression.left, vector, stride, packVectors); @@ -1874,7 +1913,7 @@ export const generateWasm = ( return; case "negate": emitElementExpression(expression.operand, vector, stride, packVectors); - code.push(...simd(vop.f32Neg)); + code.push(...simd(negateVector)); return; case "math": for (const parameter of expression.arguments) emitElementExpression(parameter, vector, stride, packVectors); @@ -1889,6 +1928,8 @@ export const generateWasm = ( * and each vector's store only touches bytes no other vector reads. */ const elementsLoop = ({ groups, packs }: LanePlan): void => { + groupRows = 4; + const vectors = (stride: number): number => 4 * stride / laneBytes; const vectorEnd = scratch(i32Type); code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); code.push(op.block, 0x40, op.loop, 0x40); @@ -1899,23 +1940,25 @@ export const generateWasm = ( for (let index = 0; index < body.length; index++) { const pack = packs.get(index); if (pack?.used === true) { - const vectors = Array.from({ length: columns[pack.column].stride / 4 }, (_, vector) => { + const locals = Array.from({ length: vectors(columns[pack.column].stride) }, (_, vector) => { const local = scratch(v128Type); elementAddress(pack.column); - code.push(...simd(vop.load), 2, ...unsigned(16 * vector), op.localSet, ...unsigned(local)); + loadLanes(laneBytes * vector); + code.push(op.localSet, ...unsigned(local)); return local; }); - packVectors.set(pack, vectors); + packVectors.set(pack, locals); } const group = groups.get(index); if (group !== undefined) { const { stride } = columns[group.column]; - for (let vector = 0; vector < stride / 4; vector++) { + for (let vector = 0; vector < vectors(stride); vector++) { const value = scratch(v128Type); emitElementExpression(group.value, vector, stride, packVectors); code.push(op.localSet, ...unsigned(value)); elementAddress(group.column); - code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(16 * vector)); + code.push(op.localGet, ...unsigned(value)); + storeLanes(laneBytes * vector); } index += group.length - 1; } @@ -2055,9 +2098,11 @@ export const generateWasm = ( stampRangeChunks(); if (filters > 0) startChunksAndBlocks(); if (mode === "rows") rowsLoop(); + let across = false; if (mode === "lanes") { const plan = planLanes(); - if (acrossElements(plan)) elementsLoop(plan); + across = acrossElements(plan); + if (across) elementsLoop(plan); } // for (; row < end; row++) { block next { body } }, where with `up` the row and its parent's are rows[n] and parents[n]. const slot = rowLocal; @@ -2085,8 +2130,12 @@ export const generateWasm = ( } labels.length = 0; labels.push("next"); - if (mode === "lanes") fused = emitLaneBody(); - else for (const current of program.body) statement(current); + // f64 lanes fuse only across rows: within a row the rest run as scalars, as the body computes. + if (mode === "lanes" && !wide) fused = emitLaneBody(); + else { + if (mode === "lanes") fused = across; + for (const current of program.body) statement(current); + } labels.pop(); code.push(op.end); accumulators.forEach((accumulator, k) => { @@ -2119,8 +2168,9 @@ export const generateWasm = ( // Across-rows mode doesn't filter rows, so filtered kernels fuse within rows instead. // Accumulators carry from row to row, which neither SIMD mode keeps. // Listed rows aren't consecutive, so `up` programs run one row at a time. - // Lanes compute in f32, so a kernel computing at the body's own precision runs one row at a time in f64. - let mode: "rows" | "lanes" | "scalar" = !simdEnabled || !storagePrecision || accumulators.length > 0 || upMode ? "scalar" : filters > 0 ? "lanes" : "rows"; + // Within a row, f64 lanes would split a component's leaves across vectors, so filtered kernels at the body's + // precision run one row at a time. + let mode: "rows" | "lanes" | "scalar" = !simdEnabled || accumulators.length > 0 || upMode || (wide && filters > 0) ? "scalar" : filters > 0 ? "lanes" : "rows"; try { generate(0, mode); } From 5e716a166b2259cf375abe0080a2be901ddeb5af Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 20:07:57 -0700 Subject: [PATCH 076/127] ecs: generated code records declarations in a global map the registry reads; @adobe/ecs/declare leaves the API Consumers have no way left to register a key themselves. A module the plugin builds appends a function that sets each key's { kind, declaration } in a map under Symbol.for("@adobe/ecs/declarations@1"), importing nothing; every copy of the package registers what it holds, in order, at its first read of a key after more were recorded. A declaration that fails is thrown once from that read and given as the reason its key's uses fail. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/package.json | 4 - .../compiler/augmentations.test.ts | 115 ++++++++++-------- .../implementation/compiler/augmentations.ts | 82 +++++++------ .../ecs/src/implementation/compiler/paths.ts | 6 - .../implementation/compiler/plugin.test.ts | 67 +++++----- .../ecs/src/implementation/compiler/plugin.ts | 5 +- .../compiler/type-host.test-helper.ts | 1 - .../world/constant-unions.test.ts | 4 +- .../implementation/world/reductions.test.ts | 4 +- .../src/implementation/world/rules.test.ts | 6 +- .../src/interface/component/register.test.ts | 11 +- .../src/interface/registry/declarations.ts | 70 ++++++++++- .../ecs/src/interface/registry/declare.ts | 21 ---- .../src/interface/registry/declared.test.ts | 41 +++++++ .../src/interface/registry/ids.test-helper.ts | 11 +- .../ecs/src/interface/registry/ids.test.ts | 31 ++--- packages/ecs/src/interface/registry/ids.ts | 7 ++ packages/ecs/src/interface/registry/names.ts | 51 ++++++-- .../ecs/src/interface/report/components.ts | 6 +- .../src/interface/resource/register.test.ts | 6 +- .../ecs/src/interface/resources/always.ts | 4 +- .../ecs/src/interface/resources/cadence.ts | 4 +- packages/ecs/src/interface/resources/clock.ts | 4 +- .../ecs/src/interface/resources/gpu-device.ts | 4 +- packages/ecs/src/interface/resources/wake.ts | 4 +- .../ecs/src/interface/resources/workers.ts | 4 +- 27 files changed, 358 insertions(+), 217 deletions(-) delete mode 100644 packages/ecs/src/interface/registry/declare.ts create mode 100644 packages/ecs/src/interface/registry/declared.test.ts diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 13fa758a..e5a73324 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -73,7 +73,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. - **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) registers each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, it returns a type naming each such declaration's key and members rather than a `World`, so using it fails to compile. That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports the registry from the importer's copy of the package: its source modules from the copy they're in, any other module, a library's shipped build among them, from `@adobe/ecs/declare`, which the importer resolves. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, it returns a type naming each such declaration's key and members rather than a `World`, so using it fails to compile. That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails then is thrown once, from that read, and given as the reason the key's uses fail. A library's shipped build records its keys the same way. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. diff --git a/packages/ecs/package.json b/packages/ecs/package.json index 19702d25..be1bf7b7 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -65,10 +65,6 @@ "./compiler": { "import": "./dist/implementation/compiler/index.js", "types": "./dist/implementation/compiler/index.d.ts" - }, - "./declare": { - "import": "./dist/interface/registry/declare.js", - "types": "./dist/interface/registry/declare.d.ts" } } } diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 98dde657..71e21678 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -9,7 +9,7 @@ import { moduleOf } from './type-schemas.js'; const header = `import { Entity, type F32, type U32, type Vec3 } from "@adobe/ecs";\n`; /** The module as the pass leaves it, or its failure's message. */ -const passed = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { +const passedWhole = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { const code = header + body; const host = hostOf(files); const result = await augmentationInsertions(moduleOf("/src/keys.ts", code, host.parse(code, "/src/keys.ts")), host); @@ -18,11 +18,15 @@ const passed = async (body: string, files: { readonly [path: string]: string } = return insertInto(code, result.insertions, "/src/keys.ts").code; }; -/** The registrations the pass emits, one per line, without the import. */ +/** The module as the pass leaves it without the function it appends, or its failure's message. */ +const passed = async (body: string, files: { readonly [path: string]: string } = {}): Promise => + (await passedWhole(body, files))?.replace(/\nfunction declare\$\d*\(key, kind, declaration\) \{.*\}\n$/, "") ?? null; + +/** The declarations the pass records, one per line, without the function recording them. */ const registrations = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { const code = await passed(body, files); if (code === null || code.startsWith("failure: ")) return code; - return code.split("\n").filter(line => /^declare(Component|Resource|Trait)\$\(/.test(line)); + return code.split("\n").filter(line => /^declare\$\(/.test(line)); }; const keys = (...names: readonly string[]) => names.map(name => `const ${name} = Symbol();`).join("\n") + "\n"; @@ -33,24 +37,30 @@ describe("augmentationInsertions", () => { interface Components { [position]: { type: Vec3 }; [done]: { type: boolean }; [label]: { type: string } } interface Resources { [speed]: { type: F32 | null }; [count]: { type: U32 | null } } }`)).toEqual([ - `declareComponent$(position, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3} });`, - `declareComponent$(done, { type: {"type":"boolean"} });`, - `declareComponent$(label, { type: {"type":"string"} });`, - `declareResource$(speed, { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, - `declareResource$(count, { type: {"anyOf":[{"type":"integer","minimum":0,"maximum":4294967295},{"type":"null"}]} });`, + `declare$(position, "component", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3} });`, + `declare$(done, "component", { type: {"type":"boolean"} });`, + `declare$(label, "component", { type: {"type":"string"} });`, + `declare$(speed, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, + `declare$(count, "resource", { type: {"anyOf":[{"type":"integer","minimum":0,"maximum":4294967295},{"type":"null"}]} });`, ]); }); - test("imports the registry's hooks it calls", async () => { - const code = await passed(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); - expect(code?.split("\n")[0]).toBe(`import { declareComponent$ } from "declare.js";`); + test("appends the function recording its declarations, which imports nothing", async () => { + const code = await passedWhole(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n").filter(line => line.startsWith("import"))).toEqual([header.trimEnd()]); + expect(code?.trimEnd().split("\n").at(-1)).toBe(`function declare$(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`); }); - test("names the hooks it imports apart from the module's own names", async () => { - const code = await passed(`import { declareComponent$ } from "elsewhere";\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); - expect(code?.split("\n").filter(line => line.includes("declareComponent$1"))).toEqual([ - `import { declareComponent$ as declareComponent$1 } from "declare.js";`, - `declareComponent$1(tag, { type: {"const":true} });`, + test("names the function apart from the module's own names", async () => { + const code = await passedWhole(`const declare$ = 1;\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n").filter(line => line.includes("declare$1"))).toEqual([ + `declare$1(tag, "component", { type: {"const":true} });`, + `function declare$1(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`, ]); }); + test("fails for a module binding a global the recording function uses", async () => { + for (const binding of [`const Map = 1;`, `function Symbol() {}`, `import { globalThis } from "./globals.js";`]) { + expect(await passed(`${binding}\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`)).toContain("which the code recording its declarations uses"); + } + }); test("gives a default as a literal, or as the value `typeof` names, in the schema", async () => { expect(await registrations(`${keys("gravity", "mode", "selected", "origin")}declare module "@adobe/ecs" { interface Resources { @@ -60,10 +70,10 @@ describe("augmentationInsertions", () => { [origin]: { type: Vec3; default: [0, 0, 0] }; } }`)).toEqual([ - `declareResource$(gravity, { type: {"type":"number","precision":1,"default":-9.8} });`, - `declareResource$(mode, { type: {"anyOf":[{"const":"all"},{"const":"done"}],"default":"all"} });`, - `declareResource$(selected, { type: {"type":"integer","format":"entity","default":Entity.none} });`, - `declareResource$(origin, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, + `declare$(gravity, "resource", { type: {"type":"number","precision":1,"default":-9.8} });`, + `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}],"default":"all"} });`, + `declare$(selected, "resource", { type: {"type":"integer","format":"entity","default":Entity.none} });`, + `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, ]); }); test("gives a value that isn't data no schema, and its default beside its traits", async () => { @@ -77,11 +87,11 @@ declare module "@adobe/ecs" { [callback]: { type: (() => void) | null }; } }`, files)).toEqual([ - `declareComponent$(row, { thread: "main" });`, - `declareResource$(canvas, { transfer: true });`, - `declareResource$(callback, {});`, + `declare$(row, "component", { thread: "main" });`, + `declare$(canvas, "resource", { transfer: true });`, + `declare$(callback, "resource", {});`, // After the value its default names. - `declareResource$(pool, { default: empty });`, + `declare$(pool, "resource", { default: empty });`, ]); }); test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => { @@ -95,24 +105,24 @@ declare module "@adobe/ecs" { } interface Resources { [total]: { type: F32; default: 0; reduce: "+" } } }`)).toEqual([ - `declareTrait$(instanced);`, - `declareComponent$(host, { type: {"const":true} });`, - `declareComponent$(outline, { type: {"type":"number"}, within: host, [instanced]: true });`, - `declareComponent$(owner, { type: {"type":"integer","format":"entity"}, reference: "owner" });`, - `declareComponent$(drawn, { type: {"const":true}, derived: true });`, - `declareResource$(total, { type: {"type":"number","precision":1,"default":0}, reduce: "+" });`, + `declare$(instanced, "trait");`, + `declare$(host, "component", { type: {"const":true} });`, + `declare$(outline, "component", { type: {"type":"number"}, within: host, [instanced]: true });`, + `declare$(owner, "component", { type: {"type":"integer","format":"entity"}, reference: "owner" });`, + `declare$(drawn, "component", { type: {"const":true}, derived: true });`, + `declare$(total, "resource", { type: {"type":"number","precision":1,"default":0}, reduce: "+" });`, ]); }); test("registers a block's traits before the components declaring them", async () => { expect(await registrations(`${keys("marked", "mark")}declare module "@adobe/ecs" { interface Components { [marked]: { type: true; [mark]: true } } interface Traits { readonly [mark]: true } -}`)).toEqual([`declareTrait$(mark);`, `declareComponent$(marked, { type: {"const":true}, [mark]: true });`]); +}`)).toEqual([`declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]); }); test("follows a type named by Schema.Type that another module declares", async () => { const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;` }; expect(await registrations(`import type { Level } from "./units.js";\n${keys("level")}declare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ - `declareComponent$(level, { type: {"type":"integer","minimum":0,"maximum":9} });`, + `declare$(level, "component", { type: {"type":"integer","minimum":0,"maximum":9} });`, ]); }); test("registers right after its key, and after the bindings its declaration names, wherever the block is", async () => { @@ -120,10 +130,10 @@ declare module "@adobe/ecs" { const later = Symbol(); const start = [1, 2, 3] as const; const after = 1;`); - expect(code?.split("\n").slice(-3)).toEqual([`const start = [1, 2, 3] as const;`, `declareResource$(later, { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":start} });`, `const after = 1;`]); + expect(code?.split("\n").slice(-3)).toEqual([`const start = [1, 2, 3] as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":start} });`, `const after = 1;`]); // Code between the key and a block below uses the key registered. const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 0 } } }`); - expect(above?.split("\n").slice(2, 4)).toEqual([`const speed = Symbol();`, `declareResource$(speed, { type: {"type":"number","precision":1,"default":0} });`]); + expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1,"default":0} });`]); }); test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => { const code = await passed(`declare module "@adobe/ecs" { @@ -134,7 +144,7 @@ declare module "@adobe/ecs" { } const marked = Symbol(); const mark = Symbol();`); - expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declareTrait$(mark);`, `declareComponent$(marked, { type: {"const":true}, [mark]: true });`]); + expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]); }); test("registers after a let, var or class its default names, which have no value before their statements", async () => { const code = await passed(`${keys("a", "b", "c")}declare module "@adobe/ecs" { @@ -145,21 +155,21 @@ var second = new (class {})(); class Pool {} const after = 1;`); expect(code?.split("\n").slice(-7)).toEqual([ - `let first = new (class {})();`, `declareResource$(a, { default: first });`, - `var second = new (class {})();`, `declareResource$(b, { default: second });`, - `class Pool {}`, `declareResource$(c, { default: Pool });`, + `let first = new (class {})();`, `declare$(a, "resource", { default: first });`, + `var second = new (class {})();`, `declare$(b, "resource", { default: second });`, + `class Pool {}`, `declare$(c, "resource", { default: Pool });`, `const after = 1;`, ]); }); test("takes a type that allows null through its aliases", async () => { const files = { "/src/maybe.ts": `export type Maybe = Pool | null;\nexport class Pool {}` }; - expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declareResource$(pool, {});`]); + expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declare$(pool, "resource", {});`]); }); test("follows types a declare global block declares, here or in a module imported, before taking a name as a global class", async () => { const files = { "/src/globals.ts": `declare global {\n interface Point { readonly x: number; readonly y: number }\n}\nexport {};` }; expect(await registrations(`import "./globals.js";\ndeclare global { type Weight = number }\n${keys("at", "weight")}declare module "@adobe/ecs" { interface Components { [at]: { type: Point }; [weight]: { type: Weight } } }`, files)).toEqual([ - `declareComponent$(at, { type: {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}}} });`, - `declareComponent$(weight, { type: {"type":"number"} });`, + `declare$(at, "component", { type: {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}}} });`, + `declare$(weight, "component", { type: {"type":"number"} });`, ]); }); test("checks the built-in traits' values, as far as their literals and types tell", async () => { @@ -177,7 +187,7 @@ const after = 1;`); expect(await passed(components(`type: string; thread: "main"`))).toContain("a string lives in shared memory"); expect(await passed(components(`type: Vec3; thread: "main"`))).toContain("a struct lives in shared memory"); // A Transferable class, through an alias and with null. - expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declareComponent$(key, { transfer: true });`); + expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declare$(key, "component", { transfer: true });`); }); test("passes over modules without a declaration of the package", async () => { expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null); @@ -206,15 +216,16 @@ const after = 1;`); describe("registeredReductionsIn", () => { const read = (code: string) => Object.fromEntries(registeredReductionsIn(parseAst(code, { lang: "js" }))); - test("reads the operators a library's registrations give, however its bundle wrote the calls", () => { - expect(read(`import { declareResource$ } from "@adobe/ecs/declare"; export const total = Symbol(); declareResource$(total, { type: {}, reduce: "+" });`)).toEqual({ total: "+" }); - expect(read(`import { declareResource$ as d } from "@adobe/ecs/declare"; const t = Symbol(), m = Symbol(); d(t, { reduce: "+" }), d(m, { reduce: "min" }); export { t as total, m as low };`)).toEqual({ t: "+", m: "min" }); - expect(read(`import { declareResource$ as d } from "@adobe/ecs/declare"; const t = Symbol(); void d(t, { reduce: "+" });`)).toEqual({ t: "+" }); - expect(read(`import * as e from "@adobe/ecs/declare"; const t = Symbol(); e.declareResource$(t, { reduce: "+" });`)).toEqual({ t: "+" }); - expect(read(`import { a as d } from "./chunk-x.js"; const t = Symbol(); d(t, { reduce: "+" });`)).toEqual({ t: "+" }); - }); - test("ignores calls of other functions on keys that aren't symbols", () => { - expect(read(`import { a as d } from "./chunk-x.js"; const t = 1; d(t, { reduce: "+" });`)).toEqual({}); - expect(read(`import * as e from "./other.js"; const t = Symbol(); e.other(t, { reduce: "+" });`)).toEqual({}); + test("reads the operators a library's declarations give, however its bundle wrote them", () => { + expect(read(`export const total = Symbol(); declare$(total, "resource", { type: {}, reduce: "+" }); function declare$(key, kind, declaration) {}`)).toEqual({ total: "+" }); + expect(read(`const t = Symbol(), m = Symbol(); d(t, "resource", { reduce: "+" }), d(m, "resource", { reduce: "min" }); export { t as total, m as low };`)).toEqual({ t: "+", m: "min" }); + expect(read(`const t = Symbol(); void d(t, "resource", { reduce: "+" });`)).toEqual({ t: "+" }); + expect(read(`const t = Symbol(); (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(t, { kind: "resource", declaration: { reduce: "+" } });`)).toEqual({ t: "+" }); + expect(read(`const t = Symbol(); (g ??= new Map).set(t, { kind: "resource", declaration: { reduce: "max" } }), e.f(1);`)).toEqual({ t: "max" }); + }); + test("ignores components, traits, and keys that aren't symbols", () => { + expect(read(`const t = 1; d(t, "resource", { reduce: "+" });`)).toEqual({}); + expect(read(`const t = Symbol(); d(t, "component", { reduce: "+" }); d(t, "trait");`)).toEqual({}); + expect(read(`const t = Symbol(); m.set(t, { kind: "component", declaration: { reduce: "+" } });`)).toEqual({}); }); }); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index bbbaa0f7..fc116627 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -2,14 +2,12 @@ import { reductionOperators } from '../../interface/registry/reduction.js'; import { isEntity, isString, isStruct } from '../../interface/registry/schemas.js'; import type { Schema } from '../../interface/schema/schema.js'; +import { declarationsName } from '../../interface/registry/ids.js'; import type { PassResult } from './input-schemas.js'; import type { Insertion } from './source-map.js'; import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypedProgram, type TypeHost, type TypeNode } from './type-schemas.js'; -/** The module emitted code imports its registry hooks from, for the module being transformed. */ -export type DeclareModuleOf = (file: string) => string; - -export type AugmentationHost = TypeHost & { readonly declareModule: DeclareModuleOf }; +export type AugmentationHost = TypeHost; type Block = Extract; type Interface = Extract; @@ -67,23 +65,23 @@ const callsOf = (expression: Expression): readonly Call[] => { return []; }; +/** An object literal's property named `name`, unless computed. */ +const propertyNamed = (object: Extract, name: string): Expression | null => { + for (const property of object.properties) { + if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name) return property.value; + } + return null; +}; + /** - * The operator each resource a module's generated registrations, `declareResource$(key, { ..., reduce })`, give, by its - * key's binding: what a library's build in library mode leaves of its declarations in its JavaScript. The call may be - * named or namespace-imported, under any local name, or, as a bundle that renamed its import leaves it, any call of a - * top-level `Symbol()` and a declaration giving `reduce`. + * The operator each resource a module's generated declarations give, by its key's binding: what a library's build in + * library mode leaves of its declarations in its JavaScript. They record `key, "resource", { ..., reduce }` through the + * function appended to the module, under any name a bundle gives it, or, inlined, set `{ kind: "resource", declaration }` + * on the declarations map; the key is a top-level `Symbol()` constant. */ export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap => { - const locals = new Set(); - const namespaces = new Set(); const symbols = new Set(); for (const statement of program.body) { - if (statement.type === "ImportDeclaration") { - for (const specifier of statement.specifiers) { - if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "declareResource$") locals.add(specifier.local.name); - if (specifier.type === "ImportNamespaceSpecifier") namespaces.add(specifier.local.name); - } - } const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue; for (const declarator of declaration.declarations) { @@ -91,21 +89,25 @@ export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap - callee.type === "Identifier" ? locals.has(callee.name) || symbols.has(key) - : callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && namespaces.has(callee.object.name) - && callee.property.type === "Identifier" && callee.property.name === "declareResource$"; + /** The declaration a call records for a resource keyed by a `Symbol()` constant, by either form, with its key. */ + const recorded = ({ callee, arguments: parameters }: Call): { readonly key: string; readonly declaration: Expression } | null => { + const [key, second, third] = parameters; + if (key?.type !== "Identifier" || !symbols.has(key.name)) return null; + if (second?.type === "Literal" && second.value === "resource" && third?.type === "ObjectExpression") return { key: key.name, declaration: third }; + const setsEntry = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "set"; + if (!setsEntry || second?.type !== "ObjectExpression") return null; + const kind = propertyNamed(second, "kind"); + const declaration = propertyNamed(second, "declaration"); + return kind?.type === "Literal" && kind.value === "resource" && declaration !== null ? { key: key.name, declaration } : null; + }; const found = new Map(); for (const statement of program.body) { if (statement.type !== "ExpressionStatement") continue; for (const call of callsOf(statement.expression)) { - const [key, declaration] = call.arguments; - if (key?.type !== "Identifier" || declaration?.type !== "ObjectExpression" || !declares(call, key.name)) continue; - for (const property of declaration.properties) { - if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "reduce") continue; - if (property.value.type === "Literal" && typeof property.value.value === "string") found.set(key.name, property.value.value); - } + const entry = recorded(call); + if (entry === null || entry.declaration.type !== "ObjectExpression") continue; + const operator = propertyNamed(entry.declaration, "reduce"); + if (operator?.type === "Literal" && typeof operator.value === "string") found.set(entry.key, operator.value); } } return found; @@ -140,6 +142,14 @@ const testModule = /\.(test|type-test|fixture|test-helper|cases)\.tsx?$/; const isLibraryModule = (path: string | null, file: string): boolean => path !== null && (path.startsWith("interface/") || path.startsWith("implementation/")) && !testModule.test(file); +/** Whether a module's top level binds `name`: imported, or declared by a variable, function or class. */ +const bindsAtTopLevel = (program: TypedProgram, name: string): boolean => program.body.some(statement => { + if (statement.type === "ImportDeclaration") return statement.specifiers.some(specifier => specifier.local.name === name); + const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement; + if (declared?.type === "VariableDeclaration") return declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name); + return (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") && declared.id?.name === name; +}); + /** `base`, or it numbered, so the name appears nowhere in the code. */ const uniqueName = (code: string, base: string): string => { let name = base; @@ -190,10 +200,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const converter = createConverter(host, file); const { fail } = converter; const insertions: Insertion[] = []; - const hooks = { component: "declareComponent$", resource: "declareResource$", trait: "declareTrait$" } as const; - // Local names the module doesn't use, so a module importing a hook itself keeps its own. - const names = { component: uniqueName(code, hooks.component), resource: uniqueName(code, hooks.resource), trait: uniqueName(code, hooks.trait) }; - const used = new Set<"component" | "resource" | "trait">(); + // A name the module doesn't use for the function appended to record its declarations. + const record = uniqueName(code, "declare$"); /** * Where a local binding has its value, which a registration naming it comes after: the end of the top-level @@ -311,6 +319,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH }; try { + const shadowed = ["globalThis", "Symbol", "Map"].find(name => bindsAtTopLevel(module.program, name)); + const first = augmentations[0]; + if (shadowed !== undefined && first !== undefined) fail(module, first.block, `the module binds ${shadowed}, which the code recording its declarations uses`); // Traits first, so a component declaring one registers after it. const ordered = augmentations.flatMap(({ block, interfaces }) => interfaces.map(declared => ({ block, declared }))) .sort((a, b) => Number(b.declared.id.name === "Traits") - Number(a.declared.id.name === "Traits")); @@ -328,10 +339,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH // String-named traits are the package's own. if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); requireOwnKey(key.name, member, "a trait's key"); - used.add("trait"); const point = endOfLocal(key.name); traitPoints.set(key.name, point); - insertions.push({ at: point, text: `\n${names.trait}(${key.name});` }); + insertions.push({ at: point, text: `\n${record}(${key.name}, "trait");` }); continue; } if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); @@ -385,8 +395,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); parts.push(...traits); - used.add(kind); - insertions.push({ at, text: `\n${names[kind]}(${key.name}, ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); + insertions.push({ at, text: `\n${record}(${key.name}, "${kind}", ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); } } } @@ -395,7 +404,6 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH throw error; } if (insertions.length === 0) return null; - const imports = [...used].map(kind => names[kind] === hooks[kind] ? hooks[kind] : `${hooks[kind]} as ${names[kind]}`); - insertions.unshift({ at: 0, text: `import { ${imports.join(", ")} } from ${JSON.stringify(host.declareModule(file))};\n` }); + insertions.push({ at: code.length, text: `\nfunction ${record}(key, kind, declaration) { (globalThis[Symbol.for(${JSON.stringify(declarationsName)})] ??= new Map()).set(key, { kind, declaration }); }\n` }); return { insertions, dependencies: [...converter.read] }; }; diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 8675ca5e..430aab37 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -21,8 +21,6 @@ export type PackageCopy = { readonly preparedLookup: string; /** The package root's module, for kernels that import the package when a build treats it as external. */ readonly index: string; - /** The registry's hook for the declarations the build reads from `Components`, `Resources` and `Traits`. */ - readonly declare: string; }; /** The root of the copy of the package containing each directory, or null; found once per directory. */ @@ -74,7 +72,6 @@ const copyAt = (root: string, source: boolean, extension: string): PackageCopy = preparedSpawn: `${prefix}/implementation/world/prepared-spawn${suffix}`, preparedLookup: `${prefix}/implementation/world/prepared-lookup${suffix}`, index: `${prefix}/index${suffix}`, - declare: `${prefix}/interface/registry/declare${suffix}`, }; copies.set(key, copy); return copy; @@ -189,6 +186,3 @@ export const typesFileOf = (file: string): string => { const declarations = path.replace(/\.([cm]?)js$/, ".d.$1ts"); return declarations !== path && existsSync(declarations) ? declarations : path; }; - -/** Whether a file is part of a copy of the package: its own sources and builds. */ -export const isInPackageCopy = (file: string): boolean => pathInCopy(fileOf(file)) !== null; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 25bd9e33..83521f2e 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -11,14 +11,16 @@ import { plugin } from './plugin.js'; const source = (path: string) => JSON.stringify(fileURLToPath(new URL(path, import.meta.url))); const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); +/** The function a module the plugin built appends, which records its keys' declarations for the registry to read. */ +const recorder = `function declare$(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`; // The application's package, which names its modules' ids. writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "fixture-app", type: "module" })); const fixture = join(directory, "fixture.js"); writeFileSync(fixture, `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); @@ -31,9 +33,9 @@ mkdirSync(join(directory, "node_modules/fake-math/dist"), { recursive: true }); writeFileSync(join(directory, "node_modules/fake-math/package.json"), JSON.stringify({ name: "fake-math", type: "module", exports: "./dist/index.js" })); writeFileSync(join(directory, "node_modules/fake-math/dist/index.js"), "export const increment = (x) => x + 1;\n"); /** The linked kernel's keys, in a module of their own that the edit doesn't evaluate again. */ -writeFileSync(join(directory, "linked-keys.js"), `import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +writeFileSync(join(directory, "linked-keys.js"), `import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; export const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); `); writeFileSync(linked, `import { System } from ${source("../../interface/system/system.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; @@ -58,10 +60,10 @@ export const schemas = [push, label].map(system => Reflect.get(system, "input")) /** A kernel spawning through a list another module declares, which the test edits. */ const listing = join(directory, "listing.js"); const listed = join(directory, "listed.js"); -const writeListed = (keys: string) => writeFileSync(listed, `import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +const writeListed = (keys: string) => writeFileSync(listed, `import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { score } from "./linked-keys.js"; export const extra = Symbol("extra"); -declareComponent$(extra, { type: { const: true } }); +registerComponent(extra, { type: { const: true } }); export const Listed = [${keys}]; `); writeListed("score"); @@ -75,24 +77,24 @@ export const description = kernelOf(kernel); /** Two kernels in one module, writing different functions of the entity. */ const pair = join(directory, "pair.js"); writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); const doubled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); const tripled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ const keys = (declaration: string, released = "score") => `import { System as Systems } from ${source("../../interface/system/system.ts")}; -import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { Finalizer } from ${source("../../interface/finalizer/finalizer.ts")}; import { Rule } from ${source("../../interface/rule/rule.ts")}; import { labelOf } from ${source("../../interface/registry/names.ts")}; export const score = Symbol("score"); const shown = Symbol("shown"); -declareComponent$(score, ${declaration}); -declareComponent$(shown, { type: { const: true }, derived: true }); +registerComponent(score, ${declaration}); +registerComponent(shown, { type: { const: true }, derived: true }); export const rule = Rule.create({ components: { has: [score] }, derive: [shown] }); export const release = Finalizer.create({ components: { read: [${released}] } }, () => {}); export const system = Systems.create({ components: { write: [score] } }, (row) => { row[score] = 1; }); @@ -112,13 +114,13 @@ writeFileSync(join(directory, "node_modules/key-library/package.json"), JSON.str writeFileSync(join(directory, "node_modules/key-library/dist/index.js"), `export const marker = Symbol("marker");\n`); writeFileSync(join(directory, "worker-keys.js"), `export const message = Symbol("message");\nself.postMessage(String(message.description));\n`); writeFileSync(identified, `import { labelOf } from ${source("../../interface/registry/names.ts")}; -import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { marker } from "key-library"; export const score = Symbol("score"); const twin = Symbol("score"); export default Symbol(); -declareComponent$(score, { type: { type: "number" } }); -declareComponent$(twin, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); +registerComponent(twin, { type: { type: "number" } }); export const labels = [labelOf(score), labelOf(twin), labelOf(marker)]; export const worker = () => new Worker(new URL("./worker-keys.js", import.meta.url), { type: "module" }); `); @@ -127,11 +129,11 @@ mkdirSync(join(directory, "node_modules/external-keys"), { recursive: true }); writeFileSync(join(directory, "node_modules/external-keys/package.json"), JSON.stringify({ name: "external-keys", type: "module", exports: "./index.js" })); writeFileSync(join(directory, "node_modules/external-keys/index.js"), `export const heat = Symbol("heat");\nexport const Pair = [Symbol("left"), Symbol("right")];\n`); const externalKernel = (name: string, added: string) => `import { System } from ${source("../../interface/system/system.ts")}; -import { declareComponent$ } from ${source("../../interface/registry/declare.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); const kernel = System.create({ kernel: true, components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; @@ -325,11 +327,11 @@ test("an application's build compiles the kernels a library ships uncompiled", a writeFileSync(join(project, "package.json"), JSON.stringify({ name: "shipped-app", type: "module" })); mkdirSync(join(project, "node_modules/kernel-library/dist"), { recursive: true }); writeFileSync(join(project, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); - // As the library's own build with the plugin left it, registering its keys through the copy it resolves. + // As the library's own build with the plugin left it, recording its keys' declarations for every copy to read. writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { System } from "@adobe/ecs"; -import { declareComponent$ } from "@adobe/ecs/declare"; const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +declare$(score, "component", { type: { type: "number" } }); +${recorder} export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); `); // The test hook comes from the installed copy, which the library's compiled kernel attaches to. @@ -359,11 +361,11 @@ test("an application's build compiles a library's reducing kernel, with the oper writeFileSync(join(project, "node_modules/reducing-library/package.json"), JSON.stringify({ name: "reducing-library", type: "module", exports: "./dist/index.js" })); // As its build in library mode left it: types stripped, the registrations it gave in their place. writeFileSync(join(project, "node_modules/reducing-library/dist/index.js"), `import { System } from "@adobe/ecs"; -import { declareComponent$, declareResource$ } from "@adobe/ecs/declare"; const score = Symbol(); -declareComponent$(score, { type: { type: "number" } }); +declare$(score, "component", { type: { type: "number" } }); const largest = Symbol(); -declareResource$(largest, { type: { type: "number", default: 0 }, reduce: "max" }); +declare$(largest, "resource", { type: { type: "number", default: 0 }, reduce: "max" }); +${recorder} export const kernel = System.create({ kernel: true, components: { read: [score] }, resources: { reduce: [largest] } }, (row, { reduce }) => { reduce[largest](row[score]); }); `); const application = join(project, "application.js"); @@ -404,7 +406,7 @@ export const kernel = System.create({ kernel: true, components: { read: [score] await expect(built).rejects.toThrow("which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"); }); -test("a build registers the keys a module declares, through the copy of the package the module resolves", async () => { +test("a build records the keys a module declares, which the copy of the package the application loads registers", async () => { const project = join(directory, "declaring"); const copy = await installCopy(project); writeFileSync(join(project, "package.json"), JSON.stringify({ name: "declaring-app", version: "1.0.0", type: "module" })); @@ -420,15 +422,17 @@ declare module "@adobe/ecs" { const outDir = join(project, "dist"); const output = await build({ configFile: false, logLevel: "silent", root: project, plugins: [plugin()], - build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" }, rolldownOptions: { external: ["@adobe/ecs"] } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); - // A library's build ships this: no path of the machine that built it. - expect(chunks.map(chunk => chunk.code).join("\n")).toContain(`from "@adobe/ecs/declare"`); + // A library's build ships this: its declarations recorded for every copy of the package, importing nothing. + expect(chunks.map(chunk => chunk.code).join("\n")).toContain(`Symbol.for("@adobe/ecs/declarations@1")`); + expect(chunks.map(chunk => chunk.code).join("\n")).not.toMatch(/^import /m); const main = chunks.find(chunk => chunk.facadeModuleId === application); const { speed }: { speed: symbol } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); - // The registry of the copy the build's import resolves to. + // The registry of the installed copy, loaded as an application loads the package, reads it. + await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/index.js")).href); const { declarationOf }: { declarationOf: (key: unknown) => unknown } = await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/interface/registry/names.js")).href); expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1, default: 2 } }); }); @@ -449,12 +453,12 @@ export const slow = System.create({ kernel: true, components: { write: [speed] } const outDir = join(project, "dist"); const output = await build({ configFile: false, logLevel: "silent", root: project, plugins: [plugin({ library: true })], - build: { outDir, minify: false, lib: { entry, formats: ["es"], fileName: "index" }, rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/declare"] } }, + build: { outDir, minify: false, lib: { entry, formats: ["es"], fileName: "index" }, rolldownOptions: { external: ["@adobe/ecs"] } }, }); const code = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) .flatMap(item => item.type === "chunk" ? [item.code] : []).join("\n"); - expect(code).toContain(`from "@adobe/ecs/declare"`); - expect(code).toMatch(/declareComponent\$\(speed, \{ type: \{\s*"type": "number",\s*"precision": 1\s*\} \}\)/); + expect(code).toContain(`Symbol.for("@adobe/ecs/declarations@1")`); + expect(code).toMatch(/declare\$\(speed, "component", \{ type: \{\s*"type": "number",\s*"precision": 1\s*\} \}\)/); // The input's schema, passed to its create. expect(code).toMatch(/"by": \{\s*"type": "number"\s*\}/); expect(code).toContain(`row[speed] = row[speed] / 2`); @@ -552,8 +556,9 @@ test("compiled code imports the runtime from the copy of the package its module const module = join(project, "copied.js"); writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); -declareComponent$(score, { type: { type: "number" } }); +declare$(score, "component", { type: { type: "number" } }); export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +${recorder} export const spawner = System.create({ kernel: false, components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); const server = await createServer({ diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index d3c769fd..7442788a 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -11,7 +11,7 @@ import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; import { augmentationInsertions, reductionsIn, registeredReductionsIn, type AugmentationHost } from './augmentations.js'; import { inputInsertions, type InputHost } from './input-schemas.js'; -import { copyForImporter, isInPackageCopy, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; +import { copyForImporter, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; import { moduleOf, type TypedProgram } from './type-schemas.js'; import { insertInto, locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; @@ -187,9 +187,6 @@ const inputPlugin = (): Plugin => { }, load: file => readFile(file, "utf8"), packagePath: packagePathOf, - // The package's own modules share the registry of the copy they're in; any other module's registrations, - // a library's shipped among them, import the copy it resolves. - declareModule: importer => isInPackageCopy(importer) ? copyForImporter(importer).declare : `${packageName}/declare`, isSystem: async (specifier, importer) => { const found = await resolved(specifier, importer); return found !== null && (found.external ? specifier === packageName : namespaceCopyOf(found.id, "System") !== null); diff --git a/packages/ecs/src/implementation/compiler/type-host.test-helper.ts b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts index e794f2f6..a3a1ad53 100644 --- a/packages/ecs/src/implementation/compiler/type-host.test-helper.ts +++ b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts @@ -23,6 +23,5 @@ export const hostOf = (files: { readonly [path: string]: string }): InputHost & }, load: async file => files[file] ?? readFileSync(file, "utf8"), packagePath: packagePathOf, - declareModule: () => "declare.js", isSystem: async specifier => specifier === "@adobe/ecs", }); diff --git a/packages/ecs/src/implementation/world/constant-unions.test.ts b/packages/ecs/src/implementation/world/constant-unions.test.ts index d86fe618..3476baa4 100644 --- a/packages/ecs/src/implementation/world/constant-unions.test.ts +++ b/packages/ecs/src/implementation/world/constant-unions.test.ts @@ -4,7 +4,7 @@ import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld } from "./create.js"; import { readEach, select } from "./reads.test-helper.js"; -import { declareResource$ } from "../../interface/registry/declare.js"; +import { registerResource } from "../../interface/registry/declarations.js"; import { defaultOf } from "./state.js"; const Frame = { fixed: 0, autoHeight: 1, autoWidth: 2 } as const; @@ -36,7 +36,7 @@ it("stores a union of number constants as a number in shared memory, read beside // Registered by hand without a default, as only code the build didn't generate can. const mode = Symbol("constantUnionsTest/mode"); -declareResource$(mode, { type: { anyOf: [{ const: 1 }, { const: 2 }, { const: 4 }] } }); +registerResource(mode, { type: { anyOf: [{ const: 1 }, { const: 2 }, { const: 4 }] } }); it("starts a resource of a union of number constants without a default at its first constant, a value of its type", () => { expect(defaultOf(mode)).toBe(1); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index 2f3729f9..26cc9e3a 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { type I32, type U32, Vec3 } from "../../interface/math/index.js"; -import { declareResource$ } from "../../interface/registry/declare.js"; +import { registerResource } from "../../interface/registry/declarations.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { readResource } from "./reads.test-helper.js"; @@ -234,7 +234,7 @@ describe("reductions", () => { it("reject what can't be reduced", async () => { // Booleans reduce bitwise. - expect(() => declareResource$(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); + expect(() => registerResource(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error only a resource with a reduce trait is reduced expect(() => System.create({ components: { has: [mass] }, resources: { reduce: [level] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index cabc0c0e..23b4e8b6 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -8,7 +8,7 @@ import { createWorld } from "./create.js"; import { count, read } from "./reads.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { declareComponent$ } from "../../interface/registry/declare.js"; +import { registerComponent } from "../../interface/registry/declarations.js"; import { programRules } from "../../interface/registry/index.js"; const source = Symbol("rulesTest/source"); @@ -142,8 +142,8 @@ describe("rules", () => { // @ts-expect-error a reference changes only through commands expect(() => System.create({ components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. - declareComponent$(left, { type: { const: true }, within: right }); - expect(() => declareComponent$(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); + registerComponent(left, { type: { const: true }, within: right }); + expect(() => registerComponent(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); }); }); diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index b6ac59ff..0e4112db 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; -import { declareComponent$, declareTrait$ } from "../registry/declare.js"; +import { registerComponent } from "../registry/declarations.js"; +import { addTrait } from "../registry/traits.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; const parent = Symbol("typesComponentTest/parent"); @@ -35,7 +36,7 @@ declare module "@adobe/ecs" { } /** Registers a declaration as the build does, which a test may write wrong. */ -const register = (key: unknown, declaration: object): void => declareComponent$(key, declaration); +const register = (key: unknown, declaration: object): void => registerComponent(key, declaration); describe("registering a component", () => { it("records the declaration, and its thread: none for a struct or a string, else its own or \"world\"", () => { @@ -57,7 +58,7 @@ describe("registering a component", () => { expect(declarationOf(count)).toEqual({ type: { type: "number" } }); const claimed = Symbol("typesComponentTest/claimed"); register(claimed, { type: { const: true } }); - expect(() => declareTrait$(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); + expect(() => addTrait(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); expect(kindOf(count)).toBe("component"); }); it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { @@ -101,10 +102,10 @@ describe("registering a component", () => { it("takes traits other packages register, in the namespace components share", () => { const early = Symbol("typesComponentTest/early"); expect(() => register(early, { type: { const: true }, [late]: true })).toThrow(`typesComponentTest/late isn't a registered trait`); - declareTrait$(late); + addTrait(late); expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); - expect(() => declareTrait$(mark)).not.toThrow(); + expect(() => addTrait(mark)).not.toThrow(); expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); }); }); diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index b647fd2c..062e6187 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -1,11 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Schema } from '../schema/schema.js'; import type { Key as TraitKey } from '../trait/key.js'; +import { declarationsName } from './ids.js'; import { layoutOf } from './layout.js'; -import { claimKey, declarationOf, failRegistration, firstKeyOf, kindOf, labelOf, requireKey, sameDeclaration } from './names.js'; +import { claimKey, declarationOf, failRegistration, firstKeyOf, installCatchUp, kindOf, labelOf, recordFailure, requireKey, sameDeclaration } from './names.js'; import { isReduction } from './reduction.js'; import { allowsNull, hasBoolean, isEntity, isString, isStruct } from './schemas.js'; -import { isTrait } from './traits.js'; +import { addTrait, isTrait } from './traits.js'; /** * A registered declaration, as any consumer iterating components or resources sees it: its `type` and other traits. @@ -96,10 +97,73 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl * Registers a declaration of `kind` under `key`, fixed from then on. The same declaration again is accepted, and so is * a new symbol with the id of a key of this kind, as a module evaluated again makes it. */ -export const createRegistry = (kind: "component" | "resource") => (key: unknown, declaration: AnyDeclaration): void => { +const createRegistry = (kind: "component" | "resource") => (key: unknown, declaration: AnyDeclaration): void => { const symbol = requireKey(kind, key); const existing = kindOf(symbol); if (existing === kind && sameDeclaration(declarationOf(symbol), declaration)) return; if (existing === undefined) check(kind, symbol, declaration); claimKey(symbol, kind, declaration); }; + +/** Registers a component: the package's own, and tests' keys made at runtime; the build declares every other. */ +export const registerComponent = createRegistry("component"); + +/** Registers a resource: the package's own, and tests' keys made at runtime; the build declares every other. */ +export const registerResource = createRegistry("resource"); + +/** The global map of what the build declared, made here when no generated code made it first. */ +const declared = ((): Map => { + const name = Symbol.for(declarationsName); + const found: unknown = Reflect.get(globalThis, name); + if (found instanceof Map) return found; + const made = new Map(); + Reflect.set(globalThis, name, made); + return made; +})(); + +/** Whether a value the generated code recorded is a declaration: an object of traits, as the build wrote it. */ +const isDeclaration = (value: unknown): value is AnyDeclaration => typeof value === "object" && value !== null; + +/** Registers one key the build declared, as its kind says. */ +const registerDeclared = (key: unknown, entry: unknown): void => { + const kind: unknown = typeof entry === "object" && entry !== null ? Reflect.get(entry, "kind") : undefined; + const declaration: unknown = typeof entry === "object" && entry !== null ? Reflect.get(entry, "declaration") : undefined; + if (kind === "trait") addTrait(key); + else if (kind === "component" && isDeclaration(declaration)) registerComponent(key, declaration); + else if (kind === "resource" && isDeclaration(declaration)) registerResource(key, declaration); +}; + +/** How many of the map's entries this copy registered. */ +let seen = 0; +let catching = false; + +/** + * Registers what the build declared since this last ran, in the order modules evaluated, as each module registered + * its keys when it was evaluated. A declaration that fails is recorded for its key's uses and thrown once, after the + * rest register. Reads of keys while it runs see those registered so far. + */ +const catchUp = (): void => { + if (catching || declared.size === seen) return; + catching = true; + let failure: unknown = undefined; + try { + let index = 0; + for (const [key, entry] of declared) { + if (index++ < seen) continue; + seen++; + try { + registerDeclared(key, entry); + } + catch (error) { + if (typeof key === "symbol") recordFailure(key, error instanceof Error ? error.message : String(error)); + failure ??= error; + } + } + } + finally { + catching = false; + } + if (failure !== undefined) throw failure; +}; + +installCatchUp(catchUp); diff --git a/packages/ecs/src/interface/registry/declare.ts b/packages/ecs/src/interface/registry/declare.ts deleted file mode 100644 index 8d58ce9c..00000000 --- a/packages/ecs/src/interface/registry/declare.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { createRegistry, type AnyDeclaration } from './declarations.js'; -import { addTrait } from './traits.js'; - -/* - * The registry's hooks for code the plugin generates, which `@adobe/ecs/declare` exports: the plugin registers each - * key's declaration in `Components`, `Resources` and `Traits` through them, after the key. They're named with `$`, as - * the plugin's other generated names are, since consumers never call them. - */ - -const components = createRegistry("component"); -const resources = createRegistry("resource"); - -/** Registers a component as the build read it from its declaration in `Components`. */ -export const declareComponent$ = (key: unknown, declaration: AnyDeclaration): void => components(key, declaration); - -/** Registers a resource as the build read it from its declaration in `Resources`. */ -export const declareResource$ = (key: unknown, declaration: AnyDeclaration): void => resources(key, declaration); - -/** Registers a trait of a consumer's own, keyed by a symbol, as the build read it from `Traits`. */ -export const declareTrait$ = (key: unknown): void => addTrait(key); diff --git a/packages/ecs/src/interface/registry/declared.test.ts b/packages/ecs/src/interface/registry/declared.test.ts new file mode 100644 index 00000000..ff2b2a56 --- /dev/null +++ b/packages/ecs/src/interface/registry/declared.test.ts @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { identify, recordDeclaration } from "./ids.test-helper.js"; +import "./declarations.js"; +import { declarationOf, kindOf, sameKey, whyUnregistered } from "./names.js"; + +describe("declarations the build records", () => { + it("registers a key at its first use, as its kind says", () => { + const speed = Symbol("declaredTest/speed"); + const tag = Symbol("declaredTest/tag"); + recordDeclaration(speed, "resource", { type: { type: "number", default: 1 } }); + recordDeclaration(tag, "trait"); + expect([kindOf(speed), kindOf(tag)]).toEqual(["resource", "trait"]); + expect(declarationOf(speed)).toEqual({ type: { type: "number", default: 1 } }); + }); + it("registers in the order modules recorded them, so a trait comes before what declares it", () => { + const mark = Symbol("declaredTest/mark"); + const marked = Symbol("declaredTest/marked"); + recordDeclaration(mark, "trait"); + recordDeclaration(marked, "component", { type: { const: true }, [mark]: true }); + expect(kindOf(marked)).toBe("component"); + }); + it("takes another symbol of one id, as a module evaluated again records it, as the same key", () => { + const first = identify(Symbol("declaredTest/again"), "declaredTest/keys.ts#again"); + const again = identify(Symbol("declaredTest/again"), "declaredTest/keys.ts#again"); + recordDeclaration(first, "component", { type: { type: "number" } }); + recordDeclaration(again, "component", { type: { type: "number" } }); + expect([kindOf(again), sameKey(first, again)]).toEqual(["component", true]); + }); + it("throws a declaration that fails once, at the next use, and gives it as the reason its key's uses fail", () => { + const late = Symbol("declaredTest/late"); + const fine = Symbol("declaredTest/fine"); + recordDeclaration(late, "component", { type: { type: "number" }, reduce: "+" }); + recordDeclaration(fine, "component", { type: { type: "number" } }); + expect(() => kindOf(fine)).toThrow(`Cannot register component "declaredTest/late": only a resource is reduced.`); + expect([kindOf(fine), kindOf(late)]).toEqual(["component", undefined]); + expect(whyUnregistered(late)).toBe(`its declaration failed to register: Cannot register component "declaredTest/late": only a resource is reduced.`); + // Left valid, so any other copy of the registry reading the map later registers it. + recordDeclaration(late, "component", { type: { type: "number" } }); + }); +}); diff --git a/packages/ecs/src/interface/registry/ids.test-helper.ts b/packages/ecs/src/interface/registry/ids.test-helper.ts index 590bfc06..5df9cd1b 100644 --- a/packages/ecs/src/interface/registry/ids.test-helper.ts +++ b/packages/ecs/src/interface/registry/ids.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { idsName } from './ids.js'; +import { declarationsName, idsName } from './ids.js'; /** * Gives a key or a declaration an id, as code the plugin generates does for a top-level `Symbol(...)` declaration or a @@ -13,3 +13,12 @@ export const identify = (value: T, id: string): T => Reflect.set(globalThis, name, map); return value; }; + +/** Records a key's declaration, as the function the plugin appends to a module declaring keys does. */ +export const recordDeclaration = (key: symbol, kind: "component" | "resource" | "trait", declaration?: object): void => { + const name = Symbol.for(declarationsName); + const existing: unknown = Reflect.get(globalThis, name); + const map = existing instanceof Map ? existing : new Map(); + map.set(key, { kind, declaration }); + Reflect.set(globalThis, name, map); +}; diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index b13213fe..3579d85b 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest"; import { Finalizer } from "../finalizer/finalizer.js"; import { Rule } from "../rule/rule.js"; import { System } from "../system/system.js"; -import { declareComponent$, declareResource$, declareTrait$ } from "./declare.js"; +import { registerComponent, registerResource } from "./declarations.js"; +import { addTrait } from "./traits.js"; import { identify } from "./ids.test-helper.js"; import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; @@ -38,12 +39,12 @@ identify(taggedAgain, "idsTest/keys.ts#tagged"); // The keys' ids are given by hand, after the keys are made, so they're registered here rather than declared, which // registers a key as soon as it's made. -declareComponent$(score, { type: { type: "number" } }); -declareComponent$(shown, { type: { const: true }, derived: true }); -declareResource$(level, { type: { type: "number", default: 0 } }); -declareComponent$(changed, { type: { type: "number" } }); -declareComponent$(crossed, { type: { type: "number" } }); -declareTrait$(tagged); +registerComponent(score, { type: { type: "number" } }); +registerComponent(shown, { type: { const: true }, derived: true }); +registerResource(level, { type: { type: "number", default: 0 } }); +registerComponent(changed, { type: { type: "number" } }); +registerComponent(crossed, { type: { type: "number" } }); +addTrait(tagged); /** Calls a constructor as an untyped caller would, naming keys no declaration types. */ const untyped = unknown>(create: F) => (...parameters: unknown[]): ReturnType => Reflect.apply(create, undefined, parameters); @@ -55,28 +56,28 @@ type Row = { [key: symbol]: number }; describe("keys with ids", () => { it("labels a key by its description, however many keys share it", () => { expect(labelOf(score)).toBe("score"); - declareComponent$(unnamed, { type: { type: "number" } }); + registerComponent(unnamed, { type: { type: "number" } }); expect([labelOf(score), labelOf(unnamed)]).toEqual(["score", "score"]); expect(labelOf(Symbol("score"))).toBe("score"); }); it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { - declareComponent$(scoreAgain, { type: { type: "number" } }); - declareComponent$(shownAgain, { type: { const: true }, derived: true }); - declareResource$(levelAgain, { type: { type: "number", default: 0 } }); - declareTrait$(taggedAgain); + registerComponent(scoreAgain, { type: { type: "number" } }); + registerComponent(shownAgain, { type: { const: true }, derived: true }); + registerResource(levelAgain, { type: { type: "number", default: 0 } }); + addTrait(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); expect(labelOf(scoreAgain)).toBe("score"); }); it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { - expect(() => declareComponent$(changedAgain, { type: { type: "string" } })).toThrow( + expect(() => registerComponent(changedAgain, { type: { type: "string" } })).toThrow( `Cannot register component "changed" (idsTest/keys.ts#changed): its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); - expect(() => declareResource$(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( + expect(() => registerResource(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( `Cannot register resource "crossed" (idsTest/keys.ts#crossed): its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); it("keeps keys of different ids apart, though their descriptions match", () => { - declareComponent$(unnamed, { type: { type: "number" } }); + registerComponent(unnamed, { type: { type: "number" } }); system(identify({ components: { write: [score] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[score] = 1; }); expect(() => system(identify({ components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); }); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts index cb9db587..235f4f40 100644 --- a/packages/ecs/src/interface/registry/ids.ts +++ b/packages/ecs/src/interface/registry/ids.ts @@ -7,6 +7,13 @@ export const idsName = "@adobe/ecs/ids@1"; const ids = Symbol.for(idsName); +/** + * The `Symbol.for` name of the global `Map` code the plugin generates fills, from each key a module declares to its + * kind and declaration, `{ kind, declaration }`, in the order modules evaluate. Every copy of the package reads it and + * none changes it. Its version changes with the format. + */ +export const declarationsName = "@adobe/ecs/declarations@1"; + const idsMap = (): Map | undefined => { const map: unknown = Reflect.get(globalThis, ids); return map instanceof Map ? map : undefined; diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts index 146dbd6f..1d3a315f 100644 --- a/packages/ecs/src/interface/registry/names.ts +++ b/packages/ecs/src/interface/registry/names.ts @@ -39,25 +39,47 @@ const wellKnown: ReadonlyMap = new Map(Reflect.ownKeys(Symbol).f return typeof value === "symbol" ? [[value, `Symbol.${String(name)}`]] : []; })); +/** Registers what the build declared since it last ran; the registry's declarations install it. */ +let catchUp = (): void => {}; + +/** Installs what registers the build's declarations, which every read of keys runs first. */ +export const installCatchUp = (update: () => void): void => { + catchUp = update; +}; + +/** Why each key the build declared failed to register, for the errors its uses give. */ +const failures = new Map(); + +/** Records why a key the build declared failed to register. */ +export const recordFailure = (key: symbol, message: string): void => { + failures.set(key, message); +}; + +/** A key's entry, once what the build declared is registered. */ +const entryOf = (key: symbol): Entry | undefined => { + catchUp(); + return entries.get(key); +}; + /** Whether a key is a component, a resource or a trait. */ -export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entries.get(key)?.kind : undefined; +export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entryOf(key)?.kind : undefined; /** The declaration of a registered component or resource. */ -export const declarationOf = (key: unknown): AnyDeclaration | undefined => typeof key === "symbol" ? entries.get(key)?.declaration : undefined; +export const declarationOf = (key: unknown): AnyDeclaration | undefined => typeof key === "symbol" ? entryOf(key)?.declaration : undefined; /** Whether two values are one key: the same value, or symbols registered under one id. */ export const sameKey = (a: unknown, b: unknown): boolean => { if (a === b) return true; if (typeof a !== "symbol" || typeof b !== "symbol") return false; - const entry = entries.get(a); + const entry = entryOf(a); return entry !== undefined && entry === entries.get(b); }; /** The symbol first registered as this key, which stands for it wherever keys are compared; any other value as it is. */ -export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.first ?? key : key; +export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entryOf(key)?.first ?? key : key; /** The symbol last registered as this key, by which a world gives values; any other value as it is. */ -export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.newest ?? key : key; +export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entryOf(key)?.newest ?? key : key; /** * How errors and reports name a key: its description, or without one its binding from its id, whatever other keys @@ -134,12 +156,19 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration) }; /** The symbol last registered under an id, by which a world gives values, or undefined where none was. */ -export const keyOfId = (id: string): symbol | undefined => identified.get(id)?.newest; +export const keyOfId = (id: string): symbol | undefined => { + catchUp(); + return identified.get(id)?.newest; +}; /** - * Why a key isn't registered, for errors: the build registers keys from their declarations, so either its module - * wasn't built with the plugin, which gives keys ids, or it isn't declared. + * Why a key isn't registered, for errors: its declaration failed to register; or the build registers keys from their + * declarations, so either its module wasn't built with the plugin, which gives keys ids, or it isn't declared. */ -export const whyUnregistered = (key: unknown): string => typeof key === "symbol" && idOfKey(key) === undefined - ? "the build's Compiler.plugin() registers keys from their declarations, and didn't build the module making it" - : "declare it beside its key, in interface Components or Resources"; +export const whyUnregistered = (key: unknown): string => { + const failure = typeof key === "symbol" ? failures.get(key) : undefined; + if (failure !== undefined) return `its declaration failed to register: ${failure}`; + return typeof key === "symbol" && idOfKey(key) === undefined + ? "the build's Compiler.plugin() registers keys from their declarations, and didn't build the module making it" + : "declare it beside its key, in interface Components or Resources"; +}; diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index b5447340..3d2d77d5 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareComponent$ } from '../registry/declare.js'; +import { registerComponent } from '../registry/declarations.js'; import type { Failure, Warning } from './failure.js'; /** @@ -38,7 +38,7 @@ declare module '@adobe/ecs' { const text = { type: "string" } as const; const count = { type: "integer", minimum: 0 } as const; -declareComponent$(run, { +registerComponent(run, { type: { type: "object", properties: { @@ -47,7 +47,7 @@ declareComponent$(run, { }, }, }); -declareComponent$(errors, { +registerComponent(errors, { type: { type: "object", properties: { diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index d159d36a..b9a8aacd 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { declareComponent$, declareResource$ } from "../registry/declare.js"; +import { registerComponent, registerResource } from "../registry/declarations.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; import { always, cadence, clock, gpuDevice, wake, workers } from "../resources/index.js"; @@ -30,7 +30,7 @@ declare module "@adobe/ecs" { } /** Registers a declaration as the build does, which a test may write wrong. */ -const register = (key: unknown, declaration: object): void => declareResource$(key, declaration); +const register = (key: unknown, declaration: object): void => registerResource(key, declaration); const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; @@ -50,7 +50,7 @@ describe("registering a resource", () => { expect(() => register(time, { type: { type: "number", default: 0 } })).not.toThrow(); expect(() => register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); const shared = Symbol("typesResourceTest/shared"); - declareComponent$(shared, { type: { const: true } }); + registerComponent(shared, { type: { const: true } }); expect(() => register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); }); it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index 52f95457..3d06f05b 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ export const always = Symbol("always"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -declareResource$(always, { type: { type: "boolean", default: false } }); +registerResource(always, { type: { type: "boolean", default: false } }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index dd83e2b8..b0e010b3 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** The cadence's value, for the implementation: `Resource.Value`. */ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; @@ -17,7 +17,7 @@ declare module '@adobe/ecs' { } } -declareResource$(cadence, { +registerResource(cadence, { type: { anyOf: [ { type: "object", properties: { kind: { const: "frame" } } }, diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index ce591a61..184fa5b8 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the @@ -13,6 +13,6 @@ declare module '@adobe/ecs' { } } -declareResource$(clock, { +registerResource(clock, { type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, }); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts index 28c1c524..38c50c4e 100644 --- a/packages/ecs/src/interface/resources/gpu-device.ts +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world @@ -14,4 +14,4 @@ declare module '@adobe/ecs' { } } -declareResource$(gpuDevice, {}); +registerResource(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index 1cc9bdfa..188830f2 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity @@ -13,4 +13,4 @@ declare module '@adobe/ecs' { } } -declareResource$(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +registerResource(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index 983fcec0..65afd664 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declareResource$ } from '../registry/declare.js'; +import { registerResource } from '../registry/declarations.js'; /** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ export const workers = Symbol("workers"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -declareResource$(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +registerResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); From f67b94c654ba27cdbfa777331ef2f14ab70cfd6d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:21:45 -0700 Subject: [PATCH 077/127] ecs: a declaration without a literal default starts at its type's: its namespaced schema's, null, a zero value, or a fresh Map or Set per world typeof defaults are gone: a default is a literal type. A resource or derived component without one starts at the default its type's namespaced schema holds (type X plus const X = { schema: { default } }), null when its type allows it, its zero value (0, false, "", item by item and field by field, a union's literal that is one), or a fresh empty Map or Set of each world's own; otherwise the build fails, saying how to give one. Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 120 +++++++++---- .../implementation/compiler/augmentations.ts | 163 ++++++++++++++++-- .../src/implementation/world/defaults.test.ts | 30 ++++ .../ecs/src/implementation/world/resources.ts | 9 +- .../ecs/src/implementation/world/state.ts | 6 +- .../src/interface/registry/declarations.ts | 15 +- .../src/interface/resource/register.test.ts | 27 +-- 7 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 packages/ecs/src/implementation/world/defaults.test.ts diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 71e21678..cce002d1 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -61,37 +61,96 @@ describe("augmentationInsertions", () => { expect(await passed(`${binding}\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`)).toContain("which the code recording its declarations uses"); } }); - test("gives a default as a literal, or as the value `typeof` names, in the schema", async () => { - expect(await registrations(`${keys("gravity", "mode", "selected", "origin")}declare module "@adobe/ecs" { + test("gives a literal default in the schema", async () => { + expect(await registrations(`${keys("gravity", "mode", "origin")}declare module "@adobe/ecs" { interface Resources { [gravity]: { type: F32; default: -9.8 }; [mode]: { type: "all" | "done"; default: "all" }; - [selected]: { type: Entity; default: typeof Entity.none }; - [origin]: { type: Vec3; default: [0, 0, 0] }; + [origin]: { type: Vec3; default: [1, 2, 3] }; } }`)).toEqual([ `declare$(gravity, "resource", { type: {"type":"number","precision":1,"default":-9.8} });`, `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}],"default":"all"} });`, - `declare$(selected, "resource", { type: {"type":"integer","format":"entity","default":Entity.none} });`, - `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, + `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[1,2,3]} });`, ]); }); - test("gives a value that isn't data no schema, and its default beside its traits", async () => { + test("derives a resource's or derived component's default from its type: zero values, null, or a fresh Map or Set", async () => { + expect(await registrations(`${keys("layer", "drawn", "speed", "count", "on", "text", "at", "stats", "focus", "frame", "list", "maybe", "names", "tags", "cache", "audio")}declare module "@adobe/ecs" { + interface Components { + [layer]: { type: number; derived: true }; + [drawn]: { type: true; derived: true }; + [speed]: { type: number }; + } + interface Resources { + [count]: { type: U32 }; + [on]: { type: boolean }; + [text]: { type: string }; + [at]: { type: Vec3 }; + [stats]: { type: { readonly sprites: U32; readonly frames: U32 } }; + [focus]: { type: Entity }; + [frame]: { type: 0 | 1 | 2 }; + [list]: { type: readonly string[] }; + [maybe]: { type: F32 | null }; + [names]: { type: ReadonlyMap }; + [tags]: { type: ReadonlySet }; + [cache]: { type: Map; thread: "main" }; + [audio]: { type: AudioContext | null }; + } +}`)).toEqual([ + `declare$(layer, "component", { type: {"type":"number","default":0}, derived: true });`, + `declare$(drawn, "component", { type: {"const":true}, derived: true });`, + `declare$(speed, "component", { type: {"type":"number"} });`, + `declare$(count, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295,"default":0} });`, + `declare$(on, "resource", { type: {"type":"boolean","default":false} });`, + `declare$(text, "resource", { type: {"type":"string","default":""} });`, + `declare$(at, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, + `declare$(stats, "resource", { type: {"type":"object","properties":{"sprites":{"type":"integer","minimum":0,"maximum":4294967295},"frames":{"type":"integer","minimum":0,"maximum":4294967295}},"default":{"sprites":0,"frames":0}} });`, + `declare$(focus, "resource", { type: {"type":"integer","format":"entity","default":0} });`, + `declare$(frame, "resource", { type: {"anyOf":[{"const":0},{"const":1},{"const":2}],"default":0} });`, + `declare$(list, "resource", { type: {"type":"array","items":{"type":"string"},"default":[]} });`, + `declare$(maybe, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, + `declare$(names, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, fresh: "Map" });`, + `declare$(tags, "resource", { type: {"type":"set","items":{"type":"string"}}, fresh: "Set" });`, + `declare$(cache, "resource", { thread: "main", fresh: "Map" });`, + `declare$(audio, "resource", {});`, + ]); + }); + test("takes the default of a namespaced type's schema, after the value holding it, unless a literal is given", async () => { + const files = { "/src/bounds.ts": `import { F32 } from "@adobe/ecs";\nexport type Upper = F32;\nexport const Upper = { schema: { ...F32.schema, default: Infinity } } as const;` }; + const code = await passed(`import { Upper } from "./bounds.js";\n${keys("low", "high", "given", "mark")}declare module "@adobe/ecs" { + interface Components { [mark]: { type: Lower } } + interface Resources { [low]: { type: Lower }; [high]: { type: Upper }; [given]: { type: Lower; default: 1 } } +} +type Lower = F32; +const Lower = { schema: { type: "number", precision: 1, default: -Infinity } } as const; +const after = 1;`, files); + expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ + `declare$(high, "resource", { type: {"type":"number","precision":1,"default":Upper.schema.default} });`, + `declare$(given, "resource", { type: {"type":"number","precision":1,"default":1} });`, + `declare$(mark, "component", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + `declare$(low, "resource", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + ]); + expect(code?.split("\n").slice(-4)).toEqual([ + `const Lower = { schema: { type: "number", precision: 1, default: -Infinity } } as const;`, + `declare$(mark, "component", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + `declare$(low, "resource", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + `const after = 1;`, + ]); + }); + test("gives a value that isn't data no schema, and its traits", async () => { const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; - expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}const empty = new Pool(); -declare module "@adobe/ecs" { + expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}declare module "@adobe/ecs" { interface Components { [row]: { type: Row; thread: "main" } } interface Resources { - [pool]: { type: Pool; default: typeof empty }; + [pool]: { type: Pool | null; default: null }; [canvas]: { type: OffscreenCanvas | null; transfer: true }; [callback]: { type: (() => void) | null }; } }`, files)).toEqual([ `declare$(row, "component", { thread: "main" });`, + `declare$(pool, "resource", { default: null });`, `declare$(canvas, "resource", { transfer: true });`, `declare$(callback, "resource", {});`, - // After the value its default names. - `declare$(pool, "resource", { default: empty });`, ]); }); test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => { @@ -126,14 +185,15 @@ declare module "@adobe/ecs" { ]); }); test("registers right after its key, and after the bindings its declaration names, wherever the block is", async () => { - const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Vec3; default: typeof start } } } + const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Start } } } const later = Symbol(); -const start = [1, 2, 3] as const; +type Start = Vec3; +const Start = { schema: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [1, 2, 3] } } as const; const after = 1;`); - expect(code?.split("\n").slice(-3)).toEqual([`const start = [1, 2, 3] as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":start} });`, `const after = 1;`]); + expect(code?.split("\n").slice(-3)).toEqual([`const Start = { schema: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [1, 2, 3] } } as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":Start.schema.default} });`, `const after = 1;`]); // Code between the key and a block below uses the key registered. - const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 0 } } }`); - expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1,"default":0} });`]); + const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 1 } } }`); + expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1,"default":1} });`]); }); test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => { const code = await passed(`declare module "@adobe/ecs" { @@ -146,21 +206,6 @@ const marked = Symbol(); const mark = Symbol();`); expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]); }); - test("registers after a let, var or class its default names, which have no value before their statements", async () => { - const code = await passed(`${keys("a", "b", "c")}declare module "@adobe/ecs" { - interface Resources { [a]: { type: Pool; default: typeof first }; [b]: { type: Pool; default: typeof second }; [c]: { type: new () => object; default: typeof Pool } } -} -let first = new (class {})(); -var second = new (class {})(); -class Pool {} -const after = 1;`); - expect(code?.split("\n").slice(-7)).toEqual([ - `let first = new (class {})();`, `declare$(a, "resource", { default: first });`, - `var second = new (class {})();`, `declare$(b, "resource", { default: second });`, - `class Pool {}`, `declare$(c, "resource", { default: Pool });`, - `const after = 1;`, - ]); - }); test("takes a type that allows null through its aliases", async () => { const files = { "/src/maybe.ts": `export type Maybe = Pool | null;\nexport class Pool {}` }; expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declare$(pool, "resource", {});`]); @@ -198,11 +243,14 @@ const after = 1;`); const files = { "/src/keys.ts": `export const elsewhere = Symbol();\nexport const layer = Symbol();` }; expect(await passed(`import { elsewhere } from "./keys.js";\n${block(`interface Components { [elsewhere]: { type: number } }`)}`, files)).toContain(`elsewhere is imported from "./keys.js": declare it in the module that declares the key`); expect(await passed(`import type { layer } from "./keys.js";\n${keys("key")}${block(`interface Components { [key]: { type: true; [layer]: true } }`)}`, files)).toContain("layer, imported as a type only"); - expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: number } }`)}`)).toContain("default"); - expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain("declares the value it starts at"); - expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("declares the value it starts at"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain(`a union of literals without 0, false or "" starts at none of them`); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: "all" | "done" } }`)}`)).toContain("declare `default:` one of them"); + expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("starts at none of them"); + expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("const Pool = { schema: { default: ... } }"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)).toContain("0 isn't one of its values"); + expect(await passed(`const start = 1;\n${keys("key")}${block(`interface Resources { [key]: { type: number; default: typeof start } }`)}`)).toContain("default is a literal type, never `typeof` a value"); + expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nexport const Lower = { schema: { type: "number", default: -1 } } as const;` })).toContain("import Lower as a value"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); - expect(await passed(`import type { Entity as E } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Entity; default: typeof E.none } }`)}`)).toContain("type only"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type"); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index fc116627..cefda6a4 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -183,14 +183,57 @@ const matches = (schema: Schema, value: Value): boolean => { const allowsNullSchema = (schema: Schema): boolean => ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); +/** What a value declared without a default starts at, by its type: a value, a fresh `Map` or `Set` per world, or why neither. */ +type Start = { readonly value: Value } | { readonly fresh: "Map" | "Set" } | { readonly none: string }; + +/** A schema's zero value: 0, false or "", each item's and field's, a union's literal that is one, or a fresh set or map. */ +const zeroOf = (schema: Schema): Start => { + if ("const" in schema) return { value: schema.const }; + if ("anyOf" in schema) { + for (const member of schema.anyOf) if ("const" in member && (member.const === 0 || member.const === false || member.const === "")) return { value: member.const }; + return { none: schema.anyOf.every(member => "const" in member) ? `a union of literals without 0, false or "" starts at none of them: declare \`default:\` one of them` : "a union of types starts at none of them" }; + } + if ("allOf" in schema) return { none: "a value of any type has no zero value" }; + switch (schema.type) { + case "number": case "integer": { + const minimum = "minimum" in schema ? schema.minimum ?? -Infinity : -Infinity; + const maximum = "maximum" in schema ? schema.maximum ?? Infinity : Infinity; + return minimum <= 0 && maximum >= 0 ? { value: 0 } : { none: "0 isn't one of its values" }; + } + case "boolean": return { value: false }; + case "string": return (schema.minLength ?? 0) === 0 ? { value: "" } : { none: `"" isn't one of its values` }; + case "null": return { value: null }; + case "set": return { fresh: "Set" }; + case "map": return { fresh: "Map" }; + case "array": { + const count = schema.minItems ?? 0; + if (count === 0) return { value: [] }; + if (schema.maxItems !== count) return { none: `an array of ${count} items or more has no zero value` }; + const item = zeroOf(schema.items); + return "value" in item ? { value: Array.from({ length: count }, () => item.value) } : { none: "fresh" in item ? "an item that's a set or a map has no zero value" : item.none }; + } + case "object": { + const value: { [field: string]: Value } = {}; + for (const field of schema.required ?? Object.keys(schema.properties)) { + const property = schema.properties[field]; + const zero = property === undefined ? { none: "it isn't a property" } : zeroOf(property); + if (!("value" in zero)) return { none: `its field ${field} starts at nothing: ${"fresh" in zero ? "a set or a map in a struct has no zero value" : zero.none}` }; + value[field] = zero.value; + } + return { value }; + } + } +}; + /** * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: each * component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and its other * traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant of this * module, so importing a key registers it: its registration comes right after it, after any binding its declaration * names, and after a trait of this module it declares, wherever the block is. A literal default and the built-in - * traits' values are checked as far as literals and types show; `default: typeof value` names a value, which the - * registration passes. Returns null when the module declares nothing. + * traits' values are checked as far as literals and types show. A resource or derived component without a literal + * default starts at its type's: its namespaced schema's, null, a zero value, or a fresh `Map` or `Set` per world. + * Returns null when the module declares nothing. */ export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => { const { code, file } = module; @@ -301,10 +344,88 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (placed !== undefined && schema !== undefined && isStruct(schema)) fail(module, placed.node, `${name} is a struct: a struct lives in shared memory, so it has no thread`); }; + /** An expression without the casts and parentheses around it. */ + const unwrapped = (expression: Declarator["init"]): Declarator["init"] => { + let inner = expression; + while (inner !== null && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) inner = inner.expression; + return inner; + }; + + /** The names a name or a chain of non-computed property reads reads; null for any other expression. */ + const pathOf = (expression: NonNullable): readonly string[] | null => { + if (expression.type === "Identifier") return [expression.name]; + if (expression.type !== "MemberExpression" || expression.computed || expression.property.type !== "Identifier" || expression.object.type === "Super") return null; + const object = pathOf(expression.object); + return object === null ? null : [...object, expression.property.name]; + }; + + /** Whether a schema's value, as its module writes it, gives a `default`: in place, or through constants and spreads. */ + const holdsDefault = async (expression: Declarator["init"], at: Module, seen: Set): Promise => { + const value = unwrapped(expression); + if (value === null || seen.has(value)) return false; + seen.add(value); + if (value.type === "Identifier" || value.type === "MemberExpression") { + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + return found !== null && await holdsDefault(found.declaration.init, found.module, seen); + } + if (value.type !== "ObjectExpression") return false; + for (const property of value.properties) { + if (property.type === "SpreadElement" ? await holdsDefault(property.argument, at, seen) : !property.computed && property.key.type === "Identifier" && property.key.name === "default") return true; + } + return false; + }; + + /** Whether the value a type's name names, as `Vec3` names its namespace beside its type, has a schema with a `default`. */ + const declaresSchemaDefault = async (path: readonly string[]): Promise => { + const member = await converter.constantNamed([...path, "schema"], module); + if (member !== null) return await holdsDefault(member.declaration.init, member.module, new Set()); + const holder = await converter.constantNamed(path, module); + const value = unwrapped(holder?.declaration.init ?? null); + if (holder === null || value?.type !== "ObjectExpression") return false; + for (const property of value.properties) { + if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "schema") { + return await holdsDefault(property.value, holder.module, new Set()); + } + } + return false; + }; + + /** A declaration's type's namespaced schema default, as code reading it, and the local binding it reads; null without one. */ + const namespacedDefault = async (type: TypeNode, name: string): Promise<{ readonly text: string; readonly local?: string } | null> => { + if (type.type !== "TSTypeReference" || type.typeArguments !== null) return null; + const path = typeNameParts(type.typeName); + const [first] = path; + if (first === undefined || !await declaresSchemaDefault(path)) return null; + const imported = module.imports.get(first); + if (imported !== undefined && !imported.value) fail(module, type, `${name}'s type ${path.join(".")} starts at its schema's default: import ${first} as a value, which the registration reads`); + return { text: `${path.join(".")}.schema.default`, local: imported === undefined ? first : undefined }; + }; + + /** The platform class a value that isn't data starts as a fresh, empty instance of, per world: `Map` or `Set`. */ + const freshOf = (type: TypeNode): "Map" | "Set" | null => { + const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; + if (inner.type !== "TSTypeReference" || inner.typeName.type !== "Identifier") return null; + const { name } = inner.typeName; + return (name === "Map" || name === "Set") && !module.types.has(name) && !module.imports.has(name) ? name : null; + }; + + /** A declared default: a literal type, never `typeof` a value. */ + const literalDefault = async (type: TypeNode, name: string): Promise => { + if (type.type === "TSTypeQuery") fail(module, type, `${name}'s default is a literal type, never \`typeof\` a value: without one, its type's zero value, null, or the default of its type's namespaced schema is what it starts at`); + return await converter.valueOfType(type, module, new Set()); + }; + + /** How to give a type's values a start no zero value gives. */ + const workaround = (type: TypeNode): string => { + const name = type.type === "TSTypeReference" ? typeNameParts(type.typeName).join(".") : "Start"; + return `declare \`default:\` as a literal, or name a type whose namespace's schema holds its default: \`type ${name} = ...; const ${name} = { schema: { default: ... } }\``; + }; + /** Where a trait a block of this module declares is registered, which what declares it comes after. */ const traitPoints = new Map(); - /** A trait's or default's value as code: a literal, or `typeof` a value, which names it; and that value's local binding. */ + /** A trait's value as code: a literal, or `typeof` a value, which names it; and that value's local binding. */ const valueText = async (type: TypeNode, what: string): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string }> => { if (type.type === "TSTypeQuery") { const named = type.exprName.type === "TSImportType" ? null : qualifiedParts(type.exprName); @@ -387,14 +508,36 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); } await checkBuiltIns(key.name, kind, typeNode, schema, builtIns); - const start = defaultNode === undefined ? undefined : await valueText(defaultNode, `${key.name}'s default`); - if (start?.local !== undefined) at = Math.max(at, endOfLocal(start.local)); - if (schema !== undefined && start?.value !== undefined && !matches(schema, start.value)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); - const allowsNull = schema === undefined ? await allowsNullType(typeNode, module, new Set()) : allowsNullSchema(schema) || "default" in schema; - if (kind === "resource" && start === undefined && !allowsNull) fail(module, member, `resource ${key.name} declares the value it starts at: \`default: ...\`, which a type allowing null may leave out`); - const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start.text}}`}`]; - if (schema === undefined && start !== undefined) parts.push(`default: ${start.text}`); + const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name); + if (schema !== undefined && given !== undefined && !matches(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); + let start = given === undefined ? undefined : JSON.stringify(given); + let fresh: "Map" | "Set" | null = null; + // First a literal, then the type's schema's default; only resources and derived components need a start. + const namespaced = given !== undefined || (schema !== undefined && "default" in schema) ? null : await namespacedDefault(typeNode, key.name); + if (namespaced !== null) { + start = namespaced.text; + if (namespaced.local !== undefined) at = Math.max(at, endOfLocal(namespaced.local)); + } + else if (given === undefined && (kind === "resource" || builtIns.has("derived")) && !(schema !== undefined && "default" in schema)) { + if (schema === undefined ? await allowsNullType(typeNode, module, new Set()) : allowsNullSchema(schema)) { + // It starts at null, as the world reads a value with neither. + } + else if (schema === undefined) { + fresh = freshOf(typeNode); + if (fresh === null) fail(module, member, `${kind} ${key.name} isn't data, so it has no zero value: ${workaround(typeNode)}`); + } + else { + const zero = zeroOf(schema); + if ("fresh" in zero) fresh = zero.fresh; + else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode)}`); + // A constant is the one value it can start at, which the world reads from its schema. + else if (!("const" in schema)) start = JSON.stringify(zero.value); + } + } + const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start}}`}`]; + if (schema === undefined && start !== undefined) parts.push(`default: ${start}`); parts.push(...traits); + if (fresh !== null) parts.push(`fresh: "${fresh}"`); insertions.push({ at, text: `\n${record}(${key.name}, "${kind}", ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); } } diff --git a/packages/ecs/src/implementation/world/defaults.test.ts b/packages/ecs/src/implementation/world/defaults.test.ts new file mode 100644 index 00000000..df89d53e --- /dev/null +++ b/packages/ecs/src/implementation/world/defaults.test.ts @@ -0,0 +1,30 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { createWorld } from "../index.js"; +import { readResource } from "./reads.test-helper.js"; + +const pool = Symbol(); +const names = Symbol(); +const count = Symbol(); + +declare module "@adobe/ecs" { + export interface Resources { + [pool]: { type: Map }; + [names]: { type: ReadonlyMap }; + [count]: { type: number }; + } +} + +describe("a resource declared without a default", () => { + it("starts at its type's zero value, and a Map or Set at an empty one of its world's own", async () => { + await using first = createWorld(); + await using second = createWorld(); + const [firstPool, secondPool] = [await readResource(first, pool), await readResource(second, pool)]; + expect(firstPool).toEqual(new Map()); + expect(secondPool).toEqual(new Map()); + expect(firstPool).not.toBe(secondPool); + expect(await readResource(first, pool)).toBe(firstPool); + expect(await readResource(first, names)).toEqual(new Map()); + expect(await readResource(first, count)).toBe(0); + }); +}); diff --git a/packages/ecs/src/implementation/world/resources.ts b/packages/ecs/src/implementation/world/resources.ts index 49ba7335..e6823a9c 100644 --- a/packages/ecs/src/implementation/world/resources.ts +++ b/packages/ecs/src/implementation/world/resources.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Storage } from './archetype.js'; import { indexOf, reserve } from './entities.js'; -import { defaultOf, idOf, type Key, type State } from './state.js'; +import { idOf, type Key, type State } from './state.js'; import { synchronizeAlone } from './synchronize.js'; import { addCommand, createBuffer, spawnCommand } from './buffer.js'; import { pushValues } from './commands.js'; @@ -10,8 +10,11 @@ import { labelOf } from '../../interface/registry/index.js'; /** Resources are components on one entity, so they share storage, ticks and sync with every other component. */ export type ResourceCell = { readonly storage: Storage; readonly row: number }; -/** A resource's value before anything writes it: the one given at the world's creation, or its default. */ -export const initialOf = (state: State, key: Key): unknown => state.initial.has(key) ? state.initial.get(key) : defaultOf(key); +/** + * A resource's value before anything writes it: the one given at the world's creation, or the start the world made + * for it, so a fresh `Map` or `Set` is one value however often it's read. + */ +export const initialOf = (state: State, key: Key): unknown => state.initial.has(key) ? state.initial.get(key) : state.starts[idOf(state, key)]; /** * Adds any missing resources with their initial values, as their own sync point, between runs; commands that are diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 00e50118..f9511a86 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -422,12 +422,14 @@ export const idOf = (state: State, key: Key): number => { }; /** - * What a resource or derived component starts at: its declaration's default or its schema's, or `null` when its type - * allows leaving it out. + * What a resource or derived component starts at: a new empty `Map` or `Set` when it starts fresh, so each world has + * its own; its declaration's default or its schema's; or `null` when its type allows leaving it out. */ export const defaultOf = (key: Key): unknown => { const declaration = declarationOf(key); if (declaration === undefined) return null; + if (declaration.fresh === "Map") return new Map(); + if (declaration.fresh === "Set") return new Set(); if ("default" in declaration) return declaration.default; const { type } = declaration; if (type === undefined) return null; diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index 062e6187..8a1a90d3 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -11,9 +11,9 @@ import { addTrait, isTrait } from './traits.js'; /** * A registered declaration, as any consumer iterating components or resources sees it: its `type` and other traits. * Without `type`, the value isn't data. A resource's `default`, or a derived object's, is what it starts at, when its - * schema has none. + * schema has none; `fresh` starts it as an empty `Map` or `Set` of each world's own. */ -export type AnyDeclaration = { readonly type?: Schema & { readonly default?: unknown }; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; +export type AnyDeclaration = { readonly type?: Schema & { readonly default?: unknown }; readonly default?: unknown; readonly fresh?: unknown } & { readonly [T in TraitKey]?: unknown }; /** The value schema of any registered component or resource, or undefined for a value that isn't data. */ export const schemaOf = (key: unknown): Schema | undefined => declarationOf(key)?.type; @@ -43,12 +43,17 @@ export const threadOfDeclaration = (declaration: AnyDeclaration): string | null const check = (kind: "component" | "resource", key: symbol, declaration: AnyDeclaration): void => { const fail = (message: string): never => failRegistration(kind, key, message); for (const trait of Reflect.ownKeys(declaration)) { - if (trait !== "default" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); + if (trait !== "default" && trait !== "fresh" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); } - const { type } = declaration; + const { type, fresh } = declaration; if (type !== undefined && "default" in declaration && (kind === "component" || "default" in type)) { fail(kind === "component" ? "its schema holds its default." : "its default is given twice, by its schema and its declaration."); } + if (fresh !== undefined) { + if (fresh !== "Map" && fresh !== "Set") fail(`fresh is "Map" or "Set".`); + if ("default" in declaration || (type !== undefined && "default" in type)) fail("it starts fresh, so it has no default."); + if (type !== undefined && "type" in type && (type.type === "set" || type.type === "map") && (type.type === "set") !== (fresh === "Set")) fail(`a ${type.type} starts as a fresh ${type.type === "set" ? "Set" : "Map"}.`); + } try { layoutOf(type); } @@ -67,7 +72,7 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl if (declaration.derived !== undefined && kind !== "component") fail("only a component is derived."); if (declaration.derived === true && declaration.within !== undefined) fail("a derived component's rule covers within."); if (declaration.derived === true && declaration.reference !== undefined) fail("a rule can't set a derived component's target, so it's no reference."); - if (declaration.derived === true && type !== undefined && !("default" in type || "const" in type || allowsNull(type))) { + if (declaration.derived === true && type !== undefined && fresh === undefined && !("default" in type || "const" in type || allowsNull(type))) { fail("a derived component's type has a default, which it starts at."); } if (declaration.reduce !== undefined) { diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index b9a8aacd..75319bde 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -12,20 +12,17 @@ const total = Symbol("typesResourceTest/total"); const flags = Symbol("typesResourceTest/flags"); const names = Symbol("typesResourceTest/names"); const tags = Symbol("typesResourceTest/tags"); -const cacheStart = new Map(); -const namesStart = new Map([["a", 1]]); -const tagsStart = new Set(["x"]); declare module "@adobe/ecs" { export interface Resources { [time]: { type: number; default: 0 }; [symbol]: { type: boolean; default: false }; - [cache]: { type: Map; thread: "main"; default: typeof cacheStart }; + [cache]: { type: Map; thread: "main" }; [port]: { type: MessagePort | null; transfer: true }; [total]: { type: number; default: 0; reduce: "+" }; [flags]: { type: boolean; default: false; reduce: "|" }; - [names]: { type: ReadonlyMap; default: typeof namesStart }; - [tags]: { type: ReadonlySet; default: typeof tagsStart }; + [names]: { type: ReadonlyMap }; + [tags]: { type: ReadonlySet }; } } @@ -54,11 +51,12 @@ describe("registering a resource", () => { expect(() => register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); }); it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { - expect(declarationOf(names)).toEqual({ type: { ...namesSchema, default: new Map([["a", 1]]) } }); - expect(() => register(names, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); - expect(() => register(names, { type: { ...namesSchema, default: new Map([["a", 2]]) } })).toThrow("with another declaration"); - expect(() => register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } })).not.toThrow(); - expect(() => register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["y"]) } })).toThrow("with another declaration"); + expect(declarationOf(names)).toEqual({ type: namesSchema, fresh: "Map" }); + expect(declarationOf(tags)).toEqual({ type: { type: "set", items: { type: "string" } }, fresh: "Set" }); + const listed = Symbol("typesResourceTest/listed"); + expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 2]]) } })).toThrow("with another declaration"); expect(() => register(Symbol("typesResourceTest/twice"), { type: { type: "number", default: 0 }, default: 1 })).toThrow("its default is given twice"); expect(threadOfDeclaration(declarationOf(names) ?? {})).toBe("world"); }); @@ -77,13 +75,18 @@ describe("registering a resource", () => { expect([cadence, always, wake, clock].map(name => threadOfDeclaration(declarationOf(name) ?? {}))).toEqual(["world", null, null, null]); }); it("checks thread and transfer as far as values tell", () => { - expect(declarationOf(cache)).toEqual({ default: new Map(), thread: "main" }); + expect(declarationOf(cache)).toEqual({ thread: "main", fresh: "Map" }); expect(threadOfDeclaration(declarationOf(cache) ?? {})).toBe("main"); expect(declarationOf(port)).toEqual({ transfer: true }); expect(() => register(Symbol("typesResourceTest/buffer"), { default: new ArrayBuffer(8), transfer: true })).not.toThrow(); expect(() => register(Symbol("typesResourceTest/map"), { default: new Map(), transfer: true })).toThrow("its value isn't Transferable"); expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number", default: 0 }, thread: "main" })).toThrow("a struct lives in shared memory"); }); + it("starts a fresh value only as an empty Map or Set, and not beside a default", () => { + expect(() => register(Symbol("typesResourceTest/array"), { fresh: "Array" })).toThrow(`fresh is "Map" or "Set".`); + expect(() => register(Symbol("typesResourceTest/both"), { fresh: "Map", default: null })).toThrow("it starts fresh, so it has no default."); + expect(() => register(Symbol("typesResourceTest/set"), { type: { type: "set", items: { type: "string" } }, fresh: "Map" })).toThrow("a set starts as a fresh Set."); + }); it("checks reductions", () => { expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number", default: 0 }, reduce: "-" })).toThrow('reduce is one of "+"'); expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean", default: false }, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); From b496ffd5895722d5dc3c530a3b19bdd0bb7052b0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:31:55 -0700 Subject: [PATCH 078/127] ecs: declarations drop defaults their types give; starts compared by identity come from namespaced schemas; docs typeof defaults become literals, or a namespaced schema's default where a start must be one object, as the objects joined threads fill are matched by identity. Explicit defaults equal to their type's zero value or null are dropped from resources and derived components. The guide and design.md describe the rules. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 8 +++++- packages/ecs/src/exports.test.ts | 2 +- packages/ecs/src/guide/01-resources.test.ts | 27 +++++++++++-------- packages/ecs/src/guide/03-systems.test.ts | 2 +- packages/ecs/src/guide/04-ticks.test.ts | 4 +-- packages/ecs/src/guide/06-sync-points.test.ts | 2 +- .../ecs/src/guide/07-change-filters.test.ts | 4 +-- packages/ecs/src/guide/guide.md | 25 ++++++++++------- .../src/implementation/world/commands.test.ts | 4 +-- .../implementation/world/conditions.test.ts | 12 ++++----- .../implementation/world/finalizers.test.ts | 8 +++--- .../world/index-changed.test.ts | 2 +- .../implementation/world/index-joined.test.ts | 5 +++- .../ecs/src/implementation/world/join.test.ts | 5 ++-- .../world/joining.test-helper.ts | 2 +- .../implementation/world/lifetime.fixture.ts | 2 +- .../src/implementation/world/move-log.test.ts | 5 +++- .../world/observe-cases.test.ts | 6 ++--- .../world/observe-factories.test.ts | 2 +- .../world/observe-joined-cases.test.ts | 7 +++-- .../world/observe-ticks-cases.test.ts | 2 +- .../world/observer-contained.test-helper.ts | 2 +- .../world/observer-contained.test.ts | 4 +-- .../world/observer-joined.test-helper.ts | 2 +- .../world/observer-joined.test.ts | 7 +++-- .../src/implementation/world/observer.test.ts | 2 +- .../world/reductions.fixture.ts | 6 ++--- .../implementation/world/reductions.test.ts | 14 +++++----- .../world/remote-finalizers.test-helper.ts | 2 +- .../world/remote-finalizers.test.ts | 6 ++--- .../world/remote-objects.test-helper.ts | 5 +++- .../src/implementation/world/remote.test.ts | 5 +++- .../world/remove.test-helper.ts | 2 +- .../src/implementation/world/reports.test.ts | 2 +- .../src/implementation/world/strings.test.ts | 2 +- .../world/thread-systems.test-helper.ts | 2 +- .../world/ticks.browser.test.ts | 2 +- .../src/implementation/world/ticks.test.ts | 2 +- .../src/implementation/world/traits.test.ts | 2 +- .../implementation/world/wasm-kernels.test.ts | 3 +-- .../src/interface/finalizer/create.test.ts | 6 ++--- .../ecs/src/interface/system/create.test.ts | 6 ++--- packages/ecs/src/perftest/finalized.ts | 2 +- packages/ecs/src/perftest/log.ts | 5 +++- packages/ecs/src/perftest/remote.ts | 2 +- packages/ecs/src/perftest/suite.ts | 4 +-- packages/ecs/src/perftest/tails.ts | 9 ++++--- packages/ecs/src/world.test.ts | 2 +- 48 files changed, 141 insertions(+), 103 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index e5a73324..e5b685e4 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -77,7 +77,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - - **A resource always has a value:** its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [0, 0, 0]`) or `typeof` a value (`default: typeof Entity.none`, `default: typeof empty`), left out only when the type allows `null`, which it then starts at; the build rejects a resource without one, and a literal default that its schema doesn't hold. A component's default, which a derived component starts at, is declared the same way. The registration puts a data value's default in its schema and gives another's beside it. + - **A resource always has a value,** and so does a derived component. The first of these applies: + 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value; + 2. its type's schema's default: a `Schema.Type<{ …; default }>`, or a namespaced type's, the value beside the type alias with the same name holding a `schema` with a `default` (`type Height = F32; const Height = { schema: { ...F32.schema, default: -Infinity } }`), read as `Height.schema.default`, the one way to give a value no literal type writes; + 3. `null`, when its type allows it; + 4. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, `Entity.none` for an entity, and a union's literal that is one of those; + 5. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap`, `ReadonlySet`, `Map` or `Set`. + Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, or a class that isn't null. A component's literal or schema default is its start the same way, which commands that give it no value add. The registration puts a data value's default in its schema and gives another's beside it. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespaced schema's. - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index 219c2f02..59208e8a 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -115,7 +115,7 @@ export const label = Symbol(); declare module "@adobe/ecs" { interface Traits { readonly [mark]: N extends Component.Key ? (Component.Value extends string ? true : never) : never } interface Components { [count]: { type: number; thread: "main"; [mark]: true } } - interface Resources { [label]: { type: string; default: typeof count; transfer: true } } + interface Resources { [label]: { type: string; default: 1; transfer: true } } } export const world: World = World.create(); `; diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index d66ff607..6eee5484 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -1,32 +1,37 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Entity, System, World } from "../index.js"; +import { Entity, F32, System, World } from "../index.js"; // A resource is a key whose value a world holds once. export const gravity = Symbol(); +export const floor = Symbol(); export const names = Symbol(); export const audio = Symbol(); export const focused = Symbol(); -const noNames: ReadonlyMap = new Map(); -// It's declared once, with its traits: `type` is the value's type, and `default` the value a world starts it at, which -// a type allowing null may leave out. The build registers what's declared. +// A value no literal writes starts at the default its type's namespaced schema holds. +export type Height = F32; +export const Height = { schema: { ...F32.schema, default: -Infinity } } as const; + +// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, +// it starts at its type's: its schema's default, null, its zero value (0, false, "", item by item), or an empty Map or +// Set of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { [gravity]: { type: number; default: -9.8 }; - // A default that isn't a literal names a value with typeof. - [names]: { type: ReadonlyMap; default: typeof noNames }; + [floor]: { type: Height }; + [names]: { type: ReadonlyMap }; // A value that isn't data, such as a class, is stored as it is. [audio]: { type: AudioContext | null }; - // No entity is Entity.none rather than null, so an entity stays data every thread reads. - [focused]: { type: Entity; default: typeof Entity.none }; + // Entity.none, 0, rather than null, so an entity stays data every thread reads. + [focused]: { type: Entity }; } } // Systems read a world: an observer only reads, after the world's changes apply. let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { - values = [resources[gravity], resources[names], resources[audio], resources[focused]]; +const read = System.create({ observer: true, resources: { read: [gravity, floor, names, audio, focused] } }, ({ resources }) => { + values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; }); it("holds one value per world", async () => { @@ -35,7 +40,7 @@ it("holds one value per world", async () => { await using moon = World.create({ [gravity]: -1.6 }); // run: runs a system once. await earth.run(read); - expect(values).toEqual([-9.8, new Map(), null, Entity.none]); + expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]); await moon.run(read); expect(values[0]).toBe(-1.6); }); diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index 2b209815..bb9c965b 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -16,7 +16,7 @@ declare module "@adobe/ecs" { } interface Resources { [delta]: { type: number; default: 0.5 }; - [frames]: { type: number; default: 0 }; + [frames]: { type: number }; } } diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index 722fd0ab..8aee14a8 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -7,8 +7,8 @@ const steps = Symbol(); declare module "@adobe/ecs" { interface Resources { - [elapsed]: { type: number; default: 0 }; - [steps]: { type: number; default: 0 }; + [elapsed]: { type: number }; + [steps]: { type: number }; } } diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts index 852add38..812c6f68 100644 --- a/packages/ecs/src/guide/06-sync-points.test.ts +++ b/packages/ecs/src/guide/06-sync-points.test.ts @@ -10,7 +10,7 @@ declare module "@adobe/ecs" { [health]: { type: number }; } interface Resources { - [seen]: { type: number; default: 0 }; + [seen]: { type: number }; } } diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts index a5b9c924..468cce0b 100644 --- a/packages/ecs/src/guide/07-change-filters.test.ts +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -18,9 +18,9 @@ declare module "@adobe/ecs" { } interface Resources { [level]: { type: number; default: 1 }; - [paused]: { type: boolean; default: false }; + [paused]: { type: boolean }; [map]: { type: { readonly width: number } | null }; - [steps]: { type: number; default: 0 }; + [steps]: { type: number }; } } diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index b6a96b37..92b60e3b 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -59,29 +59,34 @@ Systems change that data, once per run or on every tick. ```ts export const gravity = Symbol(); +export const floor = Symbol(); export const names = Symbol(); export const audio = Symbol(); export const focused = Symbol(); -const noNames: ReadonlyMap = new Map(); -// It's declared once, with its traits: `type` is the value's type, and `default` the value a world starts it at, which -// a type allowing null may leave out. The build registers what's declared. +// A value no literal writes starts at the default its type's namespaced schema holds. +export type Height = F32; +export const Height = { schema: { ...F32.schema, default: -Infinity } } as const; + +// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, +// it starts at its type's: its schema's default, null, its zero value (0, false, "", item by item), or an empty Map or +// Set of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { [gravity]: { type: number; default: -9.8 }; - // A default that isn't a literal names a value with typeof. - [names]: { type: ReadonlyMap; default: typeof noNames }; + [floor]: { type: Height }; + [names]: { type: ReadonlyMap }; // A value that isn't data, such as a class, is stored as it is. [audio]: { type: AudioContext | null }; - // No entity is Entity.none rather than null, so an entity stays data every thread reads. - [focused]: { type: Entity; default: typeof Entity.none }; + // Entity.none, 0, rather than null, so an entity stays data every thread reads. + [focused]: { type: Entity }; } } // Systems read a world: an observer only reads, after the world's changes apply. let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { - values = [resources[gravity], resources[names], resources[audio], resources[focused]]; +const read = System.create({ observer: true, resources: { read: [gravity, floor, names, audio, focused] } }, ({ resources }) => { + values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; }); await using moon = World.create({ [gravity]: -1.6 }); @@ -90,7 +95,7 @@ await moon.run(read); expect(values[0]).toBe(-1.6); ``` -[`01-resources.test.ts`](01-resources.test.ts#L6-L40) +[`01-resources.test.ts`](01-resources.test.ts#L6-L45) ### 2. Entities, components and archetypes diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index f31d2afc..431f2897 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -41,14 +41,14 @@ declare module "@adobe/ecs" { [label]: { type: string | null }; [tag]: { type: true }; [other]: { type: true }; - [slot]: { type: number; derived: true; default: 0 }; + [slot]: { type: number; derived: true }; [bit0]: { type: true }; [bit1]: { type: true }; [bit2]: { type: true }; [bit3]: { type: true }; } export interface Resources { - [level]: { type: number; default: 0 }; + [level]: { type: number }; } } Rule.create({ components: { has: [other, score] }, derive: [slot] }); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index ddeed624..00c32c58 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -16,13 +16,13 @@ const value = Symbol("conditionsTest/value"); declare module "@adobe/ecs" { interface Resources { - [paused]: { type: boolean; default: false }; + [paused]: { type: boolean }; [level]: { type: { readonly size: number } | null }; - [ticks]: { type: number; default: 0 }; - [after]: { type: number; default: 0 }; - [sizes]: { type: number; default: 0 }; - [total]: { type: number; reduce: "+"; default: 0 }; - [count]: { type: number; default: 0 }; + [ticks]: { type: number }; + [after]: { type: number }; + [sizes]: { type: number }; + [total]: { type: number; reduce: "+" }; + [count]: { type: number }; } interface Components { [value]: { type: number }; diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index c9b5fc2d..bbcce6c6 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -35,7 +35,7 @@ declare module "@adobe/ecs" { [handle]: { type: number }; [hidden]: { type: true }; [source]: { type: number }; - [slot]: { type: number; derived: true; default: 0 }; + [slot]: { type: number; derived: true }; [parent]: { type: Entity; reference: "owner" }; [label]: { type: string }; [late]: { type: number }; @@ -47,9 +47,9 @@ declare module "@adobe/ecs" { [disposing]: { type: number }; } export interface Resources { - [released]: { type: number; default: 0 }; - [level]: { type: number; default: 0 }; - [written]: { type: number; default: 0 }; + [released]: { type: number }; + [level]: { type: number }; + [written]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index 5084ba97..da9289b3 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { [total]: { type: number }; } interface Resources { - [paused]: { type: boolean; default: false }; + [paused]: { type: boolean }; } } diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 1f388847..f15ba613 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -40,7 +40,7 @@ declare module "@adobe/ecs" { [target]: { type: U32 }; [lane]: { type: U32 }; [rank]: { type: number }; - [label]: { type: { readonly text: string }; thread: "indexJoinedTest"; default: typeof blank }; + [label]: { type: Label; thread: "indexJoinedTest" }; [name]: { type: { readonly text: string }; thread: "indexJoinedTest" }; [title]: { type: string }; [band]: { type: U32 }; @@ -51,6 +51,9 @@ declare module "@adobe/ecs" { const text = { type: "object", properties: { text: { type: "string" } } } as const; /** A label's start, which a command here gives it by this value. */ const blank = { text: "" }; +/** A label, which starts as `blank`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string }; +const Label = { schema: { default: blank } }; const grid = Index.create({ key: [cell], components: { has: [weight] } }); const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index 426a9844..a95dab38 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -24,12 +24,11 @@ declare module "@adobe/ecs" { [words]: { type: ReadonlySet }; } export interface Resources { - [total]: { type: number; default: 0 }; + [total]: { type: number }; [names]: { type: readonly string[]; default: [] }; - [counts]: { type: ReadonlyMap; default: typeof countsStart }; + [counts]: { type: ReadonlyMap }; } } -const countsStart = new Map(); /** Adds its struct input to `total`, and its object input's names to `names`. */ const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 8bf3c356..53723112 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -18,7 +18,7 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } export interface Resources { - [total]: { type: number; default: 0 }; + [total]: { type: number }; [names]: { type: readonly string[]; default: [] }; } } diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 3cdba090..8f146b0d 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -23,7 +23,7 @@ declare module "@adobe/ecs" { [out]: { type: F32 }; } export interface Resources { - [total]: { type: number; reduce: "+"; default: 0 }; + [total]: { type: number; reduce: "+" }; } } diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index f0b74709..1ea558cb 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -34,7 +34,7 @@ declare module "@adobe/ecs" { [uid]: { type: number }; [amount]: { type: number }; [flag]: { type: true }; - [label]: { type: { readonly text: string } | null; thread: "moveLogTest"; default: typeof none }; + [label]: { type: Label; thread: "moveLogTest" }; [note]: { type: Note | null; thread: "moveLogTest" }; [own]: { type: string }; } @@ -43,6 +43,9 @@ declare module "@adobe/ecs" { type Note = { readonly text: string; readonly read: () => string }; /** The label's start, which a command on another thread than its own gives it by this value. */ const none = { text: "none" }; +/** A label, which starts as `none`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string } | null; +const Label = { schema: { default: none } }; const remoteKeys = [label, note] as const; diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 9cd1b417..28c46b34 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -21,9 +21,9 @@ const weight = Symbol("observeCasesTest/weight"); declare module "@adobe/ecs" { interface Resources { - [level]: { type: number; default: 0 }; - [other]: { type: number; default: 0 }; - [total]: { type: number; reduce: "+"; default: 0 }; + [level]: { type: number }; + [other]: { type: number }; + [total]: { type: number; reduce: "+" }; } interface Components { [weight]: { type: number }; diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index 47cd4dd0..c95a41b7 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { [other]: { type: number }; } interface Resources { - [level]: { type: number; default: 0 }; + [level]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index bf6d2af5..e3cc4db6 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -29,17 +29,20 @@ declare module "@adobe/ecs" { [label]: { type: string }; [marked]: { type: true }; [place]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "observeJoinedTest"; default: typeof none }; + [caption]: { type: Caption; thread: "observeJoinedTest" }; [widget]: { type: { readonly draw: () => string } | null; thread: "observeJoinedTest" }; [kept]: { type: string }; } export interface Resources { - [total]: { type: number; default: 0 }; + [total]: { type: number }; [names]: { type: readonly string[]; default: [] }; } } /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { schema: { default: none } }; const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 9858098f..9f045a67 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -15,7 +15,7 @@ const watched = Symbol("observeTicksCasesTest/watched"); const flag = Symbol("observeTicksCasesTest/flag"); declare module "@adobe/ecs" { interface Resources { - [frames]: { type: number; default: 0 }; + [frames]: { type: number }; [seen]: { type: number; default: -1 }; } interface Components { diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 72064e2c..a50698e1 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -10,7 +10,7 @@ export const holding = Symbol("observerContainedTest/holding"); declare module "@adobe/ecs" { export interface Resources { [tally]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; - [holding]: { type: number; default: 0 }; + [holding]: { type: number }; } } /** While negative, each run of the watcher says it reached its value and waits until the world's thread releases it. */ diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index afd92345..ac856192 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -12,8 +12,8 @@ const steps = Symbol("observerContainedTest/steps"); declare module "@adobe/ecs" { interface Resources { [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number]; default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }; - [paused]: { type: boolean; default: false }; - [steps]: { type: number; default: 0 }; + [paused]: { type: boolean }; + [steps]: { type: number }; } } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 9af7336a..3f9bf022 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -28,7 +28,7 @@ declare module "@adobe/ecs" { } export interface Resources { [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; - [stall]: { type: number; default: 0 }; + [stall]: { type: number }; [phase]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; [motto]: { type: string; default: "motto 0" }; [mottoRound]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 2816aa5c..a6caf336 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -24,15 +24,18 @@ declare module "@adobe/ecs" { [amount]: { type: number }; [cell]: { type: number }; [tagged]: { type: true }; - [label]: { type: { readonly text: string }; thread: "observerJoinedTest"; default: typeof blank }; + [label]: { type: Label; thread: "observerJoinedTest" }; [name]: { type: string; default: "" }; } interface Resources { - [level]: { type: number; default: 0 }; + [level]: { type: number }; } } /** The label's start, which a command here gives it by this value. */ const blank = { text: "" }; +/** A label, which starts as `blank`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string }; +const Label = { schema: { default: blank } }; const byCell = Index.create({ key: [cell], components: { has: [amount] } }); const allTagged = Index.create({ key: [], components: { has: [tagged] } }); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index 86162038..de82d53c 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -18,7 +18,7 @@ declare module "@adobe/ecs" { [cell]: { type: number }; } interface Resources { - [level]: { type: number; default: 0 }; + [level]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index e19465b2..930389a0 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -19,9 +19,9 @@ declare module "@adobe/ecs" { [sized]: { type: true }; } export interface Resources { - [count]: { type: number; reduce: "+"; default: 0 }; - [sum]: { type: number; reduce: "+"; default: 0 }; - [largest]: { type: number; reduce: "max"; default: 0 }; + [count]: { type: number; reduce: "+" }; + [sum]: { type: number; reduce: "+" }; + [largest]: { type: number; reduce: "max" }; } } diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index 26cc9e3a..6bdf894d 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -32,13 +32,13 @@ declare module "@adobe/ecs" { [heavy]: { type: true }; } export interface Resources { - [total]: { type: number; reduce: "+"; default: 0 }; - [low]: { type: Vec3; reduce: "min"; default: [0, 0, 0] }; - [bits]: { type: U32; reduce: "|"; default: 0 }; - [least]: { type: U32; reduce: "min"; default: 0 }; - [any]: { type: boolean; reduce: "|"; default: false }; - [level]: { type: number; default: 0 }; - [seen]: { type: number; default: 0 }; + [total]: { type: number; reduce: "+" }; + [low]: { type: Vec3; reduce: "min" }; + [bits]: { type: U32; reduce: "|" }; + [least]: { type: U32; reduce: "min" }; + [any]: { type: boolean; reduce: "|" }; + [level]: { type: number }; + [seen]: { type: number }; [wrapped]: { type: I32; default: 5; reduce: "+" }; } } diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 6c10a974..0374e4e7 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -49,7 +49,7 @@ declare module "@adobe/ecs" { [level]: { type: number; default: -1 }; [origin]: { type: readonly [number, number]; default: [-1, -1] }; [caption]: { type: string; default: "c" }; - [lit]: { type: boolean; default: false }; + [lit]: { type: boolean }; [tint]: { type: Vec3; default: [-1, -1, -1] }; [sample]: { type: Schema.Type<{ type: "object"; properties: { on: { type: "boolean" }; count: { type: "integer"; minimum: 0; maximum: 4294967295 }; weight: { type: "number" }; ratio: { type: "number"; precision: 1 } } }>; default: { on: false; count: 0; weight: -1; ratio: -1 } }; [material]: { type: Schema.Type<{ type: "object"; properties: { alpha: { type: "number"; precision: 1 }; color: { type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }; glossy: { type: "boolean" } }; layout: "storage" }>; default: { alpha: -1; color: [-1, -1, -1]; glossy: false } }; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index 2a7ee921..a620639d 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -18,9 +18,9 @@ const second = Symbol("remoteFinalizersTest/second"); const third = Symbol("remoteFinalizersTest/third"); declare module "@adobe/ecs" { export interface Resources { - [later]: { type: number; default: 0 }; - [second]: { type: number; default: 0 }; - [third]: { type: number; default: 0 }; + [later]: { type: number }; + [second]: { type: number }; + [third]: { type: number }; } } const bump = System.create({ resources: { write: [later] } }, ({ resources }) => { diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index 4fa2b17d..d7f0e567 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -19,11 +19,14 @@ declare module "@adobe/ecs" { [serial]: { type: number }; [phase]: { type: number }; [flag]: { type: true }; - [caption]: { type: { readonly text: string } | null; thread: "remoteObjects"; default: typeof none }; + [caption]: { type: Caption; thread: "remoteObjects" }; } } /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { schema: { default: none } }; /** A caption's text, null for none, as the watchers record it. */ const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 62937c27..069b268d 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -24,7 +24,7 @@ declare module "@adobe/ecs" { } export interface Components { [point]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "remoteTest"; default: typeof none }; + [caption]: { type: Caption; thread: "remoteTest" }; [widget]: { type: Widget | null; thread: "remoteTest" }; [kept]: { type: string }; [guarded]: { type: Widget | null; thread: "remoteTestFinalized" }; @@ -35,6 +35,9 @@ declare module "@adobe/ecs" { type Widget = { readonly draw: () => string }; /** The caption's start, which a command here gives it by this value. */ const none = { text: "none" }; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { schema: { default: none } }; /** A widget, or the number a kernel gives one, which only the world's thread could hold. */ const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index da1575fc..e643cf28 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -15,7 +15,7 @@ declare module "@adobe/ecs" { } export interface Resources { [mark]: { type: { readonly text: string }; thread: "removeTest"; default: { text: "m" } }; - [pause]: { type: number; default: 0 }; + [pause]: { type: number }; [secondRuns]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index a9e3dbc7..094f3f25 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -12,7 +12,7 @@ declare module "@adobe/ecs" { [amount]: { type: number }; } interface Resources { - [level]: { type: number; default: 0 }; + [level]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index cc2e6491..c1703c9e 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -23,7 +23,7 @@ declare module "@adobe/ecs" { } export interface Resources { [mode]: { type: "edit" | "view"; default: "view" }; - [title]: { type: string; default: "" }; + [title]: { type: string }; } } diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 2692c4de..5ffee975 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -36,7 +36,7 @@ declare module "@adobe/ecs" { [kept]: { type: string }; } export interface Resources { - [total]: { type: number; reduce: "+"; default: 0 }; + [total]: { type: number; reduce: "+" }; [changes]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; [paint]: { type: { readonly text: string }; thread: "threadSystemsTest"; default: { text: "plain" } }; [painted]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index 65f67bd0..d7a41f19 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -10,7 +10,7 @@ import { workers } from "../../interface/resources/workers.js"; const frames = Symbol("ticksBrowserTest/frames"); declare module "@adobe/ecs" { export interface Resources { - [frames]: { type: number; default: 0 }; + [frames]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 5e6e2c7b..79dfd595 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -8,7 +8,7 @@ import { createWorld } from "./create.js"; const frames = Symbol("ticksTest/frames"); declare module "@adobe/ecs" { interface Resources { - [frames]: { type: number; default: 0 }; + [frames]: { type: number }; } } diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts index 30fa31d8..5302cf4c 100644 --- a/packages/ecs/src/implementation/world/traits.test.ts +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -14,7 +14,7 @@ const parent = Symbol("traitsTest/parent"); declare module "@adobe/ecs" { export interface Components { [score]: { type: number }; - [slot]: { type: number; derived: true; default: 0 }; + [slot]: { type: number; derived: true }; [ready]: { type: true; within: typeof slot }; [parent]: { type: Entity; reference: "owner" }; } diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 410e7d31..83a74168 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -36,12 +36,11 @@ declare module "@adobe/ecs" { [target]: { type: Entity }; } export interface Resources { - [seconds]: { type: number; default: typeof secondsStart }; + [seconds]: { type: number; default: 0.016666666666666666 }; [floor]: { type: F32; default: -2.5 }; } } -const secondsStart = 1 / 60; const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = Entity.none; diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index 1c0b5c4b..6ce84ca5 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -20,14 +20,14 @@ declare module "@adobe/ecs" { export interface Components { [node]: { type: HTMLElement; thread: "main" }; [label]: { type: string }; - [slot]: { type: number; derived: true; default: 0 }; + [slot]: { type: number; derived: true }; [mark]: { type: true; derived: true }; [other]: { type: true; derived: true }; [tags]: { type: readonly string[] }; } export interface Resources { - [total]: { type: number; reduce: "+"; default: 0 }; - [time]: { type: number; default: 0 }; + [total]: { type: number; reduce: "+" }; + [time]: { type: number }; [page]: { type: Document | null; thread: "main" }; } } diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index afa1147d..bafd6052 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -31,9 +31,9 @@ declare module "@adobe/ecs" { } export interface Resources { [document]: { type: Document | null; thread: "main" }; - [time]: { type: number; default: 0 }; - [total]: { type: number; reduce: "+"; default: 0 }; - [paused]: { type: boolean; default: false }; + [time]: { type: number }; + [total]: { type: number; reduce: "+" }; + [paused]: { type: boolean }; } } diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index 81787604..eddc9707 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -33,7 +33,7 @@ declare module "@adobe/ecs" { export interface Resources { [note]: { type: string; default: "released" }; [scale]: { type: number; default: 1 }; - [offset]: { type: Vec3; default: [0, 0, 0] }; + [offset]: { type: Vec3 }; } } /** An object of the joined thread, as a DOM node or a GPU handle is. */ diff --git a/packages/ecs/src/perftest/log.ts b/packages/ecs/src/perftest/log.ts index e8fe0e28..49c02a2d 100644 --- a/packages/ecs/src/perftest/log.ts +++ b/packages/ecs/src/perftest/log.ts @@ -21,11 +21,14 @@ declare module "@adobe/ecs" { } export interface Components { [serial]: { type: number }; - [label]: { type: { readonly text: string } | null; thread: "logBenchmark"; default: typeof none }; + [label]: { type: Label; thread: "logBenchmark" }; [moved]: { type: true }; } } const none = { text: "none" }; +/** A row's label, which starts as `none`, so the mirror's copies are told apart by identity. */ +type Label = { readonly text: string } | null; +const Label = { schema: { default: none } }; /** Entities moved, and swapped out of the rows they leave, per sync point of `log replay`. */ export const replayMoves = 10_000; diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index fbaeeba0..1001eaa5 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -26,7 +26,7 @@ declare module "@adobe/ecs" { [amount]: { type: number }; } export interface Resources { - [counter]: { type: number; default: 0 }; + [counter]: { type: number }; [sign]: { type: number; default: 1 }; } } diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 01cfd945..47964584 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -37,8 +37,8 @@ export const indexSum = Symbol("suite/indexSum"); export const highest = Symbol("suite/highest"); declare module "@adobe/ecs" { export interface Resources { - [indexSum]: { type: number; reduce: "+"; default: 0 }; - [highest]: { type: number; reduce: "max"; default: 0 }; + [indexSum]: { type: number; reduce: "+" }; + [highest]: { type: number; reduce: "max" }; } } diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index 07757da9..8da40ebb 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -30,14 +30,17 @@ declare module "@adobe/ecs" { [pose]: { type: Mat4x4 }; } export interface Resources { - [surface]: { type: { readonly name: string }; thread: "tailsBenchmark"; default: typeof surfaceStart }; + [surface]: { type: Surface; thread: "tailsBenchmark" }; [poked]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; - [rowWork]: { type: U32; default: 0 }; - [worldWork]: { type: number; default: 0 }; + [rowWork]: { type: U32 }; + [worldWork]: { type: number }; } } /** An object of the joined thread, as a canvas or the DOM is, which places the systems reading it there. */ const surfaceStart = { name: "surface" }; +/** The surface tails draw to, which starts as `surfaceStart`, so they can tell it apart by identity. */ +type Surface = { readonly name: string }; +const Surface = { schema: { default: surfaceStart } }; /** What the tails read, so their reads aren't dead. */ export const drawn = { sum: 0 }; diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index 92d02064..4bd45dbe 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -9,7 +9,7 @@ declare module "@adobe/ecs" { [count]: { type: number }; } export interface Resources { - [total]: { type: number; default: 0 }; + [total]: { type: number }; } } From 97fca93f36e6fddab76799d383e60f6a2559a2ff Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:33:25 -0700 Subject: [PATCH 079/127] ecs: declarations say what bodies can't show; the build infers what they read, write and reduce Components take with in place of read, write and has; resources keep only changed, is and not; lookups take with, or an index and changed; up takes through. The build follows each body's uses and gives its declaration what it read and wrote, as lists the world takes as before. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/exports.test.ts | 17 +- packages/ecs/src/guide/01-resources.test.ts | 13 +- packages/ecs/src/guide/02-entities.test.ts | 6 +- packages/ecs/src/guide/03-systems.test.ts | 9 +- packages/ecs/src/guide/04-ticks.test.ts | 10 +- packages/ecs/src/guide/05-scheduling.test.ts | 10 +- packages/ecs/src/guide/06-sync-points.test.ts | 6 +- .../ecs/src/guide/07-change-filters.test.ts | 16 +- packages/ecs/src/guide/08-input.test.ts | 6 +- packages/ecs/src/guide/09-rules.test.ts | 10 +- packages/ecs/src/guide/10-finalizers.test.ts | 4 +- packages/ecs/src/guide/11-traits.test.ts | 8 +- packages/ecs/src/guide/13-indexes.test.ts | 14 +- packages/ecs/src/guide/guide.md | 184 +++++--- .../implementation/compiler/body-access.ts | 86 +++- .../implementation/compiler/compile-kernel.ts | 3 - .../ecs/src/implementation/compiler/exact.ts | 68 --- .../implementation/compiler/hygiene.test.ts | 4 +- .../src/implementation/compiler/inferred.ts | 184 ++++++++ .../compiler/input-schemas.test.ts | 6 +- .../ecs/src/implementation/compiler/lists.ts | 7 + .../implementation/compiler/module-value.ts | 2 +- .../implementation/compiler/plugin.test.ts | 32 +- .../ecs/src/implementation/compiler/plugin.ts | 5 +- .../src/implementation/compiler/signature.ts | 9 +- .../implementation/compiler/transform.test.ts | 412 ++++++++---------- .../src/implementation/compiler/transform.ts | 160 +++++-- .../world/async-loading.test.ts | 9 +- .../src/implementation/world/builtins.test.ts | 4 +- .../world/change-blocks.browser.test.ts | 6 +- .../world/change-blocks.fixture.ts | 6 +- .../world/change-blocks.test.ts | 30 +- .../world/changes-finalizers.test.ts | 4 +- .../world/command-rings.fixture.ts | 10 +- .../src/implementation/world/commands.test.ts | 20 +- .../world/composite-input.test.ts | 6 +- .../implementation/world/conditions.test.ts | 22 +- .../world/constant-unions.test.ts | 2 +- .../src/implementation/world/create.test.ts | 35 +- .../implementation/world/dispatch.fixture.ts | 10 +- .../world/entity-lookup-workers.fixture.ts | 10 +- .../world/entity-lookups.browser.test.ts | 2 +- .../world/entity-lookups.test-helper.ts | 20 +- .../world/entity-lookups.test.ts | 4 +- .../implementation/world/finalizers.test.ts | 37 +- .../implementation/world/gpu.browser.test.ts | 38 +- .../world/ids-keys.test-helper.ts | 2 +- .../ecs/src/implementation/world/ids.test.ts | 2 +- .../world/imported-lists.test.ts | 4 +- .../world/index-buckets.test.ts | 14 +- .../world/index-build-csp.test.ts | 6 +- .../world/index-build.fixture.ts | 10 +- .../world/index-changed.test.ts | 31 +- .../implementation/world/index-joined.test.ts | 62 +-- .../world/index-kernels.test.ts | 24 +- .../world/index-keyless.test.ts | 30 +- .../world/index-lookups-csp.test.ts | 4 +- .../implementation/world/index-order.test.ts | 16 +- .../world/index-workers.fixture.ts | 40 +- .../src/implementation/world/indexes.test.ts | 42 +- .../ecs/src/implementation/world/indexes.ts | 4 +- .../world/intrinsics.browser.test.ts | 36 +- .../ecs/src/implementation/world/join.test.ts | 6 +- .../world/joined-observer.test.ts | 2 +- .../implementation/world/joined-observer.ts | 4 +- .../world/joining.test-helper.ts | 4 +- .../world/kernel-imports.test.ts | 8 +- .../world/kernel-lookup.test-helper.ts | 5 +- .../world/kernel-path.test-helper.ts | 2 +- .../world/kernel-spawns.test.ts | 12 +- .../world/kernel-tried.test-helper.ts | 6 +- .../implementation/world/kernels-csp.test.ts | 2 +- .../src/implementation/world/kernels.test.ts | 6 +- .../world/layout-directory.browser.test.ts | 14 +- .../world/layout-directory.fixture.ts | 14 +- .../implementation/world/lifetime.fixture.ts | 5 +- .../src/implementation/world/lifetime.test.ts | 8 +- .../src/implementation/world/lookup-keys.ts | 18 +- .../ecs/src/implementation/world/loop.test.ts | 8 +- .../src/implementation/world/names.test.ts | 11 +- .../world/observe-cases.browser.test.ts | 2 +- .../world/observe-cases.test.ts | 28 +- .../world/observe-factories.test.ts | 8 +- .../world/observe-joined-cases.test.ts | 8 +- .../world/observe-ticks-cases.test.ts | 18 +- .../world/observed-workers.fixture.ts | 4 +- .../world/observer-contained.test-helper.ts | 6 +- .../world/observer-contained.test.ts | 10 +- .../world/observer-joined.test-helper.ts | 18 +- .../world/observer-joined.test.ts | 32 +- .../world/observer.browser.test.ts | 4 +- .../src/implementation/world/observer.test.ts | 18 +- .../implementation/world/pool-size.fixture.ts | 4 +- .../src/implementation/world/ports.fixture.ts | 2 +- .../implementation/world/precision.test.ts | 22 +- .../world/reductions.fixture.ts | 2 +- .../implementation/world/reductions.test.ts | 44 +- .../src/implementation/world/reductions.ts | 6 +- .../world/reevaluation.test-helper.ts | 2 +- .../world/remote-finalizers.test-helper.ts | 34 +- .../world/remote-finalizers.test.ts | 12 +- .../world/remote-objects.test-helper.ts | 4 +- .../src/implementation/world/remote.test.ts | 18 +- .../world/remove.test-helper.ts | 12 +- .../src/implementation/world/remove.test.ts | 10 +- .../world/render-order.browser.test.ts | 10 +- .../world/reports.test-helper.ts | 6 +- .../src/implementation/world/reports.test.ts | 14 +- .../ecs/src/implementation/world/rows.test.ts | 6 +- packages/ecs/src/implementation/world/rows.ts | 16 +- .../src/implementation/world/rules.test.ts | 22 +- .../src/implementation/world/schedule.test.ts | 4 +- .../implementation/world/semantics.test.ts | 6 +- .../ecs/src/implementation/world/sharing.ts | 14 +- .../src/implementation/world/strings.test.ts | 26 +- .../implementation/world/tails.test-helper.ts | 24 +- .../src/implementation/world/tails.test.ts | 4 +- .../src/implementation/world/target.test.ts | 10 +- .../world/thread-systems.fixture.ts | 2 +- .../world/thread-systems.test-helper.ts | 50 +-- .../implementation/world/thread-systems.ts | 10 +- .../world/ticks.browser.test.ts | 4 +- .../src/implementation/world/ticks.test.ts | 4 +- .../src/implementation/world/traits.test.ts | 2 +- .../world/transfers.test-helper.ts | 22 +- .../src/implementation/world/up-tree.test.ts | 8 +- .../world/up-workers.fixture.ts | 6 +- .../implementation/world/up.browser.test.ts | 4 +- .../ecs/src/implementation/world/up.test.ts | 28 +- packages/ecs/src/implementation/world/up.ts | 4 +- .../implementation/world/wasm-kernels.test.ts | 27 +- .../implementation/world/workers.fixture.ts | 21 +- .../archetype/archetype.type-test.ts | 6 +- packages/ecs/src/interface/finalizer/body.ts | 18 +- .../src/interface/finalizer/create.test.ts | 68 +-- .../ecs/src/interface/finalizer/create.ts | 61 ++- .../ecs/src/interface/finalizer/created.ts | 10 +- .../src/interface/finalizer/declaration.ts | 31 +- packages/ecs/src/interface/index.type-test.ts | 14 +- .../ecs/src/interface/indexes/create.test.ts | 41 +- packages/ecs/src/interface/indexes/create.ts | 17 +- packages/ecs/src/interface/indexes/created.ts | 4 +- .../ecs/src/interface/indexes/declaration.ts | 7 +- .../src/interface/indexes/index.type-test.ts | 115 ++--- .../src/interface/registry/ids.test-helper.ts | 26 +- .../ecs/src/interface/registry/ids.test.ts | 24 +- packages/ecs/src/interface/registry/ids.ts | 19 + .../src/interface/report/report.type-test.ts | 4 +- packages/ecs/src/interface/rule/create.ts | 7 +- packages/ecs/src/interface/rule/created.ts | 4 +- .../ecs/src/interface/rule/declaration.ts | 7 +- .../ecs/src/interface/rule/rule.type-test.ts | 32 +- packages/ecs/src/interface/system/access.ts | 79 ++-- packages/ecs/src/interface/system/body.ts | 50 ++- .../interface/system/conditions.type-test.ts | 14 +- .../src/interface/system/create-resolved.ts | 21 + .../ecs/src/interface/system/create.test.ts | 58 +-- packages/ecs/src/interface/system/create.ts | 117 +++-- .../ecs/src/interface/system/declaration.ts | 27 +- packages/ecs/src/interface/system/leaf.ts | 4 +- packages/ecs/src/interface/system/render.ts | 33 +- .../ecs/src/interface/system/resolve.test.ts | 17 + packages/ecs/src/interface/system/resolve.ts | 123 ++++++ .../src/interface/system/system.type-test.ts | 229 ++++------ packages/ecs/src/interface/system/threads.ts | 16 +- .../src/interface/system/threads.type-test.ts | 76 ++-- .../ecs/src/interface/system/up.type-test.ts | 31 +- packages/ecs/src/interface/system/values.ts | 24 +- .../ecs/src/interface/world/loop.type-test.ts | 24 +- packages/ecs/src/observe/entity.ts | 29 +- packages/ecs/src/observe/resource.ts | 4 +- packages/ecs/src/observe/select.ts | 20 +- packages/ecs/src/observe/world-state.ts | 30 +- packages/ecs/src/perftest/benchmarks.ts | 2 +- packages/ecs/src/perftest/editor.ts | 12 +- packages/ecs/src/perftest/filters.ts | 14 +- packages/ecs/src/perftest/finalized.ts | 12 +- packages/ecs/src/perftest/growth.ts | 2 +- packages/ecs/src/perftest/handoff.ts | 30 +- packages/ecs/src/perftest/hierarchy.ts | 14 +- packages/ecs/src/perftest/indexes.ts | 56 +-- packages/ecs/src/perftest/loop.ts | 2 +- packages/ecs/src/perftest/observed-gpu.ts | 2 +- packages/ecs/src/perftest/observed.ts | 6 +- packages/ecs/src/perftest/observer.ts | 6 +- packages/ecs/src/perftest/particles.ts | 2 +- packages/ecs/src/perftest/reads.ts | 16 +- packages/ecs/src/perftest/remote.ts | 22 +- packages/ecs/src/perftest/render-order.ts | 2 +- packages/ecs/src/perftest/strings.ts | 10 +- packages/ecs/src/perftest/structural.ts | 26 +- packages/ecs/src/perftest/suite.ts | 40 +- packages/ecs/src/perftest/systems.ts | 4 +- packages/ecs/src/perftest/tails.ts | 16 +- packages/ecs/src/perftest/transfers.ts | 4 +- packages/ecs/src/perftest/uploads.ts | 4 +- packages/ecs/src/perftest/values.ts | 4 +- .../ecs/src/samples/particles/gpu/main.ts | 2 +- .../src/samples/particles/gpu/particles.ts | 7 +- .../src/samples/particles/particles.test.ts | 2 +- .../ecs/src/samples/particles/particles.ts | 14 +- .../src/samples/todo/app/app.browser.test.ts | 4 +- .../ecs/src/samples/todo/app/log-failures.ts | 2 +- .../todo/app/todo.perf-browser-test.ts | 6 +- .../samples/todo/features/completion/data.ts | 2 +- .../todo/features/completion/model.test.ts | 2 +- .../samples/todo/features/completion/model.ts | 2 +- .../src/samples/todo/features/filter/dom.ts | 6 +- .../todo/features/filter/model.test.ts | 2 +- .../src/samples/todo/features/filter/model.ts | 4 +- .../samples/todo/features/generate/model.ts | 2 +- .../src/samples/todo/features/todos/data.ts | 2 +- .../todo/features/todos/dom.browser.test.ts | 2 +- .../src/samples/todo/features/todos/dom.ts | 12 +- .../src/samples/todo/features/todos/model.ts | 10 +- .../todo/features/todos/todos.test-helper.ts | 2 +- packages/ecs/src/samples/transfers/main.ts | 2 +- packages/ecs/src/samples/transfers/sprites.ts | 12 +- packages/ecs/src/world.test.ts | 8 +- 219 files changed, 2557 insertions(+), 2109 deletions(-) delete mode 100644 packages/ecs/src/implementation/compiler/exact.ts create mode 100644 packages/ecs/src/implementation/compiler/inferred.ts create mode 100644 packages/ecs/src/interface/system/create-resolved.ts create mode 100644 packages/ecs/src/interface/system/resolve.test.ts create mode 100644 packages/ecs/src/interface/system/resolve.ts diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index 59208e8a..eb96b004 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -37,20 +37,20 @@ declare module "@adobe/ecs" { interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } } } export const Placed = Archetype.create([position, element]); -export const showing = Rule.create({ components: { has: [element] }, derive: [shown] }); -export const move = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); +export const showing = Rule.create({ components: { with: [element] }, derive: [shown] }); +export const move = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); export const all = System.create({ systems: [move] }); export const draw = System.render({ shader: { accessors: {} } }, () => {}); -export const placed = Index.create({ key: [position], components: { has: [element] } }); +export const placed = Index.create({ key: [position], components: { with: [element] } }); export const ranked = Index.create({ key: [position], sorted: true }); -export const after = System.create({ components: { read: [position] }, lookup: { next: { index: ranked, read: [position] } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); +export const after = System.create({ components: { with: [position] }, lookup: { next: { index: ranked } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); export const sortedName = (index: Index<{ readonly sorted: true }>): string => index.name; // A world comes from the one import every other value does. export const world: World = World.create({ [workers]: 0 }); // Another thread joins it through the other end of a channel the world shares. declare const port: MessagePort; export const joined: World = World.join(port, { threads: ["main"] }); -export const read = System.create({ observer: true, components: { read: [position] }, resources: { read: [gpuDevice] }, lookup: { at: { read: [position] } } }, () => {}); +export const read = System.create({ observer: true, components: { with: [position] }, lookup: { at: { with: [position] } } }, () => {}); export const added = world.add(read); `; @@ -99,7 +99,8 @@ export const health = Symbol("exportsTest/health"); declare module "@adobe/ecs" { interface Components { [health]: { type: number } } } -export const both = System.create({ components: { read: [health], write: [health] } }, () => {}); +// What a body reads is inferred, never declared. +export const both = System.create({ components: { read: [health] } }, () => {}); const near = System.create({}, (_context, input: { readonly distance: number }) => { void input; }); const far = System.create({}, (_context, input: { readonly distance: string }) => { void input; }); export const listed = System.create([near, far]); @@ -133,7 +134,7 @@ describe("package exports", () => { expect(emitted).toContain(`export declare const placed: Index<{ readonly key: readonly [typeof position]; readonly components: { - readonly has: readonly [typeof element]; + readonly with: readonly [typeof element]; }; }>;`); expect(emitted).toContain(`export declare const ranked: Index<{ @@ -155,7 +156,7 @@ describe("package exports", () => { const reported = compile(misdeclared).messages; expect(reported.some(message => message.includes("No overload matches"))).toBe(false); expect(reported.filter(message => message.includes(`has a different type in another member of this composite.`))).toHaveLength(4); - expect(reported.some(message => message.startsWith("Type '[unique symbol]' is not assignable"))).toBe(true); + expect(reported.some(message => message.startsWith("Type 'symbol[]' is not assignable to type 'undefined'"))).toBe(true); }, 60_000); }); diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index 6eee5484..f753f2ce 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; import { Entity, F32, System, World } from "../index.js"; +import { Resource } from "@adobe/ecs"; // A resource is a key whose value a world holds once. export const gravity = Symbol(); @@ -29,8 +30,14 @@ declare module "@adobe/ecs" { } // Systems read a world: an observer only reads, after the world's changes apply. -let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, floor, names, audio, focused] } }, ({ resources }) => { +let values: readonly [ + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value +] | null = null; +const read = System.create({ observer: true }, ({ resources }) => { values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; }); @@ -42,5 +49,5 @@ it("holds one value per world", async () => { await earth.run(read); expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]); await moon.run(read); - expect(values[0]).toBe(-1.6); + expect(values?.[0]).toBe(-1.6); }); diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts index 524dd0ad..ba07c29e 100644 --- a/packages/ecs/src/guide/02-entities.test.ts +++ b/packages/ecs/src/guide/02-entities.test.ts @@ -26,17 +26,17 @@ export const spawn = System.create(({ commands }) => { }); // Commands also add and remove components, and despawn entities. -export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { +export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => { commands.remove(row.entity, enemy); }); // An observer system reads them back, once per entity with its components. const foes: Vec3[] = []; -const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { +const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => { foes.push(row[position]); }); let particles = 0; -const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { +const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => { particles += 1; }); diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index bb9c965b..a0a2bf81 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -27,15 +27,14 @@ const spawn = System.create(({ commands }) => { // Runs once per entity with every component in read and write, and none in not. const move = System.create({ - components: { read: [velocity], write: [position], not: [frozen] }, - resources: { read: [delta] }, + components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { // Values are replaced whole, never edited in place. row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); }); // Without components, a system runs once per run. -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { +const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); @@ -43,7 +42,7 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources }) // react to it. const moved: Vec3[] = []; let counted = 0; -const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { +const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => { moved.push(row[position]); counted = resources[frames]; }); @@ -67,7 +66,7 @@ const fail = System.create(() => { // it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as // they change logs them; what observer systems throw is in the next run's report. const logged: string[] = []; -const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); }); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index 8aee14a8..a783455c 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -13,18 +13,18 @@ declare module "@adobe/ecs" { } // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { +const advance = System.create({ }, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); // Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. let reached: (value: number) => void = () => {}; -const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { +const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => { if (resources[elapsed] >= 30) reached(resources[elapsed]); }); let read = { elapsed: 0, delta: 0 }; -const readTime = System.create({ observer: true, resources: { read: [elapsed, clock] } }, ({ resources }) => { +const readTime = System.create({ observer: true }, ({ resources }) => { read = { elapsed: resources[elapsed], delta: resources[clock].delta }; }); @@ -50,13 +50,13 @@ it("runs added systems on every tick", async () => { // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps], reduce: [wake] } }, ({ resources, reduce }) => { +const step = System.create({ }, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); let finished: (value: number) => void = () => {}; -const watchSteps = System.create({ observer: true, resources: { read: [steps], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => { if (resources[steps] === 3) finished(resources[steps]); }); diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts index 9611a71f..c562880d 100644 --- a/packages/ecs/src/guide/05-scheduling.test.ts +++ b/packages/ecs/src/guide/05-scheduling.test.ts @@ -13,11 +13,11 @@ declare module "@adobe/ecs" { } const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); }); -const hit = System.create({ components: { read: [damage], write: [health] } }, row => { +const hit = System.create({ components: { with: [damage, health] } }, row => { row[health] -= row[damage]; }); // before, after and during list the systems themselves. -const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { +const heal = System.create({ components: { with: [health] }, after: [hit] }, row => { row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order @@ -25,7 +25,7 @@ const heal = System.create({ components: { write: [health] }, after: [hit] }, ro const frame = System.create([heal, hit]); const healths: number[] = []; -const read = System.create({ observer: true, components: { read: [health] } }, row => { +const read = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); @@ -41,11 +41,11 @@ it("orders a composite's members by their constraints", async () => { // Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. const update = System.create(() => {}); const render = System.create({ after: [update] }, () => {}); -const poison = System.create({ components: { write: [health] }, during: [update] }, row => { +const poison = System.create({ components: { with: [health] }, during: [update] }, row => { row[health] -= 1; }); const seen: number[] = []; -const report = System.create({ components: { read: [health] }, during: [render] }, row => { +const report = System.create({ components: { with: [health] }, during: [render] }, row => { seen.push(row[health]); }); const phased = System.create([report, poison, update, render]); diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts index 812c6f68..11df7cfd 100644 --- a/packages/ecs/src/guide/06-sync-points.test.ts +++ b/packages/ecs/src/guide/06-sync-points.test.ts @@ -23,18 +23,18 @@ const spawn = System.create(({ commands }) => { commands.despawn(commands.spawn([health])({ [health]: 1 })); }); // A composite is one run with one sync point: count runs after spawn, before its entities exist. -const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { +const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => { resources[seen] += 1; }); const frame = System.create([spawn, count]); const healths: number[] = []; // An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. -const watch = System.create({ observer: true, components: { read: [health] } }, row => { +const watch = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); let counted = 0; -const readSeen = System.create({ observer: true, resources: { read: [seen] } }, ({ resources }) => { +const readSeen = System.create({ observer: true }, ({ resources }) => { counted = resources[seen]; }); diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts index 468cce0b..e5f951aa 100644 --- a/packages/ecs/src/guide/07-change-filters.test.ts +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -25,20 +25,20 @@ declare module "@adobe/ecs" { } // A resource in changed passes the first run and each run after it changes: no run-once flag needed. -const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { +const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => { commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); }); // A component in changed passes only rows written since this system last ran. -const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { +const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => { row[shown] = row[score]; }); -const bump = System.create({ components: { write: [score], has: [boss] } }, row => { +const bump = System.create({ components: { with: [score, boss] } }, row => { row[score] += 10; }); const shownValues: number[] = []; -const readShown = System.create({ observer: true, components: { read: [shown] } }, row => { +const readShown = System.create({ observer: true, components: { with: [shown] } }, row => { shownValues.push(row[shown]); }); @@ -57,14 +57,14 @@ it("runs systems only for what changed since they last ran", async () => { }); // Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. -const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { +const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => { resources[steps] += resources[map].width; }); -const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); -const open = System.create({ resources: { write: [map] } }, ({ resources }) => { resources[map] = { width: 4 }; }); +const pause = System.create({ }, ({ resources }) => { resources[paused] = true; }); +const open = System.create({ }, ({ resources }) => { resources[map] = { width: 4 }; }); let stepped = 0; -const readSteps = System.create({ observer: true, resources: { read: [steps] } }, ({ resources }) => { +const readSteps = System.create({ observer: true }, ({ resources }) => { stepped = resources[steps]; }); diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts index a6c23849..511b4624 100644 --- a/packages/ecs/src/guide/08-input.test.ts +++ b/packages/ecs/src/guide/08-input.test.ts @@ -15,21 +15,21 @@ const spawn = System.create(({ commands }) => { player = commands.spawn([health] // A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ - components: { write: [health] }, + components: { with: [health] }, }, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ - components: { write: [health] }, + components: { with: [health] }, target: "entity", }, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); const healths: number[] = []; -const read = System.create({ observer: true, components: { read: [health] } }, row => { +const read = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); diff --git a/packages/ecs/src/guide/09-rules.test.ts b/packages/ecs/src/guide/09-rules.test.ts index 5b2a8d5d..8ac1914c 100644 --- a/packages/ecs/src/guide/09-rules.test.ts +++ b/packages/ecs/src/guide/09-rules.test.ts @@ -20,23 +20,23 @@ declare module "@adobe/ecs" { } // Entities matching a rule have what it derives, only while they match. -Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. -Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] }); // Rules build on what others derive. -Rule.create({ components: { has: [drawn] }, derive: [layer] }); +Rule.create({ components: { with: [drawn] }, derive: [layer] }); const spawn = System.create(({ commands }) => { commands.spawn([mesh])({ [mesh]: 1 }); commands.spawn([sprite])({ [sprite]: 2 }); commands.spawn([mesh, hidden])({ [mesh]: 3 }); }); -const hide = System.create({ components: { has: [sprite] } }, (row, { commands }) => { +const hide = System.create({ components: { with: [sprite] } }, (row, { commands }) => { commands.add(row.entity, hidden); }); const layers: number[] = []; -const read = System.create({ observer: true, components: { read: [layer], has: [drawn] } }, row => { +const read = System.create({ observer: true, components: { with: [layer, drawn] } }, row => { layers.push(row[layer]); }); diff --git a/packages/ecs/src/guide/10-finalizers.test.ts b/packages/ecs/src/guide/10-finalizers.test.ts index 1e6c9deb..a4ff4af0 100644 --- a/packages/ecs/src/guide/10-finalizers.test.ts +++ b/packages/ecs/src/guide/10-finalizers.test.ts @@ -14,7 +14,7 @@ declare module "@adobe/ecs" { const open = new Set(); // Runs for each entity that stops matching, whatever the cause, reading its values from before. -Finalizer.create({ components: { read: [handle] } }, row => { +Finalizer.create({ components: { with: [handle] } }, row => { open.delete(row[handle]); }); @@ -24,7 +24,7 @@ const spawn = System.create(({ commands }) => { commands.spawn([handle])({ [handle]: id }); } }); -const destroy = System.create({ components: { read: [handle] } }, (row, { commands }) => { +const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => { if (row[handle] === 7) commands.despawn(row.entity); else commands.remove(row.entity, handle); }); diff --git a/packages/ecs/src/guide/11-traits.test.ts b/packages/ecs/src/guide/11-traits.test.ts index fffbed32..0bb7f919 100644 --- a/packages/ecs/src/guide/11-traits.test.ts +++ b/packages/ecs/src/guide/11-traits.test.ts @@ -27,16 +27,16 @@ const spawn = System.create(({ commands }) => { commands.spawn([parent])({ [parent]: mothership }); commands.spawn([target])({ [target]: mothership }); }); -const deselect = System.create({ components: { has: [selected] } }, (row, { commands }) => { +const deselect = System.create({ components: { with: [selected] } }, (row, { commands }) => { commands.remove(row.entity, selected); }); -const destroy = System.create({ components: { has: [ship] } }, (row, { commands }) => { +const destroy = System.create({ components: { with: [ship] } }, (row, { commands }) => { commands.despawn(row.entity); }); const counts = { outlined: 0, targeting: 0, all: 0 }; -const countOutlined = System.create({ observer: true, components: { has: [outline] } }, () => { counts.outlined += 1; }); -const countTargeting = System.create({ observer: true, components: { has: [target] } }, () => { counts.targeting += 1; }); +const countOutlined = System.create({ observer: true, components: { with: [outline] } }, () => { counts.outlined += 1; }); +const countTargeting = System.create({ observer: true, components: { with: [target] } }, () => { counts.targeting += 1; }); const countAll = System.create({ observer: true, components: {} }, () => { counts.all += 1; }); it("removes components with their host, and follows references", async () => { diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts index de033237..fafc73d0 100644 --- a/packages/ecs/src/guide/13-indexes.test.ts +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -19,26 +19,26 @@ declare module "@adobe/ecs" { } // An index finds entities by their components' values: here, those with a crowded count, by their cell. -const grid = Index.create({ key: [cell], components: { has: [crowded] } }); +const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. -const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { +const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; row[crowded] = lookup.at([x, y]).length; }); // A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. -const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); +const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } }); const winners: string[] = []; -const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { winners.length = 0; const high = lookup.scores({ ">=": 90 }); for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); }); const board: string[] = []; -const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { +const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => { board.push(row[player]); }); @@ -51,14 +51,14 @@ const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ looku // An index without a key holds every entity with its components, in entity order. With changed: true, this observer // system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. -const players = Index.create({ key: [], components: { has: [player] } }); +const players = Index.create({ key: [], components: { with: [player] } }); let playerCount = 0; const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { playerCount = lookup.players().length; }); const crowds: number[] = []; -const readCrowds = System.create({ observer: true, components: { read: [crowded] } }, row => { +const readCrowds = System.create({ observer: true, components: { with: [crowded] } }, row => { crowds.push(row[crowded]); }); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 92b60e3b..0985368a 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -123,25 +123,27 @@ export const spawn = System.create(({ commands }) => { }); // Commands also add and remove components, and despawn entities. -export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { +export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => { commands.remove(row.entity, enemy); }); // An observer system reads them back, once per entity with its components. const foes: Vec3[] = []; -const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { +const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => { foes.push(row[position]); }); let particles = 0; -const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { +const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => { particles += 1; }); -await world.run(spawn); -await world.run(readFoes); -await world.run(countParticles); -expect(foes).toEqual([[0, 10, 0]]); -expect(particles).toBe(1); +it("spawns entities with components", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(readFoes); + await world.run(countParticles); + expect(foes).toEqual([[0, 10, 0]]); + expect(particles).toBe(1); ``` [`02-entities.test.ts`](02-entities.test.ts#L6-L49) @@ -151,15 +153,14 @@ expect(particles).toBe(1); ```ts // Runs once per entity with every component in read and write, and none in not. const move = System.create({ - components: { read: [velocity], write: [position], not: [frozen] }, - resources: { read: [delta] }, + components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { // Values are replaced whole, never edited in place. row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); }); // Without components, a system runs once per run. -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { +const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); @@ -167,18 +168,22 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources }) // react to it. const moved: Vec3[] = []; let counted = 0; -const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { +const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => { moved.push(row[position]); counted = resources[frames]; }); -await world.run(move); -await world.run(count); -await world.run(read); -expect(moved).toEqual([[1, 0, 0]]); +it("runs a system once per entity, or once", async () => { + await using world = World.create(); + // run: runs a system once. + await world.run(spawn); + await world.run(move); + await world.run(count); + await world.run(read); + expect(moved).toEqual([[1, 0, 0]]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L28-L58) +[`03-systems.test.ts`](03-systems.test.ts#L28-L57) ```ts const fail = System.create(() => { @@ -189,42 +194,52 @@ const fail = System.create(() => { // it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as // they change logs them; what observer systems throw is in the next run's report. const logged: string[] = []; -const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); }); -world.add(logFailures); -const run: number = await world.run(fail); -expect(run).toBe(1); -expect(logged).toEqual(["fail: broken"]); +it("reports what failed as entities", async () => { + await using world = World.create(); + world.add(logFailures); + const run: number = await world.run(fail); + expect(run).toBe(1); + expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L62-L79) +[`03-systems.test.ts`](03-systems.test.ts#L61-L78) ### 4. Ticks and cadence ```ts // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { +const advance = System.create({ }, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); // Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. let reached: (value: number) => void = () => {}; -const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { +const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => { if (resources[elapsed] >= 30) reached(resources[elapsed]); }); -// cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. -// A tick runs only after a run arrives, unless always is set. -await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); -const ticked = new Promise(resolve => { reached = resolve; }); -// add: runs a system on every tick. -world.add(advance); -world.add(watch); -expect(await ticked).toBeGreaterThanOrEqual(30); -// remove: takes a system added off every tick again. -world.remove(advance); +let read = { elapsed: 0, delta: 0 }; +const readTime = System.create({ observer: true }, ({ resources }) => { + read = { elapsed: resources[elapsed], delta: resources[clock].delta }; +}); + +it("runs added systems on every tick", async () => { + // cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. + // A tick runs only after a run arrives, unless always is set. + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); + const ticked = new Promise(resolve => { reached = resolve; }); + // add: runs a system on every tick. + world.add(advance); + world.add(watch); + expect(await ticked).toBeGreaterThanOrEqual(30); + await world.run(readTime); + expect(read.delta).toBe(10); + // remove: takes a system added off every tick again. + world.remove(advance); ``` [`04-ticks.test.ts`](04-ticks.test.ts#L15-L43) @@ -232,15 +247,23 @@ world.remove(advance); ```ts // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps], reduce: [wake] } }, ({ resources, reduce }) => { +const step = System.create({ }, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); -await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); -world.add(step); -// One run arriving ticks once; step's wakes tick twice more. -await world.run(System.create(() => {})); +let finished: (value: number) => void = () => {}; +const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => { + if (resources[steps] === 3) finished(resources[steps]); +}); + +it("ticks again when a system asks", async () => { + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); + const done = new Promise(resolve => { finished = resolve; }); + world.add(step); + world.add(watchSteps); + // One run arriving ticks once; step's wakes tick twice more. + await world.run(System.create(() => {})); ``` [`04-ticks.test.ts`](04-ticks.test.ts#L51-L69) @@ -268,10 +291,11 @@ const frame = System.create([heal, hit]); // Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. const update = System.create(() => {}); const render = System.create({ after: [update] }, () => {}); -const poison = System.create({ components: { write: [health] }, during: [update] }, row => { +const poison = System.create({ components: { with: [health] }, during: [update] }, row => { row[health] -= 1; }); -const report = System.create({ components: { read: [health] }, during: [render] }, row => { +const seen: number[] = []; +const report = System.create({ components: { with: [health] }, during: [render] }, row => { seen.push(row[health]); }); const phased = System.create([report, poison, update, render]); @@ -291,7 +315,7 @@ const spawn = System.create(({ commands }) => { commands.despawn(commands.spawn([health])({ [health]: 1 })); }); // A composite is one run with one sync point: count runs after spawn, before its entities exist. -const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { +const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => { resources[seen] += 1; }); const frame = System.create([spawn, count]); @@ -301,7 +325,7 @@ const frame = System.create([spawn, count]); ```ts // An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. -const watch = System.create({ observer: true, components: { read: [health] } }, row => { +const watch = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); ``` @@ -312,12 +336,12 @@ const watch = System.create({ observer: true, components: { read: [health] } }, ```ts // A resource in changed passes the first run and each run after it changes: no run-once flag needed. -const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { +const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => { commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); }); // A component in changed passes only rows written since this system last ran. -const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { +const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => { row[shown] = row[score]; }); ``` @@ -326,7 +350,7 @@ const show = System.create({ components: { read: [score], write: [shown], change ```ts // Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. -const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { +const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => { resources[steps] += resources[map].width; }); ``` @@ -338,22 +362,30 @@ const step = System.create({ resources: { is: [map], not: [paused], write: [step ```ts // A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ - components: { write: [health] }, + components: { with: [health] }, }, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ - components: { write: [health] }, + components: { with: [health] }, target: "entity", }, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); -// Systems with input are run, not added; a composite's input has every field its members declare. -await world.run(hit, { amount: 3 }); -await world.run(heal, { entity: player, amount: 1 }); +const healths: number[] = []; +const read = System.create({ observer: true, components: { with: [health] } }, row => { + healths.push(row[health]); +}); + +it("runs a system with a value", async () => { + await using world = World.create(); + await world.run(spawn); + // Systems with input are run, not added; a composite's input has every field its members declare. + await world.run(hit, { amount: 3 }); + await world.run(heal, { entity: player, amount: 1 }); ``` [`08-input.test.ts`](08-input.test.ts#L16-L41) @@ -383,12 +415,33 @@ Rule.create({ components: { has: [drawn] }, derive: [layer] }); ```ts // Runs for each entity that stops matching, whatever the cause, reading its values from before. -Finalizer.create({ components: { read: [handle] } }, row => { +Finalizer.create({ components: { with: [handle] } }, row => { open.delete(row[handle]); }); -// dispose runs every finalizer for the entities it still matches. -await world.dispose(); +const spawn = System.create(({ commands }) => { + for (const id of [7, 8]) { + open.add(id); + commands.spawn([handle])({ [handle]: id }); + } +}); +const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => { + if (row[handle] === 7) commands.despawn(row.entity); + else commands.remove(row.entity, handle); +}); + +it("releases what entities held when they stop matching", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(destroy); + expect([...open]).toEqual([]); +}); + +it("releases what every entity holds when the world is disposed", async () => { + const world = World.create(); + await world.run(spawn); + // dispose runs every finalizer for the entities it still matches. + await world.dispose(); ``` [`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L43) @@ -440,6 +493,15 @@ declare module "@adobe/ecs" { } } +const transform = Symbol(); +const name = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [transform]: { type: readonly [number, number, number]; [instanced]: true }; + [name]: { type: string }; + } +} + // Types select components by trait. check, typeof transform | typeof name>, typeof transform>>(); ``` @@ -456,10 +518,10 @@ Reading another entity's components by id, from a system. To be written. ```ts // An index finds entities by their components' values: here, those with a crowded count, by their cell. -const grid = Index.create({ key: [cell], components: { has: [crowded] } }); +const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. -const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { +const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; row[crowded] = lookup.at([x, y]).length; }); @@ -469,17 +531,17 @@ const count = System.create({ components: { read: [cell], write: [crowded] }, lo ```ts // A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. -const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); +const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } }); const winners: string[] = []; -const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { winners.length = 0; const high = lookup.scores({ ">=": 90 }); for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); }); const board: string[] = []; -const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { +const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => { board.push(row[player]); }); ``` @@ -500,7 +562,7 @@ const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ looku ```ts // An index without a key holds every entity with its components, in entity order. With changed: true, this observer // system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. -const players = Index.create({ key: [], components: { has: [player] } }); +const players = Index.create({ key: [], components: { with: [player] } }); let playerCount = 0; const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { playerCount = lookup.players().length; diff --git a/packages/ecs/src/implementation/compiler/body-access.ts b/packages/ecs/src/implementation/compiler/body-access.ts index 70b5e2a4..e52cf4ea 100644 --- a/packages/ecs/src/implementation/compiler/body-access.ts +++ b/packages/ecs/src/implementation/compiler/body-access.ts @@ -1,10 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Node, ObjectExpression, Pattern } from 'estree'; import { children, declaredIn, patternNames } from './estree.js'; -import type { Used } from './exact.js'; import type { ModuleScope } from './module-scope.js'; -const unknown: Used = { query: null, resources: null, lookups: null }; +type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; + +/** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ +export type Used = { + readonly query: Access | null; + readonly resources: (Access & { readonly reduce: ReadonlySet }) | null; + /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */ + readonly lookups: ReadonlyMap | null> | null; + /** The components read from `up`. */ + readonly up: ReadonlySet | null; +}; + +const unknown: Used = { query: null, resources: null, lookups: null, up: null }; /** Whether `node`, a child of `parent`, names a property or label, or declares a binding, rather than referring to one. */ const isName = (node: Node, parent: Node | undefined): boolean => { @@ -74,13 +85,13 @@ const indexLookupNames = (declaration: ObjectExpression): ReadonlySet => ? [property.key.name] : [])); }; -type Group = "resources" | "lookup" | "reduce"; +type Group = "resources" | "lookup" | "reduce" | "up"; -const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce"; +const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce" || name === "up"; /** - * What a main-thread body reads, writes and contributes to through its row and context, for the parts where every use - * shows in its source. `row` and `context` are the indexes of those parameters, or -1 for none. The row and the + * What a body reads, writes and contributes to through its row and context, for the parts where every use shows in + * its source. `row` and `context` are the indexes of those parameters, or -1 for none. The row and the * context's `resources`, `lookup` and `reduce` are indexed only by module-scope keys the body doesn't shadow, or * destructured by them, and never passed on, aliased or spread. A lookup's results are followed where each is indexed * by such keys or tested, directly or through a `const` declared once; a lookup through an index's lists where they're @@ -93,11 +104,21 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module declaredIn(body.body, name => counts.set(name, (counts.get(name) ?? 0) + 1)); const parameters = new Set(body.params.flatMap(parameter => patternNames(parameter))); const isKey = (name: string): boolean => module.has(name) && !counts.has(name) && !parameters.has(name); + /** The key `node` names: a module-scope binding, or a namespace's member (`Report.run`), as its source; null for any other. */ + const keyOf = (node: Node): string | null => { + if (node.type === "Identifier") return isKey(node.name) ? node.name : null; + if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.property.type === "Identifier") { + return isKey(node.object.name) ? `${node.object.name}.${node.property.name}` : null; + } + return null; + }; const lookups = lookupNames(declaration); const indexed = indexLookupNames(declaration); const query = { read: new Set(), write: new Set() }; const resources = { read: new Set(), write: new Set(), reduce: new Set() }; + const up = new Set(); + let upKnown = true; const lookupReads = new Map | null>((lookups ?? []).map(name => [name, new Set()])); let queryKnown = true; let resourcesKnown = true; @@ -105,6 +126,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const unknownLookup = (lookup: string): void => { lookupReads.set(lookup, null); }; const unknownGroup = (group: Group): void => { if (group === "lookup") lookupsKnown = false; + else if (group === "up") upKnown = false; else resourcesKnown = false; }; @@ -121,12 +143,14 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const destructure = (pattern: Pattern, named: (property: string, value: Pattern) => boolean, keyed: (key: string) => void): boolean => { if (pattern.type !== "ObjectPattern") return false; for (const property of pattern.properties) { - if (property.type === "RestElement" || property.key.type !== "Identifier") return false; + if (property.type === "RestElement") return false; if (!property.computed) { - if (!named(property.key.name, property.value)) return false; + if (property.key.type !== "Identifier" || !named(property.key.name, property.value)) return false; + continue; } - else if (isKey(property.key.name)) keyed(property.key.name); - else return false; + const key = keyOf(property.key); + if (key === null) return false; + keyed(key); } return true; }; @@ -145,6 +169,9 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module else if (group === "resources") { if (!destructure(pattern, () => false, key => resources.read.add(key))) resourcesKnown = false; } + else if (group === "up") { + if (!destructure(pattern, () => false, key => up.add(key))) upKnown = false; + } else resourcesKnown = false; }; /** Binds the context's groups from a destructuring of it; `declaredOnce` says whether a bound name is safe to follow. */ @@ -158,6 +185,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const unknownContext = (): void => { resourcesKnown = false; lookupsKnown = false; + upKnown = false; }; const row = positions.row < 0 ? undefined : body.params[positions.row]; @@ -171,8 +199,11 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const rowOf = (lookup: string, value: Node, ancestors: readonly Node[], at: number): void => { const parent = ancestors[at]; if (parent?.type === "MemberExpression" && parent.object === value) { - if (parent.computed && parent.property.type === "Identifier" && isKey(parent.property.name)) lookupReads.get(lookup)?.add(parent.property.name); - else if (!(indexed.has(lookup) && !parent.computed && parent.property.type === "Identifier" && parent.property.name === "entity")) unknownLookup(lookup); + const key = parent.computed ? keyOf(parent.property) : null; + if (key !== null) lookupReads.get(lookup)?.add(key); + // A row's `changed` says whether what its lookup watches changed; an index's rows name their entity. + else if (!(!parent.computed && parent.property.type === "Identifier" + && (parent.property.name === "changed" || (indexed.has(lookup) && parent.property.name === "entity")))) unknownLookup(lookup); } else if (parent?.type === "VariableDeclarator" && parent.init === value && parent.id.type === "Identifier" && (results.get(parent.id.name) === lookup || rows.get(parent.id.name) === lookup)) return; @@ -255,6 +286,8 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module if (followed.has(node)) return; const parent = ancestors[at]; const grandparent = ancestors[at - 1]; + // `up` is null for a row without a parent, so it's tested. + if (group === "up" && isTested(node, parent)) return; if (parent?.type !== "MemberExpression" || parent.object !== node) return unknownGroup(group); const { property } = parent; if (group === "lookup") { @@ -262,16 +295,23 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module else lookupsKnown = false; return; } - if (!parent.computed || property.type !== "Identifier" || !isKey(property.name)) return unknownGroup(group); + const key = parent.computed ? keyOf(property) : null; + if (key === null) return unknownGroup(group); if (group === "reduce") { - if (grandparent?.type === "CallExpression" && grandparent.callee === parent) resources.reduce.add(property.name); + if (grandparent?.type === "CallExpression" && grandparent.callee === parent) resources.reduce.add(key); else resourcesKnown = false; return; } + if (group === "up") { + const use = useOf(parent, grandparent); + if (use === null || use.write) upKnown = false; + else up.add(key); + return; + } const use = useOf(parent, grandparent); if (use === null) return unknownGroup(group); - if (use.read) resources.read.add(property.name); - if (use.write) resources.write.add(property.name); + if (use.read) resources.read.add(key); + if (use.write) resources.write.add(key); }; /** Classifies a reference; `ancestors` ends with its parent. */ @@ -286,11 +326,16 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module if (property.type !== "Identifier" || !rowNamed(property.name)) queryKnown = false; return; } - const use = property.type === "Identifier" && isKey(property.name) ? useOf(parent, grandparent) : null; - if (use === null || property.type !== "Identifier") queryKnown = false; + const key = keyOf(property); + // A part of a value assigned, `row[key][0] = …`, writes it, which the compiler rejects as it must. + const great = ancestors.at(-3); + const partWritten = grandparent?.type === "MemberExpression" && grandparent.object === parent + && ((great?.type === "AssignmentExpression" && great.left === grandparent) || great?.type === "UpdateExpression"); + const use = key === null ? null : partWritten ? { read: true, write: true } : useOf(parent, grandparent); + if (use === null || key === null) queryKnown = false; else { - if (use.read) query.read.add(property.name); - if (use.write) query.write.add(property.name); + if (use.read) query.read.add(key); + if (use.write) query.write.add(key); } } else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) queryKnown = false; @@ -329,5 +374,6 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module query: queryKnown ? query : null, resources: resourcesKnown ? resources : null, lookups: lookupsKnown ? lookupReads : null, + up: upKnown ? up : null, }; }; diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 0809fe32..32785300 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Node, ObjectExpression } from 'estree'; import { bind } from './bind.js'; -import { requireExact } from './exact.js'; import { expression } from './expression.js'; import { fail } from './failure.js'; import type { Link } from './link.js'; @@ -75,8 +74,6 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: if (program === null) fail(body, "a GPU kernel can't issue commands or use values that aren't numbers or booleans"); else if (loops(program.body)) fail(body, "a GPU kernel can't use loops or labeled blocks yet"); } - const contributed = new Set(unit.accumulators.flatMap(entry => reduced[entry.reduced]?.resource ?? [])); - requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, lists); const strided = columns.flatMap((_, index) => unit.chunks.has(index) || unit.blocks.has(index) ? [] : [index]); const locals = [ ...columns.map((_, index) => `const c${index} = columns[${index}];`), diff --git a/packages/ecs/src/implementation/compiler/exact.ts b/packages/ecs/src/implementation/compiler/exact.ts deleted file mode 100644 index 57fbe856..00000000 --- a/packages/ecs/src/implementation/compiler/exact.ts +++ /dev/null @@ -1,68 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Expression, ObjectExpression, Pattern } from 'estree'; -import { fail } from './failure.js'; -import type { Lists } from './lists.js'; - -type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; - -/** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ -export type Used = { - readonly query: Access | null; - readonly resources: (Access & { readonly reduce: ReadonlySet }) | null; - /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */ - readonly lookups: ReadonlyMap | null> | null; - /** The components read from `up`, when the body's uses are all known. */ - readonly up?: ReadonlySet; -}; - -/** An object literal's plain-named properties; none for anything else. */ -const fields = (node: Expression | Pattern | undefined): ReadonlyMap => new Map(node?.type !== "ObjectExpression" ? [] : - node.properties.flatMap((property): (readonly [string, Expression | Pattern])[] => - property.type === "Property" && !property.computed && property.key.type === "Identifier" ? [[property.key.name, property.value]] : [])); - -/** - * Requires a list to name only keys in `used`. A list `lists` can't read isn't checked, since its keys can't be matched - * to the body's. - */ -const requireUsed = (node: Expression | Pattern | undefined, used: ReadonlySet, lists: Lists, message: (name: string) => string): void => { - const names = node === undefined ? null : lists.names(node); - if (node === undefined || names === null || "error" in names) return; - // A key of an array literal is reported where it's listed, and one of a list declared elsewhere where the system names the list. - names.forEach((name, k) => { - const element = node.type === "ArrayExpression" ? node.elements[k] : null; - if (!used.has(name)) fail(element ?? node, message(name)); - }); -}; - -/** - * Requires a system's declared `read`, `write` and `reduce` lists to be exactly what its body uses: a key it never uses - * would still narrow which entities match, and hold back systems that could otherwise run alongside it. - */ -export const requireExact = (declaration: ObjectExpression, used: Used, lists: Lists): void => { - const declared = fields(declaration); - const components = fields(declared.get("components")); - const resources = fields(declared.get("resources")); - if (used.query !== null) { - requireUsed(components.get("read"), used.query.read, lists, name => `${name} is in components read but never read; remove it, or move it to components has to keep requiring it`); - requireUsed(components.get("write"), used.query.write, lists, name => `${name} is in components write but never written; remove it, or move it to components read if it's only read`); - } - if (used.resources !== null) { - requireUsed(resources.get("read"), used.resources.read, lists, name => `${name} is in resources read but never read; remove it`); - requireUsed(resources.get("write"), used.resources.write, lists, name => `${name} is in resources write but never written; remove it, or move it to resources read if it's only read`); - requireUsed(resources.get("reduce"), used.resources.reduce, lists, name => `${name} is in resources reduce but never contributed to; remove it`); - } - if (used.up !== undefined) { - requireUsed(fields(declared.get("up")).get("read"), used.up, lists, - name => `${name} is in up.read but never read; remove it`); - } - if (used.lookups !== null) { - for (const [lookup, value] of fields(declared.get("lookup"))) { - const read = used.lookups.get(lookup); - if (read === null) continue; - const index = fields(value).has("index"); - requireUsed(fields(value).get("read"), read ?? new Set(), lists, name => index - ? `${name} is in lookup.${lookup} read but never read; remove it` - : `${name} is in lookup.${lookup} read but never read; remove it, or move it to lookup.${lookup} has to keep requiring it`); - } - } -}; diff --git a/packages/ecs/src/implementation/compiler/hygiene.test.ts b/packages/ecs/src/implementation/compiler/hygiene.test.ts index bcc7348f..d9426c1a 100644 --- a/packages/ecs/src/implementation/compiler/hygiene.test.ts +++ b/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -34,13 +34,13 @@ const spawn = System.create(({ commands }) => { }); const read: number[][] = []; -const readAll = System.create({ observer: true, components: { read: [p, c, l, s, e, v0, v1] } }, row => { +const readAll = System.create({ observer: true, components: { with: [p, c, l, s, e, v0, v1] } }, row => { read.push([row[p], row[c], row[l], row[s], row[e], row[v0], row[v1]]); }); // A kernel whose module constant and locals are named as the kernel emitter's bindings. const i = 100; -const grow = System.create({ kernel: true, components: { read: [c, l], write: [p, s] } }, row => { +const grow = System.create({ kernel: true, components: { with: [c, l, p, s] } }, row => { const n = row[c]; const x0 = row[l]; const s0 = n + x0; diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts new file mode 100644 index 00000000..46738ee4 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -0,0 +1,184 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ArrayExpression, Expression, Identifier, MemberExpression, ObjectExpression, Pattern, Property } from 'estree'; +import type { Used } from './body-access.js'; + +/** An object literal's plain-named properties; none for anything else. */ +const fields = (node: Expression | Pattern | undefined): ReadonlyMap => new Map(node?.type !== "ObjectExpression" ? [] : + node.properties.flatMap((property): (readonly [string, Property])[] => + property.type === "Property" && !property.computed && property.key.type === "Identifier" ? [[property.key.name, property]] : [])); + +/** + * Whether the build saw every use the body makes of its row, resources and parent, and of its lookups through an + * index: a kernel compiles from them. A lookup by entity whose rows it didn't follow reads all of its `with`. + */ +export const followsAll = (used: Used, declaration: ObjectExpression): boolean => { + if (used.query === null || used.resources === null || used.up === null) return false; + const lookups = fields(valueOf(fields(declaration).get("lookup"))); + if (used.lookups === null) return lookups.size === 0; + return [...used.lookups].every(([name, read]) => read !== null || !fields(valueOf(lookups.get(name))).has("index")); +}; + +/** `names` in the order `first` lists them, then the rest in the order the body first used them. */ +const ordered = (names: ReadonlySet, first: readonly string[]): ReadonlySet => + new Set([...first.filter(name => names.has(name)), ...names]); + +/** + * What a body uses, its row's and each lookup's keys in the order their `with` lists them, so the lists the world + * takes follow the declaration as written. + */ +export const inOrder = (used: Used, declaration: ObjectExpression): Used => { + const declared = fields(declaration); + const members = namesIn(valueOf(fields(valueOf(declared.get("components"))).get("with"))); + const lookups = fields(valueOf(declared.get("lookup"))); + return { + ...used, + query: used.query === null ? null : { read: ordered(used.query.read, members), write: ordered(used.query.write, members) }, + lookups: used.lookups === null ? null : new Map([...used.lookups].map(([name, read]) => + [name, read === null ? null : ordered(read, namesIn(valueOf(fields(valueOf(lookups.get(name))).get("with"))))])), + }; +}; + +/** + * The first key a body uses of its row, or of a lookup's rows by entity, that its declaration doesn't require, and + * where it's required: types reject it too, but a body the build reads may have none. Null when every one is + * required, or a list isn't an array literal the build can read. + */ +export const unrequired = (used: Used, declaration: ObjectExpression): { readonly key: string; readonly list: string } | null => { + const declared = fields(declaration); + const components = fields(valueOf(declared.get("components"))); + const literal = (names: readonly (Expression | undefined)[]): readonly string[] | null => { + const lists = names.filter(node => node !== undefined); + return lists.every(node => node.type === "ArrayExpression" && node.elements.every(element => element?.type === "Identifier" || element?.type === "MemberExpression")) + ? lists.flatMap(node => namesIn(node)) : null; + }; + const members = literal(["with", "added", "changed"].map(name => valueOf(components.get(name)))); + if (used.query !== null && members !== null) { + const key = [...used.query.read, ...used.query.write].find(name => !members.includes(name)); + if (key !== undefined) return { key, list: "components with" }; + } + if (used.lookups !== null) { + for (const [name, lookup] of fields(valueOf(declared.get("lookup")))) { + const parts = fields(valueOf(lookup)); + const read = used.lookups.get(name); + if (parts.has("index") || read === null || read === undefined) continue; + const required = literal([valueOf(parts.get("with")), valueOf(parts.get("changed"))]); + const key = required === null ? undefined : [...read].find(key => !required.includes(key)); + if (key !== undefined) return { key, list: `lookup.${name}.with` }; + } + } + return null; +}; + +const list = (names: Iterable): string => `[${[...names].join(", ")}]`; + +/** + * The source of what the build inferred a body uses, `Access` in `interface/system/resolve.ts`: its keys as the + * module-scope bindings the body indexes by, null for a group it couldn't follow, and `at` where the body is. + */ +export const accessText = (used: Used, at: string): string => { + const { query, resources, lookups, up } = used; + const components = query === null ? "null" : `{ read: ${list([...query.read].filter(key => !query.write.has(key)))}, write: ${list(query.write)} }`; + const resourceText = resources === null ? "null" : `{ read: ${list(resources.read)}, write: ${list(resources.write)}, reduce: ${list(resources.reduce)} }`; + const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`; + return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}, at: ${JSON.stringify(at)} }`; +}; + +/** A declaration's parts the build makes, located at `at`, so a failure in them points at the declaration. */ +const made = (at: { readonly start: number; readonly end: number }) => { + const identifier = (name: string): Identifier => ({ type: "Identifier", name, start: at.start, end: at.end }); + /** A key as the body named it: a binding, or a namespace's member. */ + const key = (name: string): Identifier | MemberExpression => { + const [object, member] = name.split("."); + return member === undefined || object === undefined ? identifier(name) : { + type: "MemberExpression", object: identifier(object), property: identifier(member), computed: false, optional: false, start: at.start, end: at.end, + }; + }; + const array = (names: Iterable): ArrayExpression => ({ type: "ArrayExpression", elements: [...names].map(key), start: at.start, end: at.end }); + const property = (name: string, value: Expression): Property => ({ + type: "Property", key: identifier(name), value, kind: "init", computed: false, method: false, shorthand: false, start: at.start, end: at.end, + }); + const object = (properties: readonly Property[]): ObjectExpression => ({ type: "ObjectExpression", properties: [...properties], start: at.start, end: at.end }); + return { identifier, array, property, object }; +}; + +/** `node`'s value when it's a property whose value is an expression. */ +const valueOf = (property: Property | undefined): Expression | undefined => + property !== undefined && property.value.type !== "ObjectPattern" && property.value.type !== "ArrayPattern" && property.value.type !== "RestElement" && property.value.type !== "AssignmentPattern" + ? property.value : undefined; + +/** The names an array literal of keys lists: bindings, or namespaces' members (`Report.run`). */ +const namesIn = (node: Expression | undefined): readonly string[] => node?.type !== "ArrayExpression" ? [] : + node.elements.flatMap(element => element?.type === "Identifier" ? [element.name] + : element?.type === "MemberExpression" && !element.computed && element.object.type === "Identifier" && element.property.type === "Identifier" ? [`${element.object.name}.${element.property.name}`] + : []); + +/** + * A kernel's declaration as the compiler reads it, from the declaration written and what the build inferred its body + * uses: `components` with `read` and `write` from the body beside its `with`, `resources` with `read` and `reduce`, + * each lookup with `read`, and `up` with `read`. `used` follows every use (`followsAll`). + */ +export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): ObjectExpression => { + const { array, property, object } = made(declaration); + const keep = (from: ReadonlyMap, names: readonly string[]): Property[] => + names.flatMap(name => from.get(name) ?? []); + const properties: Property[] = []; + let resourcesDeclared = false; + for (const original of declaration.properties) { + if (original.type !== "Property" || original.computed || original.key.type !== "Identifier") { + properties.push(...original.type === "Property" ? [original] : []); + continue; + } + const value = valueOf(original); + const parts = fields(value); + switch (original.key.name) { + case "components": { + if (value?.type !== "ObjectExpression" || used.query === null) break; + const { read, write } = used.query; + properties.push(property("components", object([ + property("read", array([...read].filter(key => !write.has(key)))), + property("write", array(write)), + ...keep(parts, ["with", "not", "added", "changed"]), + ]))); + continue; + } + case "resources": { + if (value?.type !== "ObjectExpression" || used.resources === null) break; + resourcesDeclared = true; + properties.push(property("resources", resourcesOf(used.resources, keep(parts, ["is", "not", "changed"]), namesIn(valueOf(parts.get("is"))), array, property, object))); + continue; + } + case "lookup": { + if (value?.type !== "ObjectExpression" || used.lookups === null) break; + properties.push(property("lookup", object([...parts].map(([name, lookup]) => { + const lookupParts = fields(valueOf(lookup)); + const reads = used.lookups?.get(name) ?? []; + if (lookupParts.has("index")) return property(name, object([property("read", array(reads ?? [])), ...keep(lookupParts, ["index", "changed"])])); + const withList = valueOf(lookupParts.get("with")); + // Rows the build didn't follow read everything the lookup requires. + const read = reads === null ? withList ?? array([]) : array(reads); + return property(name, object([property("read", read), ...keep(lookupParts, ["with", "not", "changed"])])); + })))); + continue; + } + case "up": { + if (value?.type !== "ObjectExpression" || used.up === null) break; + properties.push(property("up", object([...keep(parts, ["through"]), property("read", array(used.up))]))); + continue; + } + } + properties.push(original); + } + if (!resourcesDeclared && used.resources !== null) properties.push(property("resources", resourcesOf(used.resources, [], [], array, property, object))); + return object(properties); +}; + +/** A kernel's `resources`: what its body reads, but conditions it reads already, and reduces into; a write is refused by the compiler. */ +const resourcesOf = ( + resources: NonNullable, declared: readonly Property[], is: readonly string[], + array: (names: Iterable) => ArrayExpression, property: (name: string, value: Expression) => Property, object: (properties: readonly Property[]) => ObjectExpression, +): ObjectExpression => object([ + property("read", array([...resources.read].filter(key => !resources.write.has(key) && !is.includes(key)))), + ...resources.write.size === 0 ? [] : [property("write", array(resources.write))], + ...resources.reduce.size === 0 ? [] : [property("reduce", array(resources.reduce))], + ...declared, +]); diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts index 7d4deb7e..fcf37b3f 100644 --- a/packages/ecs/src/implementation/compiler/input-schemas.test.ts +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -30,10 +30,10 @@ describe("passInputSchemas", () => { ]); }); test("finds the input after the row when the declaration has components", async () => { - expect(await schemasOf(`System.create({ components: { read: [a] } }, (row, context, input: { dx: number }) => {});`)).toEqual([ + expect(await schemasOf(`System.create({ components: { with: [a] } }, (row, context, input: { dx: number }) => {});`)).toEqual([ { type: "object", properties: { dx: { type: "number" } } }, ]); - expect(await schemasOf(`System.create({ components: { read: [a] } }, (row, context) => {});`)).toBe(null); + expect(await schemasOf(`System.create({ components: { with: [a] } }, (row, context) => {});`)).toBe(null); }); test("takes a declaration not written in place's input from a third parameter, or a second with a type", async () => { expect(await schemasOf([ @@ -43,7 +43,7 @@ describe("passInputSchemas", () => { ].join("\n"))).toEqual([{ type: "object", properties: { a: { type: "number" } } }, { type: "object", properties: { b: { type: "number" } } }]); }); test("passes nothing for a body without input", async () => { - expect(await schemasOf(`System.create({ resources: { read: [a] } }, ({ resources }) => {});\nSystem.create(context => {});`)).toBe(null); + expect(await schemasOf(`System.create({ resources: {} }, ({ resources }) => {});\nSystem.create(context => {});`)).toBe(null); }); test("reads optional fields, literals, unions, arrays, tuples, sets and maps", async () => { expect(await schemasOf(`System.create({}, (_c, input: { diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts index d17d1509..c6e1e032 100644 --- a/packages/ecs/src/implementation/compiler/lists.ts +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -182,6 +182,13 @@ export const createLists = (kernel: Module, link: Link | null, nodes: readonly N const result: string[] = []; for (let k = 0; k < literal.elements.length; k++) { const element = literal.elements[k]; + // The kernel's own list may spread another it can read: `with: [...Unit, tagged]`. + if (element?.type === "SpreadElement" && module === kernel) { + const spread = names(element.argument); + if ("error" in spread) return spread; + result.push(...spread); + continue; + } const binding = element?.type === "Identifier" ? module.bindings.get(element.name) : undefined; if (element?.type !== "Identifier" || binding === undefined) { return { error: module === kernel ? "lists name keys by identifiers bound at module scope" : `${reference} names a key by something other than an identifier bound at the scope of ${module.file}` }; diff --git a/packages/ecs/src/implementation/compiler/module-value.ts b/packages/ecs/src/implementation/compiler/module-value.ts index 331bb260..0d75d48d 100644 --- a/packages/ecs/src/implementation/compiler/module-value.ts +++ b/packages/ecs/src/implementation/compiler/module-value.ts @@ -13,7 +13,7 @@ import type { Value } from './value.js'; /** Whether `name` is one of the kernel's component or resource keys. */ export const isKey = (scope: Scope, name: string): boolean => { const signature = scope.unit.signature; - return signature.read.has(name) || signature.write.has(name) || signature.resources.includes(name) + return signature.read.has(name) || signature.write.has(name) || signature.has.has(name) || signature.resources.includes(name) || signature.lookups.some(lookup => lookup.read.includes(name)); }; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 83521f2e..e2495536 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -21,7 +21,7 @@ import { registerComponent } from ${source("../../interface/registry/declaration import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); registerComponent(score, { type: { type: "number" } }); -const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); /** A kernel inlining a TypeScript helper and a function from a package's `dist`. */ @@ -43,7 +43,7 @@ import { scaled } from "./helper.ts"; import { increment } from "fake-math"; import { score } from "./linked-keys.js"; // Serving evaluates this module again after an edit, declaring the same system again with the same keys. -const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); export const description = kernelOf(kernel); `); /** Systems taking input, of a type another TypeScript module declares, which the test edits. */ @@ -71,7 +71,7 @@ writeFileSync(listing, `import { System } from ${source("../../interface/system/ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { score } from "./linked-keys.js"; import { Listed } from "./listed.js"; -const kernel = System.create({ kernel: true, components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); export const description = kernelOf(kernel); `); /** Two kernels in one module, writing different functions of the entity. */ @@ -81,8 +81,8 @@ import { registerComponent } from ${source("../../interface/registry/declaration import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); registerComponent(score, { type: { type: "number" } }); -const doubled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -const tripled = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); +const doubled = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); +const tripled = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ @@ -95,10 +95,10 @@ export const score = Symbol("score"); const shown = Symbol("shown"); registerComponent(score, ${declaration}); registerComponent(shown, { type: { const: true }, derived: true }); -export const rule = Rule.create({ components: { has: [score] }, derive: [shown] }); -export const release = Finalizer.create({ components: { read: [${released}] } }, () => {}); -export const system = Systems.create({ components: { write: [score] } }, (row) => { row[score] = 1; }); -export const kernel = Systems.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = 2; }); +export const rule = Rule.create({ components: { with: [score] }, derive: [shown] }); +export const release = Finalizer.create({ components: { with: [${released}] } }, () => {}); +export const system = Systems.create({ components: { with: [score] } }, (row) => { row[score] = 1; }); +export const kernel = Systems.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = 2; }); export const label = labelOf(score); `; const evaluated = join(directory, "evaluated.js"); @@ -134,7 +134,7 @@ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); registerComponent(score, { type: { type: "number" } }); -const kernel = System.create({ kernel: true, components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; const externalKey = join(directory, "external-key.js"); @@ -332,7 +332,7 @@ test("an application's build compiles the kernels a library ships uncompiled", a const score = Symbol("score"); declare$(score, "component", { type: { type: "number" } }); ${recorder} -export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); `); // The test hook comes from the installed copy, which the library's compiled kernel attaches to. const application = join(project, "application.js"); @@ -366,7 +366,7 @@ declare$(score, "component", { type: { type: "number" } }); const largest = Symbol(); declare$(largest, "resource", { type: { type: "number", default: 0 }, reduce: "max" }); ${recorder} -export const kernel = System.create({ kernel: true, components: { read: [score] }, resources: { reduce: [largest] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { reduce }) => { reduce[largest](row[score]); }); `); const application = join(project, "application.js"); writeFileSync(application, `import { kernelOf } from ${JSON.stringify(join(copy, "dist/implementation/world/attach-kernel.js"))}; @@ -394,7 +394,7 @@ test("an application's build says so when a library's dist doesn't register a re writeFileSync(join(project, "node_modules/unregistered-library/dist/index.js"), `import { System } from "@adobe/ecs"; const score = Symbol(); const largest = Symbol(); -export const kernel = System.create({ kernel: true, components: { read: [score] }, resources: { reduce: [largest] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { reduce }) => { reduce[largest](row[score]); }); `); const application = join(project, "application.js"); writeFileSync(application, `export { kernel } from "unregistered-library"; @@ -448,7 +448,7 @@ declare module "@adobe/ecs" { interface Components { [speed]: { type: F32 } } } export const push = System.create({}, (_context, input: { readonly by: number }) => { void input; }); -export const slow = System.create({ kernel: true, components: { write: [speed] } }, row => { row[speed] = row[speed] / 2; }); +export const slow = System.create({ kernel: true, components: { with: [speed] } }, row => { row[speed] = row[speed] / 2; }); `); const outDir = join(project, "dist"); const output = await build({ @@ -557,9 +557,9 @@ test("compiled code imports the runtime from the copy of the package its module writeFileSync(module, `import { System, Component } from "@adobe/ecs"; const score = Symbol("score"); declare$(score, "component", { type: { type: "number" } }); -export const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); ${recorder} -export const spawner = System.create({ kernel: false, components: { read: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); +export const spawner = System.create({ kernel: false, components: { with: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); `); const server = await createServer({ configFile: false, logLevel: "silent", root: project, plugins: [plugin()], diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 7442788a..f9cc6cfa 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -329,9 +329,9 @@ const compilePlugin = (automatic: boolean): Plugin => { parseTypes ??= (await import("vite")).parseAst; const linker = linkerFor(ssr, this); const hashes: string[] = []; - const transformed = await transformKernels(code, id, parse, async source => { + const transformed = await transformKernels(code, id, parse, async (source, namespace) => { const resolved = await this.resolve(source, id); - return resolved === null ? null : namespaceCopyOf(resolved.id, "System"); + return resolved === null ? null : namespaceCopyOf(resolved.id, namespace); }, (index, identity) => { const hash = kernelHash(identity); hashes[index] = hash; @@ -349,6 +349,7 @@ const compilePlugin = (automatic: boolean): Plugin => { for (const dependency of result.dependencies) this.addWatchFile(dependency); depend(parent, result.dependencies); if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + for (const warning of result.warnings) this.warn(warning); kernels.set(id, result.kernels.map((source, index) => ({ source, hash: hashes[index] ?? "" }))); return await identify(result.code) ?? { code: result.code, map: null }; }, diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 8ad975c3..a3279fae 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -25,6 +25,8 @@ export type Reduced = { readonly resource: string; readonly operator: Reduction export type Signature = { readonly read: ReadonlySet; readonly write: ReadonlySet; + /** What it requires of its rows, its `with`. */ + readonly has: ReadonlySet; readonly resources: readonly string[]; readonly reduced: readonly Reduced[]; readonly lookups: readonly Lookup[]; @@ -116,7 +118,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li for (const [name, property] of properties(declaration, "a kernel declaration")) { switch (name) { case "components": - components = keyLists(property.value, "components", ["read", "write", "has", "not", "added", "changed"], lists); + components = keyLists(property.value, "components", ["read", "write", "with", "not", "added", "changed"], lists); break; case "resources": for (const [list, value] of properties(property.value, "resources")) { @@ -125,6 +127,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li else if (list === "changed") changedResources = identifiers(value.value, "resources.changed", lists); else if (list === "reduce") reduced = reductions(value.value, lists); else if (list === "not") continue; + else if (list === "write") fail(value, "a kernel can't write resources: write them from a system that runs as written, or reduce into them"); else fail(value, `resources.${list} is not allowed in a kernel declaration`); } break; @@ -135,7 +138,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li const index = fields.some(([field]) => field === "index"); const listed = fields.filter(([field]) => field !== "index"); const read = listed.flatMap(([field, item]) => { - if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "has" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); + if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "with" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); // An index lookup's `changed: true` filters the system's runs, which the world does before calling it. if (index && field === "changed" && item.value.type === "Literal" && item.value.value === true) return []; const names = identifiers(item.value, `lookup.${lookup}.${field}`, lists); @@ -177,5 +180,5 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li if (both !== undefined) fail(declaration, `${both} is in both components read and write; write already makes it readable`); const rowFiltered = (components.get("added")?.length ?? 0) + (components.get("changed")?.length ?? 0) > 0; if (up !== null && ordered) fail(declaration, "a kernel can't use both up and order, which both decide the order of its rows"); - return { read: new Set(components.get("read")), write: new Set(write), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; + return { read: new Set(components.get("read")), write: new Set(write), has: new Set(components.get("with")), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 56c35f2c..158894c6 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -327,38 +327,38 @@ describe("kernels by default", () => { return "failure" in result ? result.failure.message : result.code.includes("attachKernel$("); }; test("compiles a system with components whose body is in the subset, unflagged", async () => { - expect(await attached(`const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`)).toBe(true); + expect(await attached(`const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`)).toBe(true); }); test("runs as written, without failing, a body that leaves the subset or a declaration a kernel can't have", async () => { - expect(await attached(`const log = []; const kernel = System.create({ components: { read: [mass] } }, row => { log.push(row[mass]); });`)).toBe(false); - expect(await attached(`const kernel = System.create({ components: { read: [mass] }, resources: { write: [delta] } }, (row, { resources }) => { resources[delta] = row[mass]; });`)).toBe(false); - expect(await attached(`const kernel = System.create({ observer: true, components: { read: [mass] } }, row => { void row[mass]; });`)).toBe(false); - expect(await attached(`const kernel = System.create({ target: "entity", components: { write: [score] } }, row => { row[score] = 1; });`)).toBe(false); - expect(await attached(`const make = () => System.create({ components: { write: [score] } }, row => { row[score] = 1; });`)).toBe(false); + expect(await attached(`const log = []; const kernel = System.create({ components: { with: [mass] } }, row => { log.push(row[mass]); });`)).toBe(false); + expect(await attached(`const kernel = System.create({ components: { with: [mass] } }, (row, { resources }) => { resources[delta] = row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ observer: true, components: { with: [mass] } }, row => { void row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ target: "entity", components: { with: [score] } }, row => { row[score] = 1; });`)).toBe(false); + expect(await attached(`const make = () => System.create({ components: { with: [score] } }, row => { row[score] = 1; });`)).toBe(false); }); test("runs as written a body reading data of a module object or array, which may change, and compiles one reading constants and functions", async () => { - expect(await attached(`export const scene = { subject: 0 }; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe(false); - expect(await attached(`const values = [1, 2]; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = values[0]; });`)).toBe(false); - expect(await attached(`const make = () => ({ speed: 2 }); export const settings = make(); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = settings.speed; });`)).toBe(false); - expect(await attached(`const pair = (a, b) => [a, b]; export const range = pair(1, 2); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = range[1]; });`)).toBe(false); - expect(await attached(`const wide = true; export const shape = wide ? { size: 2 } : { size: 1 }; const kernel = System.create({ components: { write: [score] } }, row => { row[score] = shape.size; });`)).toBe(false); - expect(await attached(`const make = () => 3; const three = make(); const kernel = System.create({ components: { write: [score] } }, row => { row[score] = three; });`)).toBe(true); - expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); - expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); + expect(await attached(`export const scene = { subject: 0 }; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = scene.subject; });`)).toBe(false); + expect(await attached(`const values = [1, 2]; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = values[0]; });`)).toBe(false); + expect(await attached(`const make = () => ({ speed: 2 }); export const settings = make(); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = settings.speed; });`)).toBe(false); + expect(await attached(`const pair = (a, b) => [a, b]; export const range = pair(1, 2); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = range[1]; });`)).toBe(false); + expect(await attached(`const wide = true; export const shape = wide ? { size: 2 } : { size: 1 }; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = shape.size; });`)).toBe(false); + expect(await attached(`const make = () => 3; const three = make(); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = three; });`)).toBe(true); + expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); + expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); }); test("tries none unflagged when automatic compilation is off, as the suites of bodies as written turn it", async () => { - expect(await attached(`const kernel = System.create({ components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(false); - expect(await attached(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(true); + expect(await attached(`const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(true); }); test("never compiles a system without components or with kernel: false", async () => { - expect(await attached(`const kernel = System.create({ resources: { read: [delta] } }, ({ resources }) => { void resources[delta]; });`)).toBe(false); - expect(await attached(`const kernel = System.create({ kernel: false, components: { read: [mass], write: [score] } }, row => { row[score] = row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ resources: {} }, ({ resources }) => { void resources[delta]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: false, components: { with: [mass, score] } }, row => { row[score] = row[mass]; });`)).toBe(false); }); test("fails, saying why, a system with kernel: true that can't compile", async () => { - expect(await attached(`const kernel = System.create({ kernel: true, resources: { read: [delta] } }, ({ resources }) => { void resources[delta]; });`)) + expect(await attached(`const kernel = System.create({ kernel: true }, ({ resources }) => { void resources[delta]; });`)) .toBe("a system without components runs once, so there's nothing to compile"); - expect(await attached(`const log = []; const kernel = System.create({ kernel: true, components: { read: [mass] } }, row => { log.push(row[mass]); });`)).not.toBe(true); - expect(await attached(`const make = () => System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = 1; });`)) + expect(await attached(`const log = []; const kernel = System.create({ kernel: true, components: { with: [mass] } }, row => { log.push(row[mass]); });`)).not.toBe(true); + expect(await attached(`const make = () => System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = 1; });`)) .toBe("kernel systems must be declared at module scope"); }); }); @@ -367,7 +367,7 @@ describe("particles", () => { test("gravity and integrate", async () => { const gravity = await compile(`const kernel = System.create({ kernel: true, - components: { write: [velocity] }, resources: { read: [delta] }, + components: { with: [velocity] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; row[velocity] = [x, y - 9.8 * resources[delta], z]; @@ -380,7 +380,7 @@ describe("particles", () => { const integrate = await compile(`const kernel = System.create({ kernel: true, - components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{}], + components: { with: [velocity, position] }, after: [{}], }, (row, { resources }) => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; @@ -398,7 +398,7 @@ describe("particles", () => { describe("layout", () => { test("interleaved components are read and written at row * stride + offset, leaving padding untouched", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, velocity] } }, (row) => { const [x, y, z] = row[velocity]; row[velocity] = [z, row[alive] === true ? y * 10 : y, x]; });`); @@ -411,7 +411,7 @@ describe("layout", () => { }); test("stamps set the changed row and its chunk; each changed column is followed by its chunk column", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] > 0) row[score] = 1; });`); const rows = 2 * 2 ** chunkShift + 5; @@ -425,12 +425,12 @@ describe("layout", () => { }); test("an unconditional stamp stamps the call's chunks once; a stamp after return stays per row", async () => { - const always = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { + const always = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { row[score] = row[mass]; });`); expect(String(always.create)).not.toContain(">> 10] = tick"); expect(run(always, 3200, {}, { range: [1000, 2100] }).chunks("score")).toEqual([tick, tick, tick, 0]); - const after = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { + const after = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] > 0) return; row[score] = 1; });`); @@ -440,7 +440,7 @@ describe("layout", () => { }); test("indices stay in step when return skips the rest of a row", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; if (x > 1 && x < 5) return; row[velocity] = [z, y, x]; @@ -452,14 +452,14 @@ describe("layout", () => { }); describe("reductions", () => { - const reducing = (reduce: string, body: string, read = "[position, mass]") => `const total = "total"; + const reducing = (body: string, read = "[position, mass]") => `const total = "total"; const low = "low"; const bits = "bits"; -const kernel = System.create({ kernel: true, components: { read: ${read} }, resources: { reduce: [${reduce}] } }, (row, { reduce }) => { ${body} });`; +const kernel = System.create({ kernel: true, components: { with: ${read} } }, (row, { reduce, resources }) => { ${body} });`; const fields = { position: vec3([1, -4, 2], [5, 0, -1], [3, 9, 6]), mass: { "[]": [2, 3, 12] } }; test("fold each contribution into its resource, leaf by leaf, from the partial", async () => { - const kernel = await compile(reducing("total, low, bits", ` + const kernel = await compile(reducing(` const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](1); @@ -474,23 +474,22 @@ const kernel = System.create({ kernel: true, components: { read: ${read} }, reso }); test("fold the values of inlined functions", async () => { - const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing("low", "reduce[low](Vec3.scale(row[position], 2));", "[position]")}`, {}, {}, { low: "max" }); + const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing("reduce[low](Vec3.scale(row[position], 2));", "[position]")}`, {}, {}, { low: "max" }); expect([0, 1, 2].map(k => run(kernel, 3, fields).reduced("low", [k]))).toEqual([4, 10, 18]); }); test("that can't be compiled fail with the reason", async () => { - expect(await failure(reducing("total", "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); - expect(await failure(reducing("total", "reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); - expect(await failure(reducing("total", "reduce[total](1);"), {}, {})).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, ${"which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"}`); - expect(await failure(reducing("total", "reduce[total](1);").replace(`kernel: true`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); - expect(await failure(reducing("total", "reduce[total](1);").replace("resources: { reduce:", "resources: { read: [total], reduce:"))).toBe("total is reduced, so it can't also be read or filtered on"); - expect(await failure(reducing("total, total", "reduce[total](1);"))).toBe("total is reduced twice"); + expect(await failure(reducing("reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); + expect(await failure(reducing("reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); + expect(await failure(reducing("reduce[total](1);"), {}, {})).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, ${"which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"}`); + expect(await failure(reducing("reduce[total](1);").replace(`kernel: true`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); + expect(await failure(reducing("reduce[total](resources[total]);"))).toBe("total is reduced, so it can't also be read or filtered on"); }); }); describe("let locals of component values", () => { test("written back whole after a tuple assignment have the tuple's shape", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, position] } }, (row) => { let v = row[position]; if (row[alive]) v = [7, 8, 9]; row[position] = v; @@ -503,22 +502,22 @@ describe("let locals of component values", () => { describe("binding identity", () => { test("a local shadowing a component key is not that component", async () => { - expect(await failure(`const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { + expect(await failure(`const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { const velocity = 1; row[position] = row[velocity]; - });`)).toBe("row is indexed by a component in components read or write, or .entity"); + });`)).toBe("a kernel's body indexes its row and context by key directly, or destructures them by key, so the build sees what it reads and writes; it can't pass them on, alias or spread them"); }); test("another binding with the same key string is not the declared component", async () => { expect(await failure(`const speed = "velocity"; - const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { row[position] = row[speed]; - });`)).toBe("speed is not in components read or write"); + });`)).toBe("speed isn't in components with, so the system doesn't require it: add it there to use it"); }); test("a helper parameter shadows a module binding only inside the helper", async () => { const kernel = await compile(`const twice = (velocity) => velocity * 2; - const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { row[position] = twice(row[velocity]) + row[velocity]; });`); expect(run(kernel, 1, { velocity: { "[]": [3] } }).field("position")).toEqual([9]); @@ -527,7 +526,7 @@ describe("binding identity", () => { describe("value semantics", () => { test("tuple locals are copies, and snapshots read before later writes", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { const before = row[position]; let v = [before[0], before[1]]; const w = v; @@ -541,7 +540,7 @@ describe("value semantics", () => { }); test("whole-value writes evaluate every leaf before storing", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { row[position] = [row[position][1], row[position][0]]; let a = 1; let b = 2; @@ -554,7 +553,7 @@ describe("value semantics", () => { }); test("struct values and conditional tuples", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, position] } }, (row) => { const p = row[mass] > 1 ? { x: 1, y: [2, 3] } : { x: 4, y: [5, 6] }; row[position] = p; });`); @@ -572,7 +571,7 @@ describe("helpers", () => { return [x * k, y * k]; }; const length = ([x, y]) => Math.sqrt(x * x + y * y); - const kernel = System.create({ kernel: true, components: { write: [position, score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { const k = 100; const x = 1; row[position] = scale(row[position], 0.5); @@ -585,14 +584,14 @@ describe("helpers", () => { test("recursive helpers are rejected", async () => { expect(await failure(`const f = (x) => f(x); - const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = f(1); });`)) + const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = f(1); });`)) .toBe("f is recursive"); }); }); describe("control flow", () => { test("return ends the row, also from inside a loop", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] < 0) return; let total = 0; for (let i = 0; i < 10; i++) { @@ -607,7 +606,7 @@ describe("control flow", () => { test("a loop test that inlines a helper is re-evaluated each iteration", async () => { const kernel = await compile(`const below = (a, b) => { const limit = b * 2; return a < limit; }; - const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { let n = 0; while (below(n, row[mass])) { n++; } let m = 0; @@ -621,7 +620,7 @@ describe("control flow", () => { describe("context", () => { test("lookups map components to their read index, with null guards", async () => { const kernel = await compile(`const half = (value) => { const h = value / 2; return h; }; - const kernel = System.create({ kernel: true, components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { + const kernel = System.create({ kernel: true, components: { with: [target, position, score] }, lookup: { targets: { with: [mass, position] } } }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { row[position] = other[position] - other[mass]; } row[score] = (other !== null && half(other[position])) || -1; @@ -634,7 +633,7 @@ describe("context", () => { }); test("optional lookup access, destructured context groups and input", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [target, score] }, lookup: { targets: { with: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; @@ -642,7 +641,7 @@ describe("context", () => { }); test("context groups reached through the context parameter", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [target, score] }, lookup: { targets: { with: [mass] } } }, (row, context, input) => { const { resources } = context; row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + input; });`); @@ -651,7 +650,7 @@ describe("context", () => { }); test("boolean columns read as booleans", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, score] } }, (row) => { row[score] = row[alive] === true ? 1 : 2; });`); expect(run(kernel, 2, { alive: { "[]": [1, 0] } }, { booleans: ["alive"] }).field("score")).toEqual([1, 2]); @@ -659,7 +658,7 @@ describe("context", () => { }); test("boolean reads convert only where observable, including through locals", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, mass, score] } }, (row, { commands }) => { const flag = row[alive]; const copy = flag; row[score] = (copy === true ? 10 : 20) + row[mass] * 2 + (flag ? 1 : 0); @@ -678,7 +677,7 @@ describe("context", () => { }); test("a spawn nested in an add's value is issued before the add begins", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { commands.add(row.entity, [target, score], { [target]: commands.spawn([mass])({ [mass]: row[mass] }), [score]: 1 }); commands.despawn(row.entity); });`); @@ -692,7 +691,7 @@ describe("context", () => { }); test("commands name components through keys", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [position] } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position] } }, (row, context) => { const { commands } = context; const spawned = commands.spawn([position, alive])({ [position]: [row[position][0], 1] }); commands.add(spawned, score, row.entity); @@ -717,7 +716,7 @@ describe("context", () => { const kernel = await compile(`import { Archetype } from "ecs"; const unit = Archetype.create([position, alive, score]); const pair = [mass, score]; - const kernel = System.create({ kernel: true, components: { read: [position] } }, (row, { commands }) => { + const kernel = System.create({ kernel: true, components: { with: [position] } }, (row, { commands }) => { const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); commands.add(spawned, pair, { [mass]: 3 }); commands.remove(row.entity, unit); @@ -734,7 +733,7 @@ describe("context", () => { test("kernel declarations list components through module constants", async () => { const kernel = await compile(`import { Archetype } from "ecs"; const moving = Archetype.create([position, velocity]); - const kernel = System.create({ kernel: true, components: { read: moving, write: [score] } }, row => { + const kernel = System.create({ kernel: true, components: { with: [score, ...moving] } }, row => { row[score] = row[position] + row[velocity]; });`); expect(run(kernel, 2, { position: { "[]": [1, 2] }, velocity: { "[]": [10, 20] } }).field("score")).toEqual([11, 22]); @@ -743,19 +742,19 @@ describe("context", () => { test.each([ ["a value for a component the list doesn't name", "commands.spawn([mass])({ [mass]: 1, [score]: 2 });", "score isn't one of the command's components"], ["a spawner kept rather than called", "const spawn = commands.spawn([mass]); spawn({ [mass]: 1 });", "a kernel calls the spawner where it gets it, as commands.spawn(components)(values)"], - ["a list holding a spread", "commands.spawn([...pair])({});", "commands.spawn: lists name keys by identifiers bound at module scope"], + ["a list holding a spread of what isn't a list", "commands.spawn([...mass])({});", "commands.spawn: mass is a key, not a list of components; list it as [mass]"], ["a list that isn't a module constant", "const list = 1; commands.add(row.entity, list, {});", "commands name components by identifiers bound at module scope"], ["a component listed twice", "commands.remove(row.entity, [mass, mass]);", "mass is listed twice"], ["an add with too many arguments", "commands.add(row.entity, mass, 1, 2);", "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"], ["a remove with rest components", "commands.remove(row.entity, mass, score);", "a remove is commands.remove(entity, component) or commands.remove(entity, components)"], ])("fail the build for %s", async (_, body, message) => { - expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); + expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); }); }); describe("program", () => { test("input and resource paths are scalars in the program", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }, input) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { resources: { [delta]: d } }, input) => { const [, y] = d; row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); });`); @@ -766,7 +765,7 @@ describe("program", () => { }); test("runtime indexing into a resource has no program", async () => { - const kernel = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row, { resources: { [delta]: d } }) => { row[score] = d[row[mass]]; });`); expect(run(kernel, 1, { mass: { "[]": [1] } }, { resources: [[5, 6]], program: false }).field("score")).toEqual([6]); @@ -777,15 +776,15 @@ describe("composites", () => { test("one argument to System.create that isn't a body is a composite, never a kernel", async () => { const result = await transform(`const a = System.create({}, () => {}); const listed = System.create([a]); -const declared = System.create({ kernel: true, systems: [a], components: { write: [score] } });`); +const declared = System.create({ kernel: true, systems: [a], components: { with: [score] } });`); expect(result !== null && "failure" in result ? result.failure.message : result?.kernels ?? []).toEqual([]); }); }); describe("lookups in the program", () => { const targets = (entity: number) => entity === 1 ? [3, [10, 20, 30]] : entity === 2 ? [4, [40, 50, 60]] : null; - const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ kernel: true, - components: { read: ${read}, write: [score] }, lookup: { ${lookup} } }, + const declaration = (body: string, read = "[target]", lookup = "targets: { with: [mass, position] }") => `const kernel = System.create({ kernel: true, + components: { with: ${read.replace("]", ", score]")} }, lookup: { ${lookup} } }, (row, { lookup: { ${lookup.includes("others") ? "targets, others" : "targets"} } }) => { ${body} });`; test("guarded leaf access, early return and a second lookup in its own handle", async () => { @@ -796,7 +795,7 @@ describe("lookups in the program", () => { const second = others(row[mass]); const extra = second !== null ? second[position][2] : 0; row[score] = tx - other[mass] + ty * 0 + extra; - `, "[target, mass]", "targets: { read: [mass, position] }, others: { read: [position] }")); + `, "[target, mass]", "targets: { with: [mass, position] }, others: { with: [position] }")); expect(kernel.program?.handles).toBe(2); const others = (entity: number) => entity === 2 ? [[0, 0, 7]] : null; expect(run(kernel, 3, { target: { "[]": [1, 2, 3] }, mass: { "[]": [2, 0, 0] }, score: { "[]": [-1, -1, -1] } }, { lookups: [targets, others] }).field("score")) @@ -804,7 +803,7 @@ describe("lookups in the program", () => { }); test("optional chains read NaN when nothing was found, anywhere", async () => { - const kernel = await compile(declaration(`row[score] = targets(row[target])?.[position]?.[1] * 2;`, "[target]", "targets: { read: [position] }")); + const kernel = await compile(declaration(`row[score] = targets(row[target])?.[position]?.[1] * 2;`, "[target]", "targets: { with: [position] }")); expect(kernel.program).not.toBeNull(); expect(run(kernel, 2, { target: { "[]": [1, 5] } }, { lookups: [entity => targets(entity)?.slice(1) ?? null] }).field("score")).toEqual([40, NaN]); }); @@ -819,9 +818,9 @@ describe("lookups in the program", () => { }); test.each([ - ["an unguarded leaf access", `const other = targets(row[target]); row[score] = other[mass];`, "targets: { read: [mass] }"], - ["a lookup result used whole", `const other = targets(row[target]); row[score] = other;`, "targets: { has: [mass] }"], - ["a leaf access after the guard's scope", `const other = targets(row[target]); if (other !== null) { row[score] = 1; } row[score] = other[mass];`, "targets: { read: [mass] }"], + ["an unguarded leaf access", `const other = targets(row[target]); row[score] = other[mass];`, "targets: { with: [mass] }"], + ["a lookup result used whole", `const other = targets(row[target]); row[score] = other;`, "targets: { with: [mass] }"], + ["a leaf access after the guard's scope", `const other = targets(row[target]); if (other !== null) { row[score] = 1; } row[score] = other[mass];`, "targets: { with: [mass] }"], ])("no program for %s", async (_, body, lookup) => { const kernel = await compile(declaration(body, "[target]", lookup)); expect(kernel.program).toBeNull(); @@ -829,11 +828,11 @@ describe("lookups in the program", () => { }); describe("subset", () => { - const kernel = (body: string, components = "{ read: [mass], write: [score] }") => + const kernel = (body: string, components = "{ with: [mass, score] }") => `const kernel = System.create({ kernel: true, components: ${components} }, (row, { commands }) => { ${body} });`; test.each([ - ["row[mass] = 1;", "mass is not in components write"], + ["row[mass] = 1;", "compiled"], ["row[score] = row[score] + row[mass];", "compiled"], ["const f = () => 1;", "functions can't be created in a kernel; define helpers at module scope"], ["row[score] = \"a\";", "only number, boolean and null literals are in the kernel subset"], @@ -849,46 +848,47 @@ describe("subset", () => { }); test("component values are written whole", async () => { - expect(await failure(kernel("row[position][0] = 1;", "{ write: [position] }"))).toBe("component values are immutable; write the whole value"); + expect(await failure(kernel("row[position][0] = 1;", "{ with: [position] }"))).toBe("component values are immutable; write the whole value"); }); test("GPU kernels", async () => { const gpu = (declaration: string, body: string) => `const kernel = System.create({ kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; - expect(await failure(gpu("components: { read: [mass], write: [score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); - expect(await failure(gpu("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass] } }", "row[score] = 1;"))) + expect(await failure(gpu("components: { with: [mass, score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); + expect(await failure(gpu("components: { with: [target, score] }, lookup: { targets: { with: [mass] } }", "row[score] = 1;"))) .toBe("a GPU kernel can't use lookups yet"); - expect(await failure(gpu("components: { write: [score], changed: [mass] }", "row[score] = 1;"))).toBe("a GPU kernel can't use change filters yet"); - expect(await failure(gpu("components: { read: [mass], write: [score] }", "context.commands.despawn(row.entity);"))) + expect(await failure(gpu("components: { with: [score], changed: [mass] }", "row[score] = 1;"))).toBe("a GPU kernel can't use change filters yet"); + expect(await failure(gpu("components: { with: [mass, score] }", "context.commands.despawn(row.entity);"))) .toBe("a GPU kernel can't issue commands or use values that aren't numbers or booleans"); - expect(await failure(gpu("components: { read: [mass], write: [score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) + expect(await failure(gpu("components: { with: [mass, score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) .toBe("a GPU kernel can't use loops or labeled blocks yet"); - expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { with: [score] } }, (row) => {});`)) .toBe(`kernel must be the literal true, false or "gpu"`); }); test("GPU programs carry their locals' names", async () => { const body = "(row) => { const doubled = row[mass] * 2; row[score] = doubled; }"; - const gpu = await compile(`const kernel = System.create({ kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); - const cpu = await compile(`const kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, ${body});`); + const gpu = await compile(`const kernel = System.create({ kernel: "gpu", components: { with: [mass, score] } }, ${body});`); + const cpu = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, ${body});`); expect(gpu.program?.names).toEqual([expect.stringMatching(/^doubled_\d+$/)]); expect(cpu.program?.names).toBeUndefined(); }); test("kernels inside functions", async () => { - expect(await failure(`const make = () => System.create({ kernel: true, components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const make = () => System.create({ kernel: true, components: { with: [score] } }, (row) => {});`)) .toBe("kernel systems must be declared at module scope"); }); test("failures carry the offending position", async () => { - const source = kernel("row[mass] = 1;"); + const source = kernel("var x = 1;"); const result = await transform(source); - const position = `${header}${source}`.indexOf("row[mass] = 1"); + const position = `${header}${source}`.indexOf("var x = 1"); expect(result !== null && "failure" in result ? result.failure.position : -1).toBe(position); }); test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { - expect(await transform(`const s = System.create({ components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); + const reaching = await transform(`const s = System.create({ components: { with: [mass] } }, (row, context) => { const f = () => [row, context]; });`); + expect(reaching !== null && "code" in reaching ? reaching.code : "").not.toContain("terminal$("); expect(await transformKernels(`import { System } from "other";\nSystem.create({ kernel: true }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); @@ -902,11 +902,11 @@ describe("terminal bodies", () => { return code.includes("terminal$((") && code.includes("function terminal$(body)"); }; test.each([ - ["no context", "(row)", "console.log(row);", `{ components: { has: [mass] } }`], + ["no context", "(row)", "console.log(row);", `{ components: { with: [mass] } }`], ["a context destructured without commands", "({ resources })", "console.log(resources);", `{}`], - ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ resources: { read: [delta] } }`], - ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { has: [mass] } }`], - ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ resources: { read: [delta] } }`], + ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ resources: {} }`], + ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { with: [mass] } }`], + ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ resources: {} }`], ])("mark a body with %s", async (_, parameters, body, declaration) => { expect(await marked(parameters, body, declaration)).toBe(true); }); @@ -927,15 +927,15 @@ describe("terminal bodies", () => { }); test("leave a function body unmarked that uses arguments", async () => { const result = await transform(`const system = System.create({}, function () { console.log(arguments); });`); - expect(result).toBeNull(); + expect(result !== null && "code" in result ? result.code : "").not.toContain("terminal$("); }); test("mark a system made from its body alone, and no kernel or finalizer", async () => { const alone = await transform("const system = System.create(() => { console.log(1); });"); - expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); - const kernel = await transform(`const kernel = System.create({ kernel: true, components: { write: [score] } }, (row) => { row[score] = 1; });`); + expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(access$(terminal$(() =>"); + const kernel = await transform(`const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = 1; });`); expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); - const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); - expect(finalizer).toBeNull(); + const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { with: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); + expect(finalizer !== null && "code" in finalizer ? finalizer.code : "").not.toContain("terminal$("); }); }); @@ -1027,8 +1027,8 @@ describe("commands in main-thread systems", () => { test.each([ ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{}`], ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{}`], - ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ kernel: false, components: { has: [mass] } }`], - ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ kernel: false, components: { has: [mass] } }`], + ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ kernel: false, components: { with: [mass] } }`], + ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ kernel: false, components: { with: [mass] } }`], ])("pass values positionally through %s", async (_, body, parameters, declaration) => { const { calls } = await spawned(body, parameters, declaration); expect(calls).toEqual([["prepared", ["score"], ["score"], [1]]]); @@ -1084,7 +1084,7 @@ describe("helpers that return early", () => { } return [c, 2]; }; - const kernel = System.create({ kernel: true, components: { read: [mass], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [mass, position] } }, (row) => { const [value, path] = pick(row[mass], 4); row[position] = [value, path, 0]; });`); @@ -1100,7 +1100,7 @@ describe("math builtins", () => { const results = (result: ReturnType) => [0, 1, 2].map(row => [0, 1, 2].map(k => result.field("position", [k])[row])); test("inline as scalar code, nested namespaces and helpers they call included", async () => { - const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { read: [velocity], write: [position, score] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { with: [velocity, position, score] } }, (row) => { const p = row[position]; const v = row[velocity]; row[position] = Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.F32.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); @@ -1119,7 +1119,7 @@ describe("math builtins", () => { }); test("across namespaces: quaternions rotating vectors, and matrices", async () => { - const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])); @@ -1132,7 +1132,7 @@ describe("math builtins", () => { test("in GPU kernels, become regions WGSL emits as builtins, and other backends run as the inlined body", async () => { const body = `(row) => { row[position] = Vec3.scale(Vec3.normalize(Vec3.cross(row[position], row[velocity])), Vec3.dot(row[position], row[velocity])); }`; - const declaration = `components: { read: [velocity], write: [position] }`; + const declaration = `components: { with: [velocity, position] }`; const gpu = await compile(`${math}const kernel = System.create({ kernel: "gpu", ${declaration} }, ${body});`); const cpu = await compile(`${math}const kernel = System.create({ kernel: true, ${declaration} }, ${body});`); const regions = (gpu.program?.body ?? []).flatMap(statement => statement.kind === "intrinsic" ? [statement.intrinsic.operation] : []); @@ -1148,17 +1148,17 @@ describe("math builtins", () => { }); test("that leave the kernel subset fail with the function's reason", async () => { - expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { row[position] = Vec3.random(); });`)).toBe("Math.random is not in the kernel subset"); - expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { row[position] = Vec3.missing([1, 2, 3]); });`)).toMatch(/^missing isn't exported by .*\/math\/vec3\/public\.ts$/); }); }); describe("imported functions and constants", () => { - const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: true, components: { read: [mass], write: [score] } }, (row) => { ${body} });`; + const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { ${body} });`; const scores = async (source: string, files: { readonly [file: string]: string }) => run(await compile(source, files), 2, { mass: { "[]": [2, 3] } }).field("score"); @@ -1243,7 +1243,7 @@ describe("imported lists", () => { const Unit = ["health", "armor", "tagged"]; const Guard = ["shield", "armor"]; const commanding = (imports: string, body: string) => - `${imports}\nconst kernel = System.create({ kernel: true, components: { read: [mass] } }, (row, { commands }) => { ${body} });`; + `${imports}\nconst kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { ${body} });`; test("name the keys of a list imported from another module, by the kernel's bindings or else through the list", async () => { const source = commanding(`import { health, armor } from "./components.js";\nimport { Unit, Guard } from "./archetypes.js";`, ` @@ -1282,12 +1282,12 @@ describe("imported lists", () => { test("in a declaration, bind keys the list's module defines to the kernel's imports of them, under any name", async () => { const source = `import { shield, Guard } from "./archetypes.js";\nimport { armor as plate } from "./components.js"; -const kernel = System.create({ kernel: true, components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; +const kernel = System.create({ kernel: true, components: { with: [score, ...Guard] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; const kernel = await compile(source, files, { shield: "shield", Guard, plate: "armor" }); expect(run(kernel, 2, { shield: { "[]": [1, 2] }, armor: { "[]": [3, 4] } }).field("score")).toEqual([13, 24]); + // What it requires but doesn't use, it only requires. expect(await failure(`import { shield, Guard } from "./archetypes.js"; -const kernel = System.create({ kernel: true, components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) - .toBe("Guard[1] is in components read but never read; remove it, or move it to components has to keep requiring it"); +const kernel = System.create({ kernel: true, components: { with: [score, ...Guard] } }, (row) => { row[score] = row[shield]; });`, files)).toBe("compiled"); }); test.each([ @@ -1338,140 +1338,74 @@ const kernel = System.create({ kernel: true, components: { read: Guard, write: [ test("fail the build for a declaration's list computed when its module runs", async () => { expect(await failure(`import { Computed } from "./archetypes.js"; -const kernel = System.create({ kernel: true, components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) - .toBe("components.read: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); +const kernel = System.create({ kernel: true, components: { with: Computed } }, (row) => { void row.entity; });`, files)) + .toBe("components.with: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); }); }); -describe("declared access", () => { - const kernel = (declaration: string, body: string) => - `const kernel = System.create({ kernel: true, ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; +describe("inferred access", () => { + /** What the build inferred the first system's body uses, as the source it gives its declaration, or why it failed. */ + const inferred = async (source: string): Promise => { + const result = await transform(source); + if (result === null) return "none"; + if ("failure" in result) return result.failure.message; + return /\{ components: .*?, at: "[^"]*" \}/.exec(result.code)?.[0] ?? "none"; + }; const system = (declaration: string, body: string) => `const system = System.create({ ${declaration} }, ${body});`; - test("a kernel uses every component and resource it lists", async () => { - expect(await failure(kernel("components: { read: [position, velocity], write: [score] }", "row[score] = row[velocity];"))) - .toBe("position is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(kernel("components: { read: [position], write: [velocity, mass] }", "row[velocity] = row[position];"))) - .toBe("mass is in components write but never written; remove it, or move it to components read if it's only read"); - expect(await failure(kernel("components: { write: [score] }, resources: { read: [delta] }", "row[score] = 1;"))) - .toBe("delta is in resources read but never read; remove it"); - expect(await failure(kernel("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass, position] } }", - "const found = lookup.targets(row[target]); row[score] = found === null ? 0 : found[mass];"))) - .toBe("position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"); - expect(await failure(kernel("components: { read: [mass], has: [alive] }", "const entity = row.entity;"))) - .toBe("mass is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(kernel("components: { read: [position, mass], write: [score] }, resources: { read: [delta] }", - "row[score] = row[mass] * resources[delta] + row[position][0];"))).toBe("compiled"); - }); - - test("a kernel reads every component it lists in up.read, and up holds only those", async () => { - const up = (kind: string, read: string, body: string) => - `const kernel = System.create({ kernel: ${kind === "gpu" ? `"gpu"` : "true"}, components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; - const propagate = "row[score] = (up === null ? 0 : up[score]) + row[mass];"; - expect(await failure(up("cpu", "score, position", propagate))).toBe("position is in up.read but never read; remove it"); - expect(await failure(up("cpu", "score", "row[score] = up === null ? row[mass] : up[position];"))).toBe("up is indexed by a component in up.read"); - expect(await failure(up("gpu", "score", propagate))).toBe("compiled"); - expect(await failure(up("cpu", "score", propagate))).toBe("compiled"); - }); - - test("a main-thread system uses every component and resource it lists, where its body shows every access", async () => { - const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; - expect(await failure(system("components: { read: [position, mass], write: [velocity] }", "(row) => { row[velocity] = row[position]; }"))).toBe(unread); - expect(await failure(system("components: { read: [position], write: [velocity] }", "(row) => { console.log(row[position]); }"))) - .toBe("velocity is in components write but never written; remove it, or move it to components read if it's only read"); - expect(await failure(system("components: { read: [mass] }", "() => {}"))).toBe(unread); - expect(await failure(system("components: { read: [mass] }", "(row, { commands }) => commands.despawn(row.entity)"))).toBe(unread); - expect(await failure(system("components: { write: [score] }", "(row) => { row[score] += 1; }"))).toBe("compiled"); - }); - - test("a main-thread system uses every resource it lists, through its context's resources however it reaches them", async () => { - const unread = "delta is in resources read but never read; remove it"; - // Without components, the context is the first parameter. - expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { context.resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta], write: [score] }", "({ resources }) => { resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { const { resources } = context; resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta] }", "(_context, input) => { console.log(input); }"))).toBe(unread); - expect(await failure(system("resources: { write: [score, mass] }", "({ resources }) => { resources[score] = 1; }"))).toBe("mass is in resources write but never written; remove it, or move it to resources read if it's only read"); - expect(await failure(system("components: { has: [mass] }, resources: { read: [delta], write: [score] }", - "(_, context) => { context.resources[score] = context.resources[delta]; }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ resources: { [delta]: d } }) => { console.log(d); }"))).toBe("compiled"); - }); - - test("a system contributes to every resource it reduces", async () => { - const totals = `const total = "total";\nconst low = "low";\n`; - const missing = "low is in resources reduce but never contributed to; remove it"; - expect(await failure(`${totals}${kernel("components: { read: [mass] }, resources: { reduce: [total, low] }", "reduce[total](row[mass]);")}`)).toBe(missing); - expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total, low] }", "(row, { reduce }) => { reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total, low] }", "(row, context) => { context.reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system("components: { read: [mass] }, resources: { reduce: [total] }", "(row, { reduce }) => { const add = reduce[total]; add(row[mass]); }")}`)).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists in a lookup, where its body shows every use of the result", async () => { - const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { targets: { read: ${lookup} } }`, body); - const unread = "position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"; - expect(await failure(looking("[mass, position]", "(row, context) => { const found = context.lookup.targets(row[target]); if (found !== null) console.log(found[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { console.log(targets(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); if (!found) return; console.log(found[mass]); }"))) - .toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); console.log(found); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { let found = targets(row[target]); console.log(found?.[mass]); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup); console.log(row[target]); }"))).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists in a lookup through an index, from its lists' rows", async () => { - const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { near: { index: grid, read: ${lookup} } }`, body); - const unread = "position is in lookup.near read but never read; remove it"; - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); for (let i = 0; i < near.length; i++) console.log(near.at(i)[mass], near.changed); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, context) => { const other = context.lookup.near(row[target]).at(0); console.log(other[mass], other.entity); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup: { near } }) => { console.log(near(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); console.log(near.at(0)[mass], near.at(1)[position]); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup.near(row[target])); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); const all = near.at; console.log(all); }"))).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists through a destructured row", async () => { - const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; - expect(await failure(system("components: { read: [position, mass] }", "({ [position]: p, entity }) => { console.log(p, entity); }"))).toBe(unread); - expect(await failure(system("components: { read: [position, mass] }", "(row) => { const { [position]: p } = row; console.log(p); }"))).toBe(unread); - expect(await failure(system("components: { read: [position] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); - }); - - test("finalizers are checked as main-thread bodies, in files that import only Finalizer", async () => { - const finalizing = (source: string) => transformKernels(source, "/finalizer.js", parse, async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`)); - const failed = async (source: string) => { - const result = await finalizing(source); - return result !== null && "failure" in result ? result.failure.message : "compiled"; - }; - const header = `import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\n`; - expect(await failed(`${header}const f = Finalizer.create({ components: { read: [handle, count] } }, (row) => { console.log(row[handle]); });`)) - .toBe("count is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failed(`${header}const f = Finalizer.changes({ resources: { read: [count] } }, (changes, { resources }) => { console.log(changes); });`)) - .toBe("count is in resources read but never read; remove it"); - expect(await failed(`${header}const f = Finalizer.changes({ resources: { read: [count] } }, (changes, { resources }) => { console.log(changes, resources[count]); });`)) - .toBe("compiled"); - expect(await failed(`${header}const f = Finalizer.create({ components: { read: [handle] } }, (row) => { console.log(row[handle]); });`)).toBe("compiled"); - }); - - test("a list declared as a module constant is checked as its array literal", async () => { - const moving = `const moving = [position, velocity];\n`; - expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position]); }")}`)) - .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position], row[velocity]); }")}`)).toBe("compiled"); - expect(await failure(`${moving}${kernel("components: { read: moving, write: [score] }", "row[score] = row[position];")}`)) - .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); - }); - - test("a main-thread system whose body hides an access isn't checked", async () => { - expect(await failure(system("components: { read: [mass] }", "(row) => { helper(row); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "(row) => { for (const key of [mass]) console.log(row[key]); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "(row) => { const mass = \"other\"; console.log(row[mass]); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "function (row) { console.log(arguments[0][mass]); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ resources: { ...all } }) => { console.log(all); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "(context) => { helper(context.resources); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "(context) => { let r = context.resources; console.log(r); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "body"))).toBe("compiled"); - expect(await failure("const decay = (k) => System.create({ name: \"a\", components: { read: [k] } }, (row) => { console.log(row[k]); });")).toBe("compiled"); + test("a row's reads and writes, its members in with's order", async () => { + expect(await inferred(system("components: { with: [position, velocity, mass] }", "(row) => { row[velocity] = row[mass] + row[position]; }"))) + .toContain("components: { read: [position, mass], write: [velocity] }"); + expect(await inferred(system("components: { with: [score] }", "(row) => { row[score] += 1; }"))).toContain("components: { read: [], write: [score] }"); + expect(await inferred(system("components: { with: [position, mass] }", "({ [position]: p, entity }) => { console.log(p, entity); }"))) + .toContain("components: { read: [position], write: [] }"); + }); + + test("a row it can't follow as unknown, which the world takes as every member written, with a warning", async () => { + const result = await transform(system("components: { with: [position] }", "(row) => { helper(row); }")); + expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings.length] : null).toEqual([true, 1]); + }); + + test("resources read, written and reduced, however its context reaches them", async () => { + expect(await inferred(system("", "(context) => { context.resources[score] = context.resources[delta]; }"))) + .toContain("resources: { read: [delta], write: [score], reduce: [] }"); + expect(await inferred(system("", "(context) => { const { resources } = context; resources[score] += 1; }"))) + .toContain("resources: { read: [score], write: [score], reduce: [] }"); + expect(await inferred(system("", "({ resources: { [delta]: d } }) => { console.log(d); }"))).toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(`const total = "total";\n${system("components: { with: [mass] }", "(row, { reduce }) => { reduce[total](row[mass]); }")}`)) + .toContain("resources: { read: [], write: [], reduce: [total] }"); + expect(await inferred(system("", "(context) => { helper(context); }"))).toContain("resources: null"); + }); + + test("what a lookup's rows are read for, by entity and through an index, or unknown", async () => { + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass, position] } }", + "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[mass]); }"))).toContain(`lookup: { "targets": [mass] }`); + expect(await inferred(system("components: { with: [target] }, lookup: { near: { index: grid } }", + "(row, { lookup }) => { const near = lookup.near(row[target]); console.log(near.at(0)[mass], near.length); }"))).toContain(`lookup: { "near": [mass] }`); + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", + "(row, { lookup }) => { helper(lookup.targets(row[target])); }"))).toContain(`lookup: { "targets": null }`); + }); + + test("what its body reads of its parent", async () => { + expect(await inferred(system("components: { with: [mass, score] }, up: { through: target }", "(row, { up }) => { row[score] = (up === null ? 0 : up[score]) + row[mass]; }"))) + .toContain("up: [score]"); + }); + + test("fail the build for a key a body uses that its declaration doesn't require", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { console.log(row[mass]); }"))) + .toBe("mass isn't in components with, so the system doesn't require it: add it there to use it"); + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[position]); }"))) + .toBe("position isn't in lookup.targets.with, so the system doesn't require it: add it there to use it"); + }); + + test("finalizers, in files that import only Finalizer", async () => { + const result = await transformKernels(`import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\nconst f = Finalizer.create({ components: { with: [handle, count] } }, (row, { resources }) => { console.log(row[handle], resources[count]); });`, + "/finalizer.js", parse, async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`)); + expect(result !== null && "code" in result ? result.code : "").toContain("{ components: { read: [handle], write: [] }, resources: { read: [count], write: [], reduce: [] }"); + }); + + test("a body given by name, read where it's bound", async () => { + expect(await inferred(`const move = (row) => { row[position] = row[velocity]; };\n${system("components: { with: [position, velocity] }", "move")}`)) + .toContain("components: { read: [velocity], write: [position] }"); }); }); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 80c352cf..286b762a 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -1,26 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; +import type { FunctionDeclaration, MaybeNamedFunctionDeclaration, Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; +import { posix } from 'node:path'; import { compileKernel, type CompiledKernel } from './compile-kernel.js'; import { children, isFunction } from './estree.js'; import { fail, failureOf, type Failure } from './failure.js'; import { isPending, type Link, type Linker } from './link.js'; -import { createModule, locateIn, type Module } from './module-scope.js'; +import { createModule, locateIn, type Module, type ModuleBinding } from './module-scope.js'; import type { PackageCopy } from './paths.js'; import { spawnSites } from './spawns.js'; import { kernelOf, type KernelRequest } from './signature.js'; -import { usedBy } from './body-access.js'; -import { requireExact } from './exact.js'; -import { createLists } from './lists.js'; +import { usedBy, type Used } from './body-access.js'; +import { accessText, followsAll, inOrder, resolvedDeclaration, unrequired } from './inferred.js'; import { neverReachesCommands } from './terminal.js'; import { lookupSites } from './lookups.js'; import { terminalBodiesName } from '../world/terminal.js'; +import { accessName as accessName$ } from '../../interface/registry/ids.js'; export type Parse = (code: string) => Program; /** * The copy of the package an import specifier, resolved from the module being transformed, is a module of that exports - * `System` and `Finalizer`; null when it's no such module. Compiled code imports the runtime from that copy. + * `namespace`; null when it's no such module. Compiled code imports the runtime from that copy. */ -export type ResolveSystemImport = (source: string) => Promise; +export type ResolveSystemImport = (source: string, namespace: "System" | "Finalizer") => Promise; /** * The JS expression for kernel `index`'s module url. `identity` is everything the description and module are * compiled to (the description without its url, plus the module source), so it can version the url. @@ -31,7 +32,7 @@ export type KernelUrl = (index: number, identity: string) => string; * whose functions and constants the kernels inline, or that compiling them read before failing. */ export type TransformResult = - | { readonly code: string; readonly kernels: readonly string[]; readonly dependencies: readonly string[] } + | { readonly code: string; readonly kernels: readonly string[]; readonly dependencies: readonly string[]; readonly warnings: readonly string[] } | { readonly failure: Failure; readonly dependencies: readonly string[] }; type Edit = { readonly start: number; readonly end: number; readonly text: string }; @@ -45,29 +46,34 @@ const systemLocals = async (program: Program, resolveSystemImport: ResolveSystem if (statement.type !== "ImportDeclaration" || typeof statement.source.value !== "string") continue; const specifiers = statement.specifiers.flatMap(specifier => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && (specifier.imported.name === "System" || specifier.imported.name === "Finalizer") ? [{ local: specifier.local.name, imported: specifier.imported.name }] : []); - const resolved = specifiers.length > 0 ? await resolveSystemImport(statement.source.value) : null; - if (resolved !== null) { + for (const { local, imported } of specifiers) { + const namespace = imported === "System" ? "System" : "Finalizer"; + const resolved = await resolveSystemImport(statement.source.value, namespace); + if (resolved === null) continue; copy ??= resolved; - for (const { local, imported } of specifiers) locals.set(local, imported === "System" ? "System" : "Finalizer"); + locals.set(local, namespace); } } return { locals, copy }; }; -type Created = "system" | "finalizer" | "changes"; +type Created = "system" | "render" | "finalizer" | "changes"; -/** `System.create`, or `Finalizer.create` or `Finalizer.changes`, whose bodies run on the main thread; null for any other call. */ +/** `System.create` or `System.render`, or `Finalizer.create` or `Finalizer.changes`, whose bodies the build reads; null for any other call. */ const createdBy = (node: SimpleCallExpression, locals: ReadonlyMap): Created | null => { if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.object.type !== "Identifier" || node.callee.property.type !== "Identifier") return null; const namespace = locals.get(node.callee.object.name); const method = node.callee.property.name; - if (namespace === "System") return method === "create" ? "system" : null; + if (namespace === "System") return method === "create" ? "system" : method === "render" ? "render" : null; return namespace === "Finalizer" && (method === "create" || method === "changes") ? (method === "create" ? "finalizer" : "changes") : null; }; -/** Which parameters are the row and the context: a system without `components` takes only its context, and a changes finalizer takes its changes first. */ +/** + * Which parameters are the row and the context: a system without `components` takes only its context, a render + * system its GPU context first, and a changes finalizer its changes first. + */ const positionsOf = (created: Created, declaration: ObjectExpression): { readonly row: number; readonly context: number } => { - if (created === "changes") return { row: -1, context: 1 }; + if (created === "changes" || created === "render") return { row: -1, context: 1 }; const rowed = declaration.properties.some(property => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "components"); return rowed ? { row: 0, context: 1 } : { row: -1, context: 0 }; @@ -103,6 +109,39 @@ const addFunction = (name: string, prepare: string, keys: readonly string[]): st ].join("\n"); }; +/** + * A declaration written in place: an object literal, or one passed to a call that wraps it, as a helper giving it a + * label or an id does; undefined for anything else. + */ +const literalOf = (node: Node | undefined): ObjectExpression | undefined => { + if (node?.type === "ObjectExpression") return node; + if (node?.type === "CallExpression" && node.arguments[0]?.type === "ObjectExpression") return node.arguments[0]; + return undefined; +}; + +/** Fails the build for a key the body uses that its declaration doesn't require, which would otherwise narrow what it matches unseen. */ +const requireRequired = (used: Used, declaration: ObjectExpression, body: Node): void => { + const found = unrequired(used, declaration); + if (found !== null) fail(body, `${found.key} isn't in ${found.list}, so the system doesn't require it: add it there to use it`); +}; + +/** A function declaration with a name, which `export default` may leave out. */ +const isNamed = (node: FunctionDeclaration | MaybeNamedFunctionDeclaration): node is FunctionDeclaration => node.id !== null; + +/** The function a module-scope binding holds: a function declaration, or a `const` initialized by one. */ +const functionOf = (binding: ModuleBinding | undefined): Node | undefined => { + if (binding?.kind === "function") return isNamed(binding.node) ? binding.node : undefined; + if (binding?.kind === "const" && isFunction(binding.init)) return binding.init; + return undefined; +}; + +/** The declaration of a system that declares nothing, made from its body alone. */ +const empty: ObjectExpression = { type: "ObjectExpression", properties: [], start: 0, end: 0 }; + +/** Whether a declaration has property `name`, written out. */ +const declares = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name); + const uniqueName = (code: string, base: string): string => { let name = base; for (let n = 1; code.includes(name); n++) name = `${base}${n}`; @@ -121,13 +160,13 @@ const compileLinked = async (declaration: ObjectExpression, body: Node, module: } }; -type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly request: KernelRequest }; +type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly request: KernelRequest; readonly used: Used }; /** * The module's `System.create` calls that compile to kernels, required or, when `automatic`, tried: at module scope, * with an inline body. Throws for a required one elsewhere or without one; a tried one there runs as written. */ -const kernelSites = (program: Program, locals: ReadonlyMap, automatic: boolean): readonly KernelSite[] => { +const kernelSites = (program: Program, locals: ReadonlyMap, automatic: boolean, module: Module): readonly KernelSite[] => { const sites: KernelSite[] = []; const visit = (node: Node, nested: boolean): void => { if (node.type === "CallExpression" && createdBy(node, locals) === "system") { @@ -138,7 +177,13 @@ const kernelSites = (program: Program, locals: ReadonlyMap(); - /** Main-thread declarations are checked only against lists of their own module. */ - const mainThreadLists = createLists(module, null, []); + const at = (node: Node): string => `${posix.basename(file)}:${module.locate(node.start).line}`; + const warnings: string[] = []; + const accessName = uniqueName(code, "access$"); + let accessing = false; const link: Link | null = linker === undefined ? null : { module: dependency => { if (dependency !== file) dependencies.add(dependency); @@ -190,7 +237,7 @@ export const transformKernels = async ( const edits: Edit[] = []; const kernels: string[] = []; /** Each compiled kernel's description, by its call, and whether its declaration required it. */ - const compiled = new Map(); + const compiled = new Map(); /** Module-scope declarations of prepared spawns, appended after everything they name is declared. */ const prepared: string[] = []; let adding = false; @@ -198,30 +245,63 @@ export const transformKernels = async ( const visit = (node: Node, nested: boolean): void => { const created = node.type === "CallExpression" ? createdBy(node, locals) : null; const [first, second] = node.type === "CallExpression" ? node.arguments : []; + /** Whether `node` is a function, written in place or bound by name at module scope. */ + const functional = (node: Node | undefined): boolean => node !== undefined && (isFunction(node) || (node.type === "Identifier" && functionOf(module.bindings.get(node.name)) !== undefined)); // One argument that isn't a body makes a composite, of a list or a declaration: never a kernel, nor checked. - const composite = created === "system" && first !== undefined && second === undefined && !isFunction(first); + const composite = created === "system" && first !== undefined && second === undefined && !functional(first); if (node.type === "CallExpression" && created !== null && !composite) { // A system that declares nothing is made from its body alone, which takes only its context. - const alone = created === "system" && first !== undefined && isFunction(first) && second === undefined; - const declaration = alone ? undefined : first; - const body = alone ? first : second; + const alone = created === "system" && first !== undefined && functional(first) && second === undefined; + const declaration = alone ? undefined : literalOf(first); + const given = alone ? first : second; + // A body given by name is the function its module binds it to, which the build reads in its place. + const body = given?.type === "Identifier" ? functionOf(module.bindings.get(given.name)) ?? given : given; const kernel = compiled.get(node); + const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : created === "render" ? positionsOf(created, empty) : null; + // A declaration not written in place, or a body that isn't a function written in place, is one the build can't + // read: it takes what it uses from an internal factory, or fails where it's made. + if (positions === null || first === undefined || first.type === "SpreadElement" || body === undefined || body.type === "SpreadElement" || !isFunction(body)) { + for (const child of children(node)) visit(child, nested || isFunction(node)); + return; + } + const declared = declaration?.type === "ObjectExpression" ? declaration : empty; + const used = kernel?.used ?? inOrder(usedBy(declared, body, module.bindings, positions), declared); + requireRequired(used, declared, body); + const where = at(body); + if (used.query === null) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); + if (used.lookups !== null && [...used.lookups.values()].some(read => read === null) || used.lookups === null && declares(declared, "lookup")) { + warnings.push(`${where}: the build can't follow how this body uses a lookup's rows, so it takes everything they hold as read.`); + } + accessing = true; + const access = accessText(used, where); if (kernel !== undefined) { - edits.push({ start: node.start, end: node.end, text: `${attach}(${code.slice(node.start, node.end)}, ${kernel.description}${kernel.required ? "" : ", true"})` }); + // `first` is the declaration: a kernel's call has one written in place. + const call = `${code.slice(node.start, first.start)}${accessName}(${code.slice(first.start, first.end)}, ${access})${code.slice(first.end, node.end)}`; + edits.push({ start: node.start, end: node.end, text: `${attach}(${call}, ${kernel.description}${kernel.required ? "" : ", true"})` }); return; } - const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : null; - if (declaration?.type === "ObjectExpression" && positions !== null && body !== undefined && body.type !== "SpreadElement") { - requireExact(declaration, usedBy(declaration, body, module.bindings, positions), mainThreadLists); + // Edits within a body apply only to one written in place; a body given by name is only read. + if (given !== body) { + edits.push({ start: first.start, end: first.start, text: `${accessName}(` }, { start: first.end, end: first.end, text: `, ${access})` }); + return; } // A system whose body never reaches its commands is terminal where its declaration allows (`world/tails.ts`). - if (created === "system" && positions !== null && body !== undefined && body.type !== "SpreadElement" && neverReachesCommands(body, positions.context)) { - terminals = true; - edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); + const terminalBody = created === "system" && neverReachesCommands(body, positions.context); + if (terminalBody) terminals = true; + if (alone) { + // The body is the system's first argument, which takes what it uses, outside its terminal mark. + edits.push( + { start: body.start, end: body.start, text: `${accessName}(${terminalBody ? `${terminal}(` : ""}` }, + { start: body.end, end: body.end, text: `${terminalBody ? ")" : ""}, ${access})` }, + ); + } + else { + edits.push({ start: first.start, end: first.start, text: `${accessName}(` }, { start: first.end, end: first.end, text: `, ${access})` }); + if (terminalBody) edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); } - const spawns = positions === null || body === undefined || body.type === "SpreadElement" ? null : spawnSites(body, module.bindings, positions.context); + const spawns = spawnSites(body, module.bindings, positions.context); // Index lookups pass their key positionally, through a list of their own (`world/prepared-lookup.ts`). - const lookups = positions === null || body === undefined || body.type === "SpreadElement" || declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); + const lookups = declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); const spawned = spawns?.sites ?? []; for (const site of lookups) { if (spawned.some(other => other.start < site.end && site.start < other.end)) continue; @@ -247,17 +327,17 @@ export const transformKernels = async ( for (const child of children(node)) visit(child, inner); }; try { - for (const { node, declaration, body, request } of kernelSites(program, locals, automatic)) { + for (const { node, declaration, body, request, used } of kernelSites(program, locals, automatic, module)) { let kernel: CompiledKernel; try { - kernel = await compileLinked(declaration, body, module, request, link); + kernel = await compileLinked(resolvedDeclaration(declaration, used), body, module, request, link); } catch (error) { // A body tried unflagged that leaves the subset runs as written. if (!request.required && failureOf(error) !== undefined) continue; throw error; } const source = `export const create = ${kernel.create};\n`; - compiled.set(node, { description: kernel.description(kernelUrl(kernels.length, `${kernel.description("")}\n${source}`)), required: request.required }); + compiled.set(node, { description: kernel.description(kernelUrl(kernels.length, `${kernel.description("")}\n${source}`)), required: request.required, used }); kernels.push(source); } visit(program, false); @@ -276,5 +356,7 @@ export const transformKernels = async ( ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(copy.preparedLookup)};`] : [], ]; const marking = terminals ? [`function ${terminal}(body) { const set = Symbol.for(${JSON.stringify(terminalBodiesName)}); (globalThis[set] || (globalThis[set] = new WeakSet())).add(body); return body; }`] : []; - return { code: [...imports, result, ...prepared, ...marking].join("\n"), kernels, dependencies: [...dependencies] }; + // An internal factory may have given its declaration what its body uses first, which stands. + const giving = accessing ? [`function ${accessName}(made, uses) { const key = Symbol.for(${JSON.stringify(accessName$)}); const map = globalThis[key] || (globalThis[key] = new WeakMap()); if (!map.has(made)) map.set(made, uses); return made; }`] : []; + return { code: [...imports, result, ...prepared, ...marking, ...giving].join("\n"), kernels, dependencies: [...dependencies], warnings }; }; diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts index 827883c9..61238339 100644 --- a/packages/ecs/src/implementation/world/async-loading.test.ts +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -38,12 +38,11 @@ const load = async (path: string): Promise => { }; // 1. A rule derives a slot for every visible entity with a mesh source. -Rule.create({ components: { has: [meshSource], not: [hidden] }, derive: [meshSlot] }); +Rule.create({ components: { with: [meshSource], not: [hidden] }, derive: [meshSlot] }); // 2. Each new slot gets a fresh token, and its load is requested; the application starts requested loads. const request = System.create({ - components: { read: [meshSource], write: [meshSlot], added: [meshSlot] }, - resources: { write: [nextToken, requests] }, + components: { with: [meshSource, meshSlot], added: [meshSlot] }, }, (row, { resources }) => { const token = resources[nextToken]; resources[nextToken] = token + 1; @@ -53,14 +52,14 @@ const request = System.create({ // 3. A completed load arrives as a run of its own. A slot whose token still matches gets the mesh; a stale one releases it. const apply = System.create({ - lookup: { slots: { read: [meshSlot] } }, + lookup: { slots: { with: [meshSlot] } }, }, ({ lookup, commands }, input: { readonly entity: Entity; readonly token: number; readonly handle: number }) => { if (lookup.slots(input.entity)?.[meshSlot] === input.token) commands.add(input.entity, meshReady, input.handle); else loaded.delete(input.handle); }); // 4. When the slot goes, its mesh goes with it, and the finalizer releases what was loaded. -Finalizer.create({ components: { read: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); +Finalizer.create({ components: { with: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); const spawn = System.create({}, ({ commands }) => { commands.spawn([meshSource])({ [meshSource]: "ship.glb" }); diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts index 92d229bf..fe2f0166 100644 --- a/packages/ecs/src/implementation/world/builtins.test.ts +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -31,7 +31,7 @@ const rotation = Quat.fromAxisAngle([0, 1, 0], 0.5); const matrix = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); /** Rotates, transforms and offsets each position, with functions from three math namespaces inlined. */ -const transform = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); @@ -52,7 +52,7 @@ const integerResults = (b: Vec3.U32, s: Vec3.I32): { readonly counts: Vec3.U32; sums: Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))), }); -const integers = System.create({ kernel: true, components: { read: [bits], write: [counts, sums] } }, row => { +const integers = System.create({ kernel: true, components: { with: [bits, counts, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[counts] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))); diff --git a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts index 14fb4ce7..d7abfd87 100644 --- a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts @@ -32,14 +32,14 @@ const seed = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn([value, at, note])({ [value]: 0, [at]: i })); }); -const raise = System.create({ kernel: "gpu", components: { write: [value] } }, row => { +const raise = System.create({ kernel: "gpu", components: { with: [value] } }, row => { row[value] = row[value] + 1; }); -const setValue = System.create({ target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { +const setValue = System.create({ target: "entity", components: { with: [value] } }, (row, _context, input: { readonly entity: Entity }) => { row[value] += 1; }); let found: number[] = []; -const there = System.create({ components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const there = System.create({ components: { with: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts index 82db1534..9bde1968 100644 --- a/packages/ecs/src/implementation/world/change-blocks.fixture.ts +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -28,18 +28,18 @@ const seed = System.create({}, ({ commands }) => { }); /** Writes the rows whose `at` is one of three, stamping row by row. */ -const writeSome = System.create({ kernel: true, components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { +const writeSome = System.create({ kernel: true, components: { with: [at, value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c) row[value] += 1; }); /** Counts each run of the rows whose value changed. */ -const watch = System.create({ kernel: true, components: { write: [seen], changed: [value] } }, row => { +const watch = System.create({ kernel: true, components: { with: [seen], changed: [value] } }, row => { row[seen] += 1; }); let found: number[] = []; -const onMain = System.create({ components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); +const onMain = System.create({ components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); /** * The rows a kernel on workers and a system on the world's thread find after a kernel on workers wrote three rows far diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index e19ef0ae..d3494b25 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -59,7 +59,7 @@ const seed = System.create({}, ({ commands }) => { }); // Writers. -const setValue = System.create({ target: "entity", components: { write: [value] } }, (row, _context, input: { readonly entity: Entity }) => { +const setValue = System.create({ target: "entity", components: { with: [value] } }, (row, _context, input: { readonly entity: Entity }) => { row[value] += 1; }); const replace = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { @@ -75,25 +75,25 @@ const spawnOne = System.create({}, ({ commands }, input: { readonly at: number } commands.spawn(plain)({ [value]: 0, [at]: input.at, [seen]: 0, [group]: 0 }); }); /** Writes the rows whose `at` is in a list of up to four, which stamps row by row. */ -const writeSome = System.create({ kernel: true, components: { read: [at], write: [value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { +const writeSome = System.create({ kernel: true, components: { with: [at, value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { const here = row[at]; if (here === input.a || here === input.b || here === input.c || here === input.d) row[value] += 1; }); /** Writes every row, which stamps chunks and blocks once per call. */ -const writeAll = System.create({ kernel: true, components: { write: [value] } }, row => { +const writeAll = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] += 1; }); -const setGroup = System.create({ target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity }) => { +const setGroup = System.create({ target: "entity", components: { with: [group] } }, (row, _context, input: { readonly entity: Entity }) => { row[group] = 1; }); // Readers. let found: number[] = []; -const onMain = System.create({ components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); -const observed = System.create({ observer: true, components: { read: [at], changed: [value] } }, row => { found.push(row[at]); }); -const ofTwo = System.create({ components: { read: [at], changed: [group, value] } }, row => { found.push(row[at]); }); -const inKernel = System.create({ kernel: true, components: { write: [seen], changed: [value] } }, row => { row[seen] += 1; }); -const inUp = System.create({ components: { read: [at], changed: [value] }, up: { through: parent, read: [value] } }, row => { found.push(row[at]); }); +const onMain = System.create({ components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); +const observed = System.create({ observer: true, components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); +const ofTwo = System.create({ components: { with: [at], changed: [group, value] } }, row => { found.push(row[at]); }); +const inKernel = System.create({ kernel: true, components: { with: [seen], changed: [value] } }, row => { row[seen] += 1; }); +const inUp = System.create({ components: { with: [at], changed: [value] }, up: { through: parent } }, row => { found.push(row[at]); }); const byGroup = Index.create({ key: [group] }); let grouped: number[] = []; const inGroup = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { @@ -106,11 +106,11 @@ const seedNoted = System.create({}, ({ commands }) => { entities = []; for (let i = 0; i < rows; i++) entities.push(commands.spawn(noted)({ [value]: 0, [at]: i })); }); -const noteSome = System.create({ components: { read: [at], write: [note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { +const noteSome = System.create({ components: { with: [at, note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { if (input.ats.includes(row[at])) row[note] = { text: "noted" }; }); -const notesThere = System.create({ components: { read: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); -const valuesThere = System.create({ components: { read: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const notesThere = System.create({ components: { with: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const valuesThere = System.create({ components: { with: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); /** The `at` of the rows a main-thread reader ran, in order. */ const ranOn = async (world: World, reader: System): Promise => { @@ -264,8 +264,8 @@ const seedExtra = System.create({}, ({ commands }) => { const addExtra = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, extra, 1); }); -const addedExtra = System.create({ components: { read: [at], added: [extra] } }, row => { found.push(row[at]); }); -const raiseHeights = System.create({ kernel: true, components: { write: [height] }, up: { through: parent, read: [height] } }, (row, { up }) => { +const addedExtra = System.create({ components: { with: [at], added: [extra] } }, row => { found.push(row[at]); }); +const raiseHeights = System.create({ kernel: true, components: { with: [height] }, up: { through: parent } }, (row, { up }) => { row[height] = up === null ? 1 : up[height] + 1; }); -const heights = System.create({ components: { read: [at], changed: [height] } }, row => { found.push(row[at]); }); +const heights = System.create({ components: { with: [at], changed: [height] } }, row => { found.push(row[at]); }); diff --git a/packages/ecs/src/implementation/world/changes-finalizers.test.ts b/packages/ecs/src/implementation/world/changes-finalizers.test.ts index 86cbf676..1c14300d 100644 --- a/packages/ecs/src/implementation/world/changes-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/changes-finalizers.test.ts @@ -21,14 +21,14 @@ declare module "@adobe/ecs" { // A changes finalizer sees each sync point's structural changes to the components it watches, grouped by how // they changed, with values before and after: for persistence, replication and undo. -Finalizer.changes({ watch: [name, score], resources: { write: [journal] } }, (changes, { resources }) => { +Finalizer.changes({ watch: [name, score] }, (changes, { resources }) => { const lines = changes.groups.flatMap(group => group.entities.map(entity => `${changes.previous(entity, name) ?? changes.next(entity, name)}: +${group.added.length} -${group.removed.length} score ${changes.previous(entity, score)} → ${changes.next(entity, score)}`)); resources[journal] = [...resources[journal], ...lines]; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); -const retire = System.create({ components: { has: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); +const retire = System.create({ components: { with: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); it("records each sync point's structural changes", async () => { await using world = createWorld(); diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index 557f1ab7..81b25a3c 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -50,7 +50,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) */ const burst = System.create({ kernel: true, - components: { read: [index] }, + components: { with: [index] }, }, (row, { commands }, input: { readonly shift: number }) => { const i = row[index]; commands.add(row.entity, weight, -1); @@ -103,8 +103,8 @@ const targets = System.create({}, ({ commands }, input: { readonly count: number /** A spawn and an add for each row, then a read through its target, which throws at the last row, whose target is none. */ const failing = System.create({ kernel: true, - components: { read: [index, target] }, - lookup: { targets: { read: [index] } }, + components: { with: [index, target] }, + lookup: { targets: { with: [index] } }, }, (row, { lookup, commands }) => { commands.spawn([made])({ [made]: row[index] }); commands.add(row.entity, weight, row[index]); @@ -132,7 +132,7 @@ export const failed = (count: number): Promise => using(createWorld({ [w /** A spawn for every 64th row: each chunk uses one position of its block of eight and returns the rest. */ const sparse = System.create({ kernel: true, - components: { read: [index], not: [made] }, + components: { with: [index], not: [made] }, }, (row, { commands }) => { if (row[index] % 64 === 0) commands.spawn([made])({ [made]: row[index] }); }); @@ -156,7 +156,7 @@ export const returned = (count: number): Promise<{ readonly spawned: number; rea /** Object components from a kernel: left out, null, and a number, by add and by spawn. */ const objects = System.create({ kernel: true, - components: { read: [index] }, + components: { with: [index] }, }, (row, { commands }) => { const i = row[index]; commands.add(row.entity, [name, maybe, tally], { [maybe]: null, [tally]: i }); diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index 431f2897..f3cb70b3 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -51,7 +51,7 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Rule.create({ components: { has: [other, score] }, derive: [slot] }); +Rule.create({ components: { with: [other, score] }, derive: [slot] }); const unit = Archetype.create([position, score, tag]); const made = Archetype.create([score, tag]); @@ -66,17 +66,17 @@ const literals: Entity[] = []; const spawnLiterals = System.create({}, ({ commands }) => { for (let i = 0; i < 6; i++) literals.push(commands.spawn(i % 3 === 0 ? defaulted : i % 3 === 1 ? made : [score, label])({ [score]: i })); }); -const addLiterals = System.create({ components: { has: [score], not: [health] } }, (row, { commands }) => { +const addLiterals = System.create({ components: { with: [score], not: [health] } }, (row, { commands }) => { commands.add(row.entity, [health, label], { [label]: "x", [health]: 5 }); }); -const spawnFrom = System.create({ kernel: true, components: { read: [score], not: [tag, other] } }, (row, { commands }) => { +const spawnFrom = System.create({ kernel: true, components: { with: [score], not: [tag, other] } }, (row, { commands }) => { commands.spawn(made)({ [score]: row[score] + 10 }); }); -const mark = System.create({ kernel: true, components: { has: [tag], not: [other] } }, (row, { commands }) => { +const mark = System.create({ kernel: true, components: { with: [tag], not: [other] } }, (row, { commands }) => { commands.add(row.entity, marks, {}); }); -const unmark = System.create({ kernel: true, components: { has: [other] } }, (row, { commands }) => { +const unmark = System.create({ kernel: true, components: { with: [other] } }, (row, { commands }) => { commands.remove(row.entity, marks); }); const defaulted = Archetype.create([score, label, tag]); @@ -192,14 +192,14 @@ describe("literal values in a system's body", () => { }); /** Three adds of one component to each row, by systems ordered first to third, the second adding twice. */ -const first = System.create({ kernel: true, components: { has: [position] } }, (row, { commands }) => { +const first = System.create({ kernel: true, components: { with: [position] } }, (row, { commands }) => { commands.add(row.entity, score, 1); }); -const second = System.create({ components: { has: [position] }, after: [first] }, (row, { commands }) => { +const second = System.create({ components: { with: [position] }, after: [first] }, (row, { commands }) => { commands.add(row.entity, score, 2); commands.add(row.entity, score, 3); }); -const third = System.create({ kernel: true, components: { has: [position] }, after: [second] }, (row, { commands }) => { +const third = System.create({ kernel: true, components: { with: [position] }, after: [second] }, (row, { commands }) => { commands.add(row.entity, score, 4); }); @@ -258,8 +258,8 @@ describe("add and remove", () => { }); const added: Entity[] = []; const changed: Entity[] = []; - const watchAdded = System.create({ components: { has: [score], added: [tag] } }, row => { added.push(row.entity); }); - const watchChanged = System.create({ components: { has: [score], changed: [position] } }, row => { changed.push(row.entity); }); + const watchAdded = System.create({ components: { with: [score], added: [tag] } }, row => { added.push(row.entity); }); + const watchChanged = System.create({ components: { with: [score], changed: [position] } }, row => { changed.push(row.entity); }); const watch = System.create({ systems: [watchAdded, watchChanged] }); await world.run(watch); added.length = changed.length = 0; diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index 052777bd..631c26aa 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; @@ -27,11 +28,12 @@ describe("composite input", () => { it("rejects a body taking input without the schema the plugin gives from its type, as one not written in place has", () => { const counting = (_context: unknown, input: { readonly count: number }): void => { void input.count; }; - expect(() => System.create({}, counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with Compiler.plugin().'); + expect(() => System.create({}, counting)).toThrow("has no access the build inferred from its body"); + expect(() => System.create(giveAccess({}), counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with Compiler.plugin().'); }); it("rejects an input schema that isn't an object of named fields", () => { const create: (...parameters: unknown[]) => unknown = System.create; - expect(() => create({}, (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); + expect(() => create(giveAccess({}), (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); }); }); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index 00c32c58..f3000359 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -30,14 +30,14 @@ declare module "@adobe/ecs" { } -const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); -const tick = System.create({ resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); -const afterTick = System.create({ resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); +const pause = System.create({ }, ({ resources }) => { resources[paused] = true; }); +const tick = System.create({ resources: { not: [paused] } }, ({ resources }) => { resources[ticks] += 1; }); +const afterTick = System.create({ after: [tick] }, ({ resources }) => { resources[after] += 1; }); const frame = System.create({ systems: [tick, afterTick] }); -const grow = System.create({ kernel: true, components: { write: [value] }, resources: { not: [paused] } }, row => { +const grow = System.create({ kernel: true, components: { with: [value] }, resources: { not: [paused] } }, row => { row[value] = row[value] + 1; }); -const growWhilePaused = System.create({ kernel: true, components: { write: [value] }, resources: { is: [paused] } }, row => { +const growWhilePaused = System.create({ kernel: true, components: { with: [value] }, resources: { is: [paused] } }, row => { row[value] = row[value] + 1; }); @@ -53,10 +53,10 @@ describe("resource conditions", () => { it("read an `is` condition as holding", async () => { await using world = createWorld({ [workers]: 0 }); - const measure = System.create({ resources: { is: [level], write: [sizes] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); + const measure = System.create({ resources: { is: [level] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); await world.run(measure); expect(await readResource(world, sizes)).toBe(0); - const load = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 3 }; }); + const load = System.create({ }, ({ resources }) => { resources[level] = { size: 3 }; }); await world.run(load); await world.run(measure); expect(await readResource(world, sizes)).toBe(3); @@ -65,12 +65,12 @@ describe("resource conditions", () => { it("keep a skipped system's lastRun, so it sees every change once it runs again", async () => { await using world = createWorld({ [workers]: 0 }); const seen: number[] = []; - const watch = System.create({ components: { read: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); + const watch = System.create({ components: { with: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); await world.run(spawn); await world.run(watch); expect(seen).toEqual([]); - await world.run(System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 1 }; })); + await world.run(System.create({ }, ({ resources }) => { resources[level] = { size: 1 }; })); await world.run(watch); expect(seen).toEqual([7]); }); @@ -78,7 +78,7 @@ describe("resource conditions", () => { it("leave a reduced resource as it was when every contributor is skipped", async () => { await using world = createWorld({ [workers]: 0 }); let next = 5; - const add = System.create({ resources: { not: [paused], reduce: [total] } }, ({ reduce }) => { reduce[total](next++); }); + const add = System.create({ resources: { not: [paused] } }, ({ reduce }) => { reduce[total](next++); }); await world.run(add); expect(await readResource(world, total)).toBe(5); await world.run(pause); @@ -91,8 +91,6 @@ describe("resource conditions", () => { expect(() => System.create({ resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); // @ts-expect-error both is and not expect(() => System.create({ resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); - // @ts-expect-error is already makes it readable - expect(() => System.create({ resources: { is: [level], read: [level] } }, ({ resources }) => { void resources[level]; })).toThrow("is already makes it readable"); }); it("skip a kernel the same way", async () => { await using world = createWorld({ [workers]: 0 }); diff --git a/packages/ecs/src/implementation/world/constant-unions.test.ts b/packages/ecs/src/implementation/world/constant-unions.test.ts index 3476baa4..26faae78 100644 --- a/packages/ecs/src/implementation/world/constant-unions.test.ts +++ b/packages/ecs/src/implementation/world/constant-unions.test.ts @@ -23,7 +23,7 @@ const spawn = System.create({}, ({ commands }) => { commands.spawn([frame, shown])({ [frame]: Frame.autoWidth, [shown]: null }); }); // Reads the union beside an object of the main thread: the union is a number in shared memory, on no thread. -const show = System.create({ components: { read: [frame], write: [shown] } }, row => { +const show = System.create({ components: { with: [frame, shown] } }, row => { row[shown] = { text: `frame ${row[frame]}` }; }); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index d2e6eb94..ee3a1fc6 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; import { Entity } from "../../interface/entity/entity.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; @@ -38,13 +39,13 @@ declare module "@adobe/ecs" { const spawn = System.create({ - resources: { read: [spawnCount], changed: [spawnCount] }, + resources: { changed: [spawnCount] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[spawnCount]; i++) commands.spawn([position, velocity, label])({ [position]: [i, 0], [velocity]: 1, [label]: `e${i}` }); }); const move = System.create({ - components: { read: [velocity], write: [position], not: [frozen] }, + components: { with: [velocity, position], not: [frozen] }, after: [spawn], }, row => { const [x, y] = row[position]; @@ -54,7 +55,6 @@ const move = System.create({ /** Counts rows whose position may have changed since it last ran. */ const watch = System.create({ components: { changed: [position] }, - resources: { write: [seen] }, after: [move], }, (_, { resources }) => { resources[seen] += 1; @@ -62,7 +62,7 @@ const watch = System.create({ /** A kernel whose module can't be imported. */ const missing = attachKernel( - System.create({ kernel: true, components: { has: [velocity] } }, () => {}), + System.create({ kernel: true, components: { with: [velocity] } }, () => {}), { columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "/@missing/kernel.js", program: null, reductions: [], reducedShapes: [] }, ); @@ -180,14 +180,15 @@ describe("World.create", () => { }); it("throws at creating a composite with an unordered conflict, naming nothing", () => { - const push = System.create({ components: { write: [position] } }, row => { row[position] = [0, 0]; }); + const push = System.create({ components: { with: [position] } }, row => { row[position] = [0, 0]; }); expect(() => System.create({ systems: [move, push] })).toThrow('"move" and "push"'); expect(() => System.create({ systems: [move] })).not.toThrow(); }); - it("gives a body's context only the kinds its declaration declares", async () => { + it("gives a body's context only the kinds its body uses", async () => { let keys: string[] = []; - const probe = System.create({ resources: { read: [seen] } }, context => { + // It passes its context on, so the build can't follow it, and it's given what it reads. + const probe = System.create(giveAccess({}, { resources: { read: [seen] } }), context => { keys = Object.keys(context); void context.resources[seen]; }); @@ -196,12 +197,6 @@ describe("World.create", () => { expect(keys).toEqual(["resources", "commands"]); }); - it("rejects a key listed in both read and write, since write already makes it readable", () => { - // @ts-expect-error write already makes it readable - expect(() => System.create({ components: { read: [velocity], write: [velocity] } }, row => { row[velocity] += 1; })) - .toThrow(`lists "createTest/velocity" in both components read and write`); - }); - it("reports a system that throws, still applying its commands", async () => { const fail = System.create({}, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); @@ -251,8 +246,8 @@ describe("World.create", () => { it("skips every row when a system's only filter is an unchanged resource", async () => { const count = System.create({ - components: { has: [velocity] }, - resources: { write: [seen], changed: [spawnCount] }, + components: { with: [velocity] }, + resources: { changed: [spawnCount] }, }, (_, { resources }) => { resources[seen] += 1; }); @@ -289,11 +284,11 @@ describe("World.create", () => { if (input.action === "spawn") for (let i = 0; i < 2100; i++) spawned.push(commands.spawn([velocity])({ [velocity]: i })); else commands.despawn(spawned[5]); }); - const writer = System.create({ components: { write: [velocity] } }, row => { + const writer = System.create({ components: { with: [velocity] } }, row => { if (row[velocity] === 2099) row[velocity] = -1; }); const changes: number[] = []; - const observer = System.create({ components: { read: [velocity], changed: [velocity] } }, row => { + const observer = System.create({ components: { with: [velocity], changed: [velocity] } }, row => { changes.push(row[velocity]); }); const world = createWorld(); @@ -350,13 +345,13 @@ describe("World.create", () => { await using world = createWorld(); expect(await readResource(world, gpuDevice)).toBeNull(); let seen: unknown = undefined; - await world.run(System.create({ resources: { read: [gpuDevice] } }, ({ resources }) => { seen = resources[gpuDevice]; })); + await world.run(System.create({ }, ({ resources }) => { seen = resources[gpuDevice]; })); expect(seen).toBeNull(); }); it("accepts a system declared again the same under its id, as a module evaluated again declares it, and rejects a different one", async () => { - const first = System.create(identify({ resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); - const again = System.create(identify({ resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); + const first = System.create(identify({ }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); + const again = System.create(identify({ }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); expect(() => System.create(identify({}, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); await using world = createWorld(); await world.run(first); diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts index 4c46539e..0532e731 100644 --- a/packages/ecs/src/implementation/world/dispatch.fixture.ts +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -42,17 +42,17 @@ const scatter = System.create({}, ({ commands }) => { const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); }); -const fastA = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { +const fastA = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; row[position] = [x + vx, y + vy]; }); // Commands make it a JS kernel, dispatched by message. -const slowB = System.create({ kernel: true, components: { read: [velocity] } }, (row, { commands }) => { +const slowB = System.create({ kernel: true, components: { with: [velocity] } }, (row, { commands }) => { const [vx] = row[velocity]; if (vx > 100) commands.despawn(row.entity); }); -const fastC = System.create({ kernel: true, components: { read: [velocity], write: [offset] } }, row => { +const fastC = System.create({ kernel: true, components: { with: [velocity, offset] } }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -64,11 +64,11 @@ const grow = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 1], [velocity]: [2, i % 3], [offset]: 0 }); }); let observedRows = 0; -const bump = System.create({ kernel: true, components: { write: [offset] } }, row => { +const bump = System.create({ kernel: true, components: { with: [offset] } }, row => { row[offset] = row[offset] + 1; }); const watch = System.create({ components: { changed: [offset] } }, () => { observedRows++; }); -const later = System.create({ kernel: true, components: { read: [velocity], write: [offset] }, after: [watch] }, row => { +const later = System.create({ kernel: true, components: { with: [velocity, offset] }, after: [watch] }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 37d41892..ff87d091 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -32,8 +32,8 @@ declare module "@adobe/ecs" { */ const watchKernel = System.create({ kernel: true, - components: { read: [follows], write: [kernelSeen] }, - lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, + components: { with: [follows, kernelSeen] }, + lookup: { found: { with: [weight], changed: [weight] }, marked: { with: [mark], changed: [mark] }, eligible: { with: [mark], not: [banned] } }, }, (row, { lookup }) => { const found = lookup.found(row[follows]); const marked = lookup.marked(row[follows]); @@ -44,8 +44,8 @@ const watchKernel = System.create({ }); const watchPlain = System.create({ - components: { read: [follows], write: [plainSeen] }, - lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, + components: { with: [follows, plainSeen] }, + lookup: { found: { with: [weight], changed: [weight] }, marked: { with: [mark], changed: [mark] }, eligible: { with: [mark], not: [banned] } }, }, (row, { lookup }) => { const found = lookup.found(row[follows]); const marked = lookup.marked(row[follows]); @@ -65,7 +65,7 @@ const seed = System.create({}, ({ commands }) => { }); /** Writes some weights, marks and unmarks some targets, despawns some and spawns others: a different change each round. */ -const stir = System.create({ components: { write: [weight] } }, (row, { commands }, input: { readonly round: number }) => { +const stir = System.create({ components: { with: [weight] } }, (row, { commands }, input: { readonly round: number }) => { const w = row[weight]; if ((w + input.round) % 5 === 0) row[weight] = w + 1000; if ((w + input.round) % 11 === 0) commands.add(row.entity, mark, 2); diff --git a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts index 4f8aacad..d334d6d4 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts @@ -7,7 +7,7 @@ import { createWorld, join, type CreatedWorld } from "./create.js"; import { edit, probe, scene, threadProbe, value } from "./entity-lookups.test-helper.js"; /** Writes every `value` on the GPU, which stamps its whole column at once. */ -const bumpOnGpu = System.create({ kernel: "gpu", components: { write: [value] } }, row => { +const bumpOnGpu = System.create({ kernel: "gpu", components: { with: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index 643c86ce..969bdc90 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -65,40 +65,40 @@ const seenOf = (subject: Entity, lookup: { /** On the world's thread. */ export const probe = System.create({ - components: { read: [target] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, + components: { with: [target] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, }, (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** The same, registered later, so its first run comes after every change. */ export const lateProbe = System.create({ - components: { read: [target] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, + components: { with: [target] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, }, (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** On thread "entityLookupsTest", as it reads the probe's label there. */ export const threadProbe = System.create({ - components: { read: [target, label] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, + components: { with: [target, label] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, }, (row, { lookup }) => { if (row[label]?.text === "probe") scene.seen.push(seenOf(row[target], lookup)); }); /** Writes every `value` in its rows, stamping them. */ -export const bump = System.create({ components: { write: [value] } }, row => { +export const bump = System.create({ components: { with: [value] } }, row => { row[value] += 1; }); /** The same as a kernel, whose stamps are muted unless something watches `value`. */ -export const bumpKernel = System.create({ kernel: true, components: { write: [value] } }, row => { +export const bumpKernel = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); /** Writes every `other`, which no lookup watches. */ -export const touch = System.create({ components: { write: [other] } }, row => { +export const touch = System.create({ components: { with: [other] } }, row => { row[other] += 1; }); @@ -124,7 +124,7 @@ export const edit = System.create({ }); /** Points every probe at the subject. */ -export const retarget = System.create({ components: { write: [target] } }, row => { +export const retarget = System.create({ components: { with: [target] } }, row => { row[target] = scene.subject; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test.ts b/packages/ecs/src/implementation/world/entity-lookups.test.ts index 068b3ec2..c07bd798 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test.ts @@ -13,10 +13,10 @@ import { runReported } from "./reports.test-helper.js"; /** What a finalizer's lookup gave as probes were despawned. */ const finalized: (boolean | null)[] = []; -Finalizer.create({ components: { read: [target] }, lookup: { found: { read: [value], changed: [value] } } }, (row, { lookup }) => { +Finalizer.create({ components: { with: [target] }, lookup: { found: { with: [value], changed: [value] } } }, (row, { lookup }) => { finalized.push(lookup.found(row[target])?.changed ?? null); }); -const despawnProbes = System.create({ components: { has: [target] } }, (row, { commands }) => { +const despawnProbes = System.create({ components: { with: [target] } }, (row, { commands }) => { commands.despawn(row.entity); }); diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index bbcce6c6..a0885d8b 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -54,7 +54,7 @@ declare module "@adobe/ecs" { } -Rule.create({ components: { has: [source] }, derive: [slot] }); +Rule.create({ components: { with: [source] }, derive: [slot] }); let steps: ((commands: System.Commands) => void)[] = []; const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { @@ -68,7 +68,7 @@ const step = (world: ReturnType) => (run: (commands: System. describe("finalizers", () => { it("run for each entity that stops matching, by any cause, reading its values before the sync point applies", async () => { const seen: [Entity, number][] = []; - Finalizer.create({ components: { read: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); + Finalizer.create({ components: { with: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); const spawned: Entity[] = []; @@ -92,7 +92,7 @@ describe("finalizers", () => { it("run for rules' and references' removals too, and at disposal for every live match", async () => { const seen: number[] = []; - Finalizer.create({ components: { read: [slot, source] } }, row => { seen.push(row[source]); }); + Finalizer.create({ components: { with: [slot, source] } }, row => { seen.push(row[source]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); let root = Entity.none, child = Entity.none; @@ -111,9 +111,8 @@ describe("finalizers", () => { it("write resources at the sync point, queue commands for the next one, and see lookups as they were", async () => { const found: (number | null)[] = []; Finalizer.create({ - components: { read: [counted] }, - lookup: { handles: { read: [counted] } }, - resources: { write: [released] }, + components: { with: [counted] }, + lookup: { handles: { with: [counted] } }, }, (row, { resources, lookup, commands }) => { resources[released] += 1; found.push(lookup.handles(row.entity)?.[counted] ?? null); @@ -134,11 +133,11 @@ describe("finalizers", () => { it("leave their commands for the next run's sync point, even when that run first adds resources", async () => { let other = Entity.none; - Finalizer.create({ components: { read: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); - const reads = System.create({ resources: { read: [level] } }, ({ resources }) => { void resources[level]; }); + Finalizer.create({ components: { with: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); + const reads = System.create({ }, ({ resources }) => { void resources[level]; }); // Read after every run, as a read through a run of its own would apply the commands left for it. let seen: string | null = null; - const watch = System.create({ observer: true, lookup: { labels: { read: [label] } } }, ({ lookup }) => { + const watch = System.create({ observer: true, lookup: { labels: { with: [label] } } }, ({ lookup }) => { seen = lookup.labels(other)?.[label] ?? null; }); const world = createWorld({ [workers]: 0 }); @@ -154,7 +153,7 @@ describe("finalizers", () => { }); it("keep reservations they make apart from the indexes their sync point frees", async () => { - Finalizer.create({ components: { read: [respawned] } }, (row, { commands }) => { + Finalizer.create({ components: { with: [respawned] } }, (row, { commands }) => { commands.spawn([respawned])({ [respawned]: row[respawned] + 100 }); }); const world = createWorld({ [workers]: 0 }); @@ -172,9 +171,9 @@ describe("finalizers", () => { it("run in their order, and a throw still applies the sync point and is reported", async () => { const order: string[] = []; - const second = Finalizer.create({ components: { read: [ordered] } }, () => { order.push("second"); }); - Finalizer.create({ components: { read: [ordered] }, before: [second] }, () => { order.push("first"); }); - const throwing = Finalizer.create({ components: { read: [ordered] } }, () => { throw new Error("finalizer failed"); }); + const second = Finalizer.create({ components: { with: [ordered] } }, () => { order.push("second"); }); + Finalizer.create({ components: { with: [ordered] }, before: [second] }, () => { order.push("first"); }); + const throwing = Finalizer.create({ components: { with: [ordered] } }, () => { throw new Error("finalizer failed"); }); void throwing; const world = createWorld({ [workers]: 0 }); const run = step(world); @@ -185,7 +184,7 @@ describe("finalizers", () => { expect(order).toEqual(["first", "second"]); expect(await read(world, a, handle)).toBeNull(); const writer = (name: "writerA" | "writerB") => - Finalizer.create({ components: { read: [writing] }, resources: { write: [written] } }, (_, { resources }) => { resources[written] = 1; }); + Finalizer.create({ components: { with: [writing] } }, (_, { resources }) => { resources[written] = 1; }); writer("writerA"); expect(() => writer("writerB")).toThrow("order them with before or after"); await world.dispose(); @@ -195,15 +194,15 @@ describe("finalizers", () => { describe("a finalizer declared again the same, as a module evaluated again declares it", () => { it("keeps its place in each world, and runs its new body once", async () => { const seen: string[] = []; - const first = Finalizer.create(identify({ components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); + const first = Finalizer.create(identify({ components: { with: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); const world = createWorld({ [workers]: 0 }); let entity = Entity.none; await step(world)(commands => { entity = commands.spawn([reloaded])({ [reloaded]: 1 }); }); - const again = Finalizer.create(identify({ components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); + const again = Finalizer.create(identify({ components: { with: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); expect(again).toBe(first); await step(world)(commands => { commands.despawn(entity); }); expect(seen).toEqual(["again"]); - expect(() => Finalizer.create(identify({ components: { read: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.create(identify({ components: { with: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); await world.dispose(); }); }); @@ -213,7 +212,7 @@ describe("a finalizer created after a world has entities it matches", () => { const world = createWorld({ [workers]: 0 }); await step(world)(commands => { commands.spawn([disposing])({ [disposing]: 4 }); }); const seen: number[] = []; - Finalizer.create({ components: { read: [disposing] }, resources: { read: [level] } }, row => { seen.push(row[disposing]); }); + Finalizer.create({ components: { with: [disposing] } }, row => { seen.push(row[disposing]); }); await world.dispose(); expect(seen).toEqual([4]); }); @@ -222,7 +221,7 @@ describe("a finalizer created after a world has entities it matches", () => { describe("changes finalizers", () => { it("watch by default every component that isn't derived, including ones first used later, and no resources", async () => { const seen: unknown[] = []; - Finalizer.changes({ resources: { read: [level] } }, (changes, { resources }) => { + Finalizer.changes({ }, (changes, { resources }) => { void resources[level]; for (const group of changes.groups) seen.push(group.to); }); diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 6f921e71..759da4b6 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -53,33 +53,33 @@ const spawn = System.create({}, ({ commands }) => { } }); -const move = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy * 2, pz + vz]; }); /** Between two GPU kernels: reads what the first wrote, and changes what both read. */ -const reset = System.create({ components: { write: [position, velocity] }, after: [move] }, row => { +const reset = System.create({ components: { with: [position, velocity] }, after: [move] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px * 2, py, pz + 100]; row[velocity] = [vz, vx, vy]; }); -const moveAgain = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] }, after: [reset] }, row => { +const moveAgain = System.create({ kernel: "gpu", components: { with: [velocity, position] }, after: [reset] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; }); /** Between two GPU kernels: changes only what they read, so nothing needs reading back first. */ -const turn = System.create({ components: { write: [velocity] }, after: [move] }, row => { +const turn = System.create({ components: { with: [velocity] }, after: [move] }, row => { const [vx, vy, vz] = row[velocity]; row[velocity] = [vz * 3, vx, vy]; }); -const moveLater = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] }, after: [turn] }, row => { +const moveLater = System.create({ kernel: "gpu", components: { with: [velocity, position] }, after: [turn] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; @@ -95,7 +95,7 @@ const spawnNumbers = System.create({}, ({ commands }) => { * JS semantics the GPU keeps: u32 values past 2^31, integer and bitwise chains bit for bit, infinities, rounding. * Arithmetic is f32, so only results f32 holds exactly are compared; WGSL lets implementations assume no value is NaN. */ -const numbers = System.create({ kernel: "gpu", components: { write: [code, amount] } }, row => { +const numbers = System.create({ kernel: "gpu", components: { with: [code, amount] } }, row => { const c = row[code]; const a = row[amount]; const mixed = (c ^ 0x0f0f0f0f) >>> 0; @@ -104,7 +104,7 @@ const numbers = System.create({ kernel: "gpu", components: { write: [code, amoun + (c | 0) / 0x1000000 + Math.clz32(c) + (c > 0x80000000 ? 10000 : 0) + Math.max(a, -1) * 100 + (a % 1.5) * 10000; }); -const flagScore = System.create({ kernel: "gpu", components: { read: [flag], write: [score] } }, row => { +const flagScore = System.create({ kernel: "gpu", components: { with: [flag, score] } }, row => { row[score] = row[flag] ? 1 : 0; }); @@ -115,8 +115,8 @@ const spawnFollower = System.create({}, ({ commands }) => { /** On the CPU, reads through a lookup what a GPU kernel wrote. */ const follow = System.create({ - components: { read: [followed], write: [score] }, - lookup: { leader: { read: [position] } }, + components: { with: [followed, score] }, + lookup: { leader: { with: [position] } }, after: [move], }, (row, { lookup: { leader } }) => { row[score] = leader(row[followed])?.[position][0] ?? -1; @@ -125,7 +125,7 @@ const follow = System.create({ const following = System.create({ systems: [move, follow] }); /** Despawns the first entity, moving the last into its row, then makes the sync point throw. */ -const poison = System.create({ components: { read: [position] } }, (row, { commands }) => { +const poison = System.create({ components: { with: [position] } }, (row, { commands }) => { if (row[position][0] === 2) commands.despawn(row.entity); if (row[position][0] === 4) commands.add(row.entity, score, Symbol("poison") as unknown as number); }); @@ -149,7 +149,7 @@ const fail = System.render({ shader: { accessors: { place: position } }, after: const failing = System.create({ systems: [move, fail] }); /** Math from three namespaces, inlined into a compute shader. */ -const transform = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); @@ -163,7 +163,7 @@ const spawnIntegers = System.create({}, ({ commands }) => { }); /** Integer builtins, which wrap and count bits as the functions do. */ -const integers = System.create({ kernel: "gpu", components: { write: [bits, sums] } }, row => { +const integers = System.create({ kernel: "gpu", components: { with: [bits, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[bits] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), Vec3.U32.bitwiseXor(b, [7, 7, 7])))); @@ -171,7 +171,7 @@ const integers = System.create({ kernel: "gpu", components: { write: [bits, sums }); /** Float builtins across vectors, quaternions and matrices, on in-domain inputs. */ -const shading = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const shading = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const p = row[position]; const v = Vec3.add(row[velocity], [0.25, 0.5, 0.75]); const n = Vec3.normalize(v); @@ -187,7 +187,7 @@ const shading = System.create({ kernel: "gpu", components: { read: [velocity], w }); /** Reads the entity, an f64. */ -const entityScore = System.create({ kernel: "gpu", components: { write: [score] } }, row => { +const entityScore = System.create({ kernel: "gpu", components: { with: [score] } }, row => { row[score] = row.entity; }); @@ -209,7 +209,7 @@ const positions = async (world: ReturnType): Promise (await readEach(world, await select(world, { required: [position] }), position)).map(value => value ?? [NaN, NaN, NaN]); let nextDevice: GPUDevice | null = null; -const setDevice = System.create({ resources: { write: [gpuDevice] } }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); +const setDevice = System.create({ }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); describe("removing GPU systems", () => { it("releases the GPU records of a render system and a GPU kernel once they're removed", async () => { @@ -264,7 +264,7 @@ describe("the gpuDevice resource", () => { const world = createWorld({ [workers]: 0, [gpuDevice]: lost }); const cpuWorld = createWorld({ [workers]: 0 }); let changes = 0; - const watchDevice = System.create({ resources: { read: [gpuDevice], changed: [gpuDevice] } }, ({ resources }) => { + const watchDevice = System.create({ resources: { changed: [gpuDevice] } }, ({ resources }) => { void resources[gpuDevice]; changes++; }); @@ -577,7 +577,7 @@ describe("render systems", () => { const own = await device(); const output = own.createBuffer({ size: count * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); const copy = System.render({ - components: { has: [tag] }, + components: { with: [tag] }, shader: { group: 0, accessors: { place: position } }, after: [move], }, ({ encoder, tables }) => { @@ -624,12 +624,12 @@ describe("render systems", () => { /** The x a spawned position reaches after one `move`, of the one row the nudges below write. */ const nudged = 3000; -const nudgeRow = System.create({ components: { write: [position] } }, row => { +const nudgeRow = System.create({ components: { with: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); -const nudgeKernel = System.create({ kernel: true, components: { write: [position] } }, row => { +const nudgeKernel = System.create({ kernel: true, components: { with: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); diff --git a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts index 192330fb..bea98081 100644 --- a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts +++ b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts @@ -15,7 +15,7 @@ declare module "@adobe/ecs" { } } -export const showing = Rule.create({ components: { has: [score] }, derive: [shown] }); +export const showing = Rule.create({ components: { with: [score] }, derive: [shown] }); export const recording = Finalizer.changes({ watch: [score] }, ({ groups }) => { for (const group of groups) changes.push(group.added); }); diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts index 86544a8f..b1898b4a 100644 --- a/packages/ecs/src/implementation/world/ids.test.ts +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -19,7 +19,7 @@ describe("a world given keys with ids", () => { const again: typeof import("./ids-keys.test-helper.js") = await import(/* @vite-ignore */ `./ids-keys.test-helper.js?again=${Date.now()}`); expect(again.score).not.toBe(score); const spawnAgain = System.create({}, ({ commands }) => { spawned.push(commands.spawn([again.score])({ [again.score]: 2 })); }); - const add = System.create({ components: { write: [again.score] } }, row => { row[again.score] += 10; }); + const add = System.create({ components: { with: [again.score] } }, row => { row[again.score] += 10; }); await world.run(spawnAgain); await world.run(add); const [first, second] = spawned; diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts index d2f55c2d..6c2246c2 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -20,13 +20,13 @@ const seed = System.create({}, ({ commands }) => { }); /** Spawns and adds through lists another module declares, naming one of their keys only through the list. */ -const ignite = System.create({ kernel: true, components: { read: [heat] } }, (row, { commands }) => { +const ignite = System.create({ kernel: true, components: { with: [heat] } }, (row, { commands }) => { commands.spawn(Spark)({ [ember]: row[heat] * 2 }); commands.add(row.entity, Lists.Marks, { [ember]: row[heat] }); }); /** Declares an imported list, and removes through one. */ -const cool = System.create({ kernel: true, components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { +const cool = System.create({ kernel: true, components: { with: [ember, ...Lists.Spark] } }, (row, { commands }) => { if (row[ember] > 4) commands.remove(row.entity, Spark); }); diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts index 6fd52648..4771273c 100644 --- a/packages/ecs/src/implementation/world/index-buckets.test.ts +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -31,7 +31,7 @@ declare module "@adobe/ecs" { } const groups = [0, 1, 2]; -const byGroup = Index.create({ key: [group], components: { has: [serial], not: [hidden] } }); +const byGroup = Index.create({ key: [group], components: { with: [serial], not: [hidden] } }); /** What a reader saw: each bucket's length, or each bucket's rows as entity index and serial, in order. */ let lengths: number[] = []; @@ -39,7 +39,7 @@ let rows: (readonly [number, number])[][] = []; const readLengths = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { lengths = groups.map(value => lookup.byGroup(value).length); }); -const readRows = System.create({ lookup: { byGroup: { index: byGroup, read: [serial] } } }, ({ lookup }) => { +const readRows = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { rows = groups.map(value => { const list = lookup.byGroup(value); const found: (readonly [number, number])[] = []; @@ -54,8 +54,8 @@ const readRows = System.create({ lookup: { byGroup: { index: byGroup, read: [ser /** Each reader row's bucket's serials, each times its position plus one: a sum that tells the order. */ const orderSum = System.create({ kernel: true, - components: { read: [reads], write: [sum] }, - lookup: { byGroup: { index: byGroup, read: [serial] } }, + components: { with: [reads, sum] }, + lookup: { byGroup: { index: byGroup } }, }, (row, { lookup }) => { const list = lookup.byGroup(row[reads]); let total = 0; @@ -63,8 +63,8 @@ const orderSum = System.create({ row[sum] = total; }); const orderSumByHand = System.create({ - components: { read: [reads], write: [sumByHand] }, - lookup: { byGroup: { index: byGroup, read: [serial] } }, + components: { with: [reads, sumByHand] }, + lookup: { byGroup: { index: byGroup } }, }, (row, { lookup }) => { const list = lookup.byGroup(row[reads]); let total = 0; @@ -72,7 +72,7 @@ const orderSumByHand = System.create({ row[sumByHand] = total; }); let sums: { kernel: number[]; byHand: number[] } = { kernel: [], byHand: [] }; -const readSums = System.create({ components: { read: [reads, sum, sumByHand] } }, row => { +const readSums = System.create({ components: { with: [reads, sum, sumByHand] } }, row => { sums.kernel[row[reads]] = row[sum]; sums.byHand[row[reads]] = row[sumByHand]; }); diff --git a/packages/ecs/src/implementation/world/index-build-csp.test.ts b/packages/ecs/src/implementation/world/index-build-csp.test.ts index 09d552f9..7144013a 100644 --- a/packages/ecs/src/implementation/world/index-build-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-build-csp.test.ts @@ -19,7 +19,7 @@ declare module "@adobe/ecs" { } /** A key of a pair of i32s and an f64, so the steps encode three kinds of leaf. */ -const bySpot = Index.create({ key: [spot, bulk], components: { has: [size] } }); +const bySpot = Index.create({ key: [spot, bulk], components: { with: [size] } }); const count = 70_000; describe("index builds in phases where code can't be generated", () => { @@ -33,14 +33,14 @@ describe("index builds in phases where code can't be generated", () => { for (let k = 0; k < count; k++) commands.spawn([spot, size, bulk])({ [spot]: [k % 37 - 18, k % 11], [size]: k, [bulk]: (k % 3) / 4 }); })); const expected = new Map(); - await world.run(System.create({ components: { read: [spot, size, bulk] } }, row => { + await world.run(System.create({ components: { with: [spot, size, bulk] } }, row => { const text = `${row[spot][0]},${row[spot][1]},${row[bulk]}`; const held = expected.get(text) ?? []; held.push(row[size]); expected.set(text, held); })); const found = new Map(); - await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { at: { index: bySpot } } }, ({ lookup }) => { for (const text of expected.keys()) { const [x, y, b] = text.split(",").map(Number); const list = lookup.at([x, y], b); diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts index 4c48d5bc..6043e9b5 100644 --- a/packages/ecs/src/implementation/world/index-build.fixture.ts +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -20,7 +20,7 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); const serials = Index.create({ key: [serial], unique: true }); const width = 300; @@ -35,13 +35,13 @@ const seed = System.create({}, ({ commands }) => { }); /** Moves every entity to another cell, so the next refresh builds the index again. */ -const shuffle = System.create({ components: { write: [cell] } }, row => { +const shuffle = System.create({ components: { with: [cell] } }, row => { const [x, y] = row[cell]; row[cell] = [(x * 7 + 3) % width, (y + x) % height]; }); /** A kernel, so the pool starts, as it does with a world's first kernel. */ -const warm = System.create({ kernel: true, components: { read: [serial], write: [weight] } }, row => { +const warm = System.create({ kernel: true, components: { with: [serial, weight] } }, row => { row[weight] = row[serial] % 13; }); @@ -49,7 +49,7 @@ const seen = { grid: 0, rows: 0, serials: 0 }; /** Every cell's rows, in order, folded into one number; and the unique index's row of each serial. */ const read = System.create({ - lookup: { near: { index: grid, read: [weight] }, bySerial: { index: serials } }, + lookup: { near: { index: grid }, bySerial: { index: serials } }, }, ({ lookup }) => { let fold = 0; let rows = 0; @@ -71,7 +71,7 @@ const read = System.create({ }); /** The same folds without an index: each cell's rows sorted by entity, and each serial's lowest entity. */ -const reference = System.create({ components: { read: [cell, serial, weight] } }, row => { +const reference = System.create({ components: { with: [cell, serial, weight] } }, row => { const [x, y] = row[cell]; const cellRows = byCell[x * height + y] ??= []; cellRows.push([row.entity, row[weight]]); diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index da9289b3..a639b430 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Index } from "../../interface/indexes/index-type.js"; @@ -26,28 +27,28 @@ declare module "@adobe/ecs" { } } -const byGroup = Index.create({ key: [group], components: { has: [weight] } }); +const byGroup = Index.create({ key: [group], components: { with: [weight] } }); const inOrder = Index.create({ key: [group], sorted: true }); -const weighted = Index.create({ key: [], components: { has: [weight] } }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); const one = { type: "object", properties: { entity: Entity.schema, value: { type: "number" } } } as const; let spawned: Entity[] = []; const spawn = System.create({}, ({ commands }, input: { readonly groups: readonly number[] }) => { for (const value of input.groups) spawned.push(commands.spawn([group, weight, total])({ [group]: value, [weight]: 1, [total]: 0 })); }); -const setGroup = System.create({ target: "entity", components: { write: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { +const setGroup = System.create({ target: "entity", components: { with: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[group] = input.value; }); -const setWeight = System.create({ target: "entity", components: { write: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { +const setWeight = System.create({ target: "entity", components: { with: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[weight] = input.value; }); -const despawn = System.create({ target: "entity", components: { has: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const despawn = System.create({ target: "entity", components: { with: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); -const dropWeight = System.create({ target: "entity", components: { has: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const dropWeight = System.create({ target: "entity", components: { with: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.remove(row.entity, weight); }); -const pause = System.create({ resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { +const pause = System.create({ }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); @@ -62,7 +63,7 @@ const counting = System.create({ lookup: { byGroup: { index: byGroup, changed: t /** Each row's total: how many rows share its group, by hundreds, and its weight; run only when the groups changed. */ const tally = System.create({ kernel: true, - components: { read: [group, weight], write: [total] }, + components: { with: [group, weight, total] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, (row, { lookup }) => { row[total] = lookup.byGroup(row[group]).length * 100 + row[weight]; @@ -131,7 +132,7 @@ describe("an index lookup's changed: true", () => { spawned = []; const flags: boolean[] = []; const watching = System.create({ - lookup: { byGroup: { index: byGroup, changed: true }, weights: { read: [weight], changed: [weight] } }, + lookup: { byGroup: { index: byGroup, changed: true }, weights: { with: [weight], changed: [weight] } }, }, ({ lookup }) => { flags.push(lookup.weights(spawned[0])?.changed ?? false); }); @@ -151,7 +152,7 @@ describe("an index lookup's changed: true", () => { spawned = []; const rows: number[] = []; const filtering = System.create({ - components: { read: [weight], changed: [weight] }, + components: { with: [weight], changed: [weight] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, row => { rows.push(row[weight]); @@ -204,7 +205,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const totals: number[] = []; - const readTotals = System.create({ observer: true, components: { read: [total] } }, row => { + const readTotals = System.create({ observer: true, components: { with: [total] } }, row => { totals.push(row[total]); }); const totalsOf = async (): Promise => { @@ -224,11 +225,11 @@ describe("an index lookup's changed: true", () => { }); it("is only for a system's index lookups", () => { - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); - const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [declaration, () => {}]); - expect(() => untypedSystem({ lookup: { weights: { read: [weight], changed: true } } })) + const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [giveAccess(declaration), () => {}]); + const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [giveAccess(declaration), () => {}]); + expect(() => untypedSystem({ lookup: { weights: { with: [weight], changed: true } } })) .toThrow(`lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); - expect(() => untypedFinalizer({ components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) + expect(() => untypedFinalizer({ components: { with: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) .toThrow(`lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); }); }); diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index f15ba613..97b673bc 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -55,19 +55,19 @@ const blank = { text: "" }; type Label = { readonly text: string }; const Label = { schema: { default: blank } }; -const grid = Index.create({ key: [cell], components: { has: [weight] } }); -const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); const lanes = Index.create({ key: [lane, rank], sorted: true }); const names = Index.create({ key: [name] }); const titles = Index.create({ key: [title] }); -const weighted = Index.create({ key: [], components: { has: [weight] } }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); /** Two long buckets, which changes leave unsorted until a list is read by position. */ -const bands = Index.create({ key: [band], components: { has: [serial] } }); +const bands = Index.create({ key: [band], components: { with: [serial] } }); /** On the joined thread: the serials of a band's rows, in entity order. */ const describeBand = System.create({ - components: { read: [target], write: [label], has: [bandReader] }, - lookup: { inBand: { index: bands, read: [serial] } }, + components: { with: [target, label, bandReader] }, + lookup: { inBand: { index: bands } }, }, (row, { lookup }) => { const list = lookup.inBand(row[target]); const serials: number[] = []; @@ -79,7 +79,7 @@ const seedBands = System.create({}, ({ commands }) => { for (let k = 0; k < 2; k++) commands.spawn([target, label, bandReader])({ [target]: k, [label]: blank }); }); /** Moves some entities to the other band, despawns some and spawns others. */ -const stirBands = System.create({ components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { +const stirBands = System.create({ components: { with: [serial, band] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); @@ -88,8 +88,8 @@ const stirBands = System.create({ components: { read: [serial], write: [band] } let observedBands: string[] = []; const observeBands = System.create({ observer: true, - components: { read: [target], has: [bandReader, label] }, - lookup: { inBand: { index: bands, read: [serial] } }, + components: { with: [target, bandReader, label] }, + lookup: { inBand: { index: bands } }, }, (row, { lookup }) => { const list = lookup.inBand(row[target]); const serials: number[] = []; @@ -121,7 +121,7 @@ const bandScenario = async (world: CreatedWorld, holder: World): Promise { +const shift = System.create({ components: { with: [serial, cell, label] } }, row => { if (row[serial] % 17 !== 0) return; const [x, y] = row[cell]; row[cell] = [(x + 1) % 13, y]; @@ -130,8 +130,8 @@ const shift = System.create({ components: { read: [serial], write: [cell, label] /** On the joined thread: each cell's neighbors' count and weights, whether its list changed, and its serial's target's weight. */ const describeCells = System.create({ - components: { read: [cell, target], write: [label] }, - lookup: { near: { index: grid, read: [weight], changed: [weight] }, bySerial: { index: serials, read: [weight] }, all: { index: weighted, read: [weight] } }, + components: { with: [cell, target, label] }, + lookup: { near: { index: grid, changed: [weight] }, bySerial: { index: serials }, all: { index: weighted } }, after: [shift], }, (row, { lookup }) => { const [x, y] = row[cell]; @@ -153,8 +153,8 @@ const describeCells = System.create({ /** On the joined thread: the rows of the row's lane within 5 of its rank, in key order. */ const describeRanks = System.create({ - components: { read: [lane, rank], write: [label] }, - lookup: { inLane: { index: lanes, read: [rank] } }, + components: { with: [lane, rank, label] }, + lookup: { inLane: { index: lanes } }, after: [describeCells], }, (row, { lookup }) => { const r = row[rank]; @@ -172,20 +172,20 @@ const seed = System.create({}, ({ commands }) => { }); /** Moves cells, changes weights and ranks, despawns some and spawns others. */ -const stir = System.create({ components: { read: [serial], write: [cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { +const stir = System.create({ components: { with: [serial, cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((row[serial] + input.round) % 9 === 0) row[cell] = [(x + 1) % 13, y]; if ((row[serial] + input.round) % 11 === 0) row[weight] = row[weight] + 1; if ((row[serial] + input.round) % 23 === 0) commands.despawn(row.entity); if (row[serial] % 50 === input.round) commands.spawn([cell, weight, serial, target, label])({ [cell]: [input.round, 3], [weight]: 2, [serial]: 1000 + row[serial], [target]: row[serial], [label]: blank }); }); -const rerank = System.create({ components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { +const rerank = System.create({ components: { with: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 5 === 0) row[rank] = (row[rank] * 3 + 1) % 97; }); /** A system of the joined thread looking up through an index of its strings, which the world keeps on its own thread. */ const byName = System.create({ - components: { read: [name], write: [label] }, + components: { with: [name, label] }, lookup: { named: { index: names } }, }, (row, { lookup }) => { row[label] = { text: String(lookup.named(row[name]).length) }; @@ -193,7 +193,7 @@ const byName = System.create({ /** On the joined thread, added and removed: labels each cell by its bucket's length. */ const counting = System.create({ - components: { read: [cell], write: [label] }, + components: { with: [cell, label] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { const [x, y] = row[cell]; @@ -203,30 +203,30 @@ const keepTicking = System.create({}, () => {}); const ranks = Index.create({ key: [rank], sorted: true }); /** Systems of the joined thread writing a string, and looking up by strings, which only the world's thread does. */ -const writeTitle = System.create({ components: { read: [label], write: [title] } }, row => { +const writeTitle = System.create({ components: { with: [label, title] } }, row => { row[title] = row[label].text; }); -const byTitle = System.create({ components: { read: [title], write: [label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { +const byTitle = System.create({ components: { with: [title, label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { row[label] = { text: String(lookup.titled(row[title]).length) }; }); /** A system of the joined thread whose rows are in an index's order, which runs only on the world's thread. */ -const inOrder = System.create({ components: { read: [rank], write: [label] }, order: ranks }, row => { +const inOrder = System.create({ components: { with: [rank, label] }, order: ranks }, row => { row[label] = { text: String(row[rank]) }; }); /** Systems on the joined thread and an observer system of its objects, each with an index as a change filter, which run only on the world's thread. */ -const countWhenMoved = System.create({ components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { +const countWhenMoved = System.create({ components: { with: [cell, label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { row[label] = { text: String(lookup.near(row[cell]).length) }; }); -const readWhenMoved = System.create({ observer: true, components: { read: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { +const readWhenMoved = System.create({ observer: true, components: { with: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { void row[label]; }); const ownWhenMoved = System.create({ observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); /** The entities `readLabels` reads, and their labels. */ const reading: { entities: readonly Entity[]; labels: readonly string[] } = { entities: [], labels: [] }; -const readLabels = System.create({ observer: true, lookup: { labeled: { read: [label] } } }, ({ lookup }) => { +const readLabels = System.create({ observer: true, lookup: { labeled: { with: [label] } } }, ({ lookup }) => { reading.labels = reading.entities.map(entity => lookup.labeled(entity)?.[label].text ?? "missing"); }); @@ -270,29 +270,29 @@ const seedHeld = System.create({}, ({ commands }, input: { readonly from: number for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, weight, label, title])({ [serial]: k, [weight]: k % 5, [label]: blank, [title]: `t${k}` }); }); /** On the joined thread: every label, by serial and round. */ -const relabelHeld = System.create({ components: { read: [serial], write: [label], has: [title] } }, (row, _context, input: { readonly round: number }) => { +const relabelHeld = System.create({ components: { with: [serial, label, title] } }, (row, _context, input: { readonly round: number }) => { row[label] = { text: `${row[serial]}.${input.round}` }; }); /** On the world's thread: every weight and title, by round. */ -const rewriteHeld = System.create({ components: { read: [serial], write: [weight, title] } }, (row, _context, input: { readonly round: number }) => { +const rewriteHeld = System.create({ components: { with: [serial, weight, title] } }, (row, _context, input: { readonly round: number }) => { row[weight] = row[serial] * 10 + input.round; row[title] = `t${row[serial]}.${input.round}`; }); /** Moves every held entity to the other archetype, marked on one run and not on the next. */ -const markHeld = System.create({ components: { has: [title], not: [marked] } }, (row, { commands }) => { +const markHeld = System.create({ components: { with: [title], not: [marked] } }, (row, { commands }) => { commands.add(row.entity, marked); }); -const unmarkHeld = System.create({ components: { has: [title, marked] } }, (row, { commands }) => { +const unmarkHeld = System.create({ components: { with: [title, marked] } }, (row, { commands }) => { commands.remove(row.entity, marked); }); -const held = Index.create({ key: [], components: { has: [serial, weight, label, title] } }); -const heldSerials = Index.create({ key: [serial], unique: true, components: { has: [weight, label, title] } }); +const held = Index.create({ key: [], components: { with: [serial, weight, label, title] } }); +const heldSerials = Index.create({ key: [serial], unique: true, components: { with: [weight, label, title] } }); /** What `observeHeld` read last. */ let observedHeld = ""; /** An observer system reading every held row through the keyless index, and serial 3's through the unique one. */ const observeHeld = System.create({ observer: true, - lookup: { all: { index: held, read: [serial, weight, label, title] }, bySerial: { index: heldSerials, read: [weight, label, title] } }, + lookup: { all: { index: held }, bySerial: { index: heldSerials } }, }, ({ lookup }) => { const all = lookup.all(); const read: string[] = []; diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index fdd507d3..68cb7c73 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -37,15 +37,15 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ key: [cell], components: { has: [weight] } }); -const lanes = Index.create({ key: [lane, rank], sorted: true, components: { has: [ranked] } }); -const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true, components: { with: [ranked] } }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ kernel: true, - components: { read: [cell], write: [total, count] }, - lookup: { near: { index: grid, read: [weight] } }, + components: { with: [cell, total, count] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; @@ -64,7 +64,7 @@ const neighbors = System.create({ }); /** The same, on the main thread, for comparison. */ -const neighborsByHand = System.create({ components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { +const neighborsByHand = System.create({ components: { with: [cell, total, count] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; let found = 0; @@ -82,7 +82,7 @@ const neighborsByHand = System.create({ components: { read: [cell], write: [tota /** How many rows of the row's lane have a rank within 5 of its own, and the first of them, through a sorted range. */ const window = System.create({ kernel: true, - components: { read: [lane, rank], write: [count, seen] }, + components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } }, }, (row, { lookup }) => { const r = row[rank]; @@ -91,7 +91,7 @@ const window = System.create({ row[seen] = near.at(0).entity; }); -const windowByHand = System.create({ components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { +const windowByHand = System.create({ components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { const r = row[rank]; const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); row[count] = near.length; @@ -101,8 +101,8 @@ const windowByHand = System.create({ components: { read: [lane, rank], write: [c /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ kernel: true, - components: { read: [target], write: [total] }, - lookup: { bySerial: { index: serials, read: [weight] } }, + components: { with: [target, total] }, + lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { const found = lookup.bySerial(row[target]); row[total] = found === null ? -1 : found[weight]; @@ -111,7 +111,7 @@ const follow = System.create({ /** Counts runs whose list of the row's cell changed. */ const watch = System.create({ kernel: true, - components: { read: [cell], write: [changes] }, + components: { with: [cell, changes] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { if (lookup.near([row[cell][0], row[cell][1]]).changed) row[changes] = row[changes] + 1; @@ -179,7 +179,7 @@ describe.each([[true], [false]])("kernels looking up through indexes, WASM %s", /** A comparison on a hash index. */ const compared = System.create({ kernel: true, - components: { read: [serial], write: [count] }, + components: { with: [serial, count] }, lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { // @ts-expect-error a hash index takes whole keys diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index 5f52e0c6..7190bd3b 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -30,9 +30,9 @@ declare module "@adobe/ecs" { } } -const shown = Index.create({ key: [], components: { has: [weight], not: [hidden] } }); +const shown = Index.create({ key: [], components: { with: [weight], not: [hidden] } }); const everything = Index.create({ key: [] }); -const inOrder = Index.create({ key: [], sorted: true, components: { has: [weight] } }); +const inOrder = Index.create({ key: [], sorted: true, components: { with: [weight] } }); /** What a lookup gave: its entities and weights, in its order, its length and whether it changed. */ type Seen = { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly length: number; readonly changed: boolean }; @@ -40,7 +40,7 @@ let seen: Seen = { entities: [], weights: [], length: 0, changed: false }; let all: readonly Entity[] = []; const look = System.create({ - lookup: { shown: { index: shown, read: [weight] }, all: { index: everything } }, + lookup: { shown: { index: shown }, all: { index: everything } }, }, ({ lookup }) => { const rows = lookup.shown(); const entities: Entity[] = []; @@ -114,7 +114,7 @@ describe("an index without a key on the main thread", () => { await world.run(look); expect(seen.changed).toBe(false); // A write to what it holds by, unwatched, or to anything else, changes nothing. - await world.run(System.create({ components: { write: [weight, color] } }, row => { + await world.run(System.create({ components: { with: [weight, color] } }, row => { row[weight] = row[weight] + 1; row[color] = 2; })); @@ -135,10 +135,10 @@ describe("an index without a key on the main thread", () => { await world.run(watchColor); await world.run(watchColor); expect(watchedChanged).toBe(false); - await world.run(System.create({ components: { write: [color] } }, row => { row[color] = 5; })); + await world.run(System.create({ components: { with: [color] } }, row => { row[color] = 5; })); await world.run(watchColor); expect(watchedChanged).toBe(false); - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = 5; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = 5; })); await world.run(watchColor); expect(watchedChanged).toBe(true); await world.run(watchColor); @@ -151,7 +151,7 @@ describe("an index without a key on the main thread", () => { // Moving the first to another archetype puts it last in storage order. await world.run(System.create(({ commands }) => { commands.add(entities[0], spin, 1); })); const order: Entity[] = []; - await world.run(System.create({ components: { read: [weight] }, order: inOrder }, row => { if (row[weight] > 0) order.push(row.entity); })); + await world.run(System.create({ components: { with: [weight] }, order: inOrder }, row => { if (row[weight] > 0) order.push(row.entity); })); expect(order).toEqual(entities); }); }); @@ -203,8 +203,8 @@ describe("an incrementally refreshed index without a key", () => { /** Each row's tally of the rows of `shown`: how many, and the sum of a window of their weights. */ const tally = System.create({ kernel: true, - components: { read: [color], write: [total, count] }, - lookup: { shown: { index: shown, read: [weight] } }, + components: { with: [color, total, count] }, + lookup: { shown: { index: shown } }, }, (row, { lookup }) => { const rows = lookup.shown(); let sum = 0; @@ -212,7 +212,7 @@ const tally = System.create({ row[total] = sum; row[count] = rows.length; }); -const tallyByHand = System.create({ components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } } }, (row, { lookup }) => { +const tallyByHand = System.create({ components: { with: [color, total, count] }, lookup: { shown: { index: shown } } }, (row, { lookup }) => { const rows = lookup.shown(); let sum = 0; for (let i = row[color] % 7; i < rows.length; i += 7) sum += rows.at(i)[weight]; @@ -222,7 +222,7 @@ const tallyByHand = System.create({ components: { read: [color], write: [total, /** Counts runs whose list changed, `weight` watched. */ const visit = System.create({ kernel: true, - components: { write: [visited] }, + components: { with: [visited] }, lookup: { shown: { index: shown, changed: [weight] } }, }, (row, { lookup }) => { if (lookup.shown().changed) row[visited] = row[visited] + 1; @@ -231,7 +231,7 @@ const visit = System.create({ const values = async (world: CreatedWorld, keys: readonly Component.Key[]): Promise => { let found: readonly unknown[] = []; const entities = [...await select(world, { required: [total] })].sort(byIndex); - await world.run(System.create({ lookup: { of: { read: [total, count, visited] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { of: { with: [total, count, visited] } } }, ({ lookup }) => { found = entities.map(entity => { const row = lookup.of(entity); return row === null ? null : keys.map(key => key === total ? row[total] : key === count ? row[count] : row[visited]); @@ -247,7 +247,7 @@ describe.each([[true], [false]])("kernels looking up through an index without a for (let k = 0; k < 300; k++) commands.spawn([weight, color, total, count, visited])({ [weight]: k % 11, [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); for (let k = 0; k < 40; k++) commands.spawn([color, total, count, visited])({ [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); })); - await world.run(System.create({ components: { read: [color] } }, (row, { commands }) => { + await world.run(System.create({ components: { with: [color] } }, (row, { commands }) => { if (row[color] % 5 === 0) commands.add(row.entity, hidden); if (row[color] % 9 === 0) commands.despawn(row.entity); })); @@ -261,10 +261,10 @@ describe.each([[true], [false]])("kernels looking up through an index without a const visits = async (): Promise => (await values(world, [visited])).reduce((sum, row) => sum + (Array.isArray(row) ? Number(row[0]) : 0), 0); const rows = (await values(world, [visited])).length; expect(await visits()).toBe(rows); - await world.run(System.create({ components: { write: [color] } }, row => { row[color] = row[color] + 1; })); + await world.run(System.create({ components: { with: [color] } }, row => { row[color] = row[color] + 1; })); await world.run(visit); expect(await visits()).toBe(rows); - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 1; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = row[weight] + 1; })); await world.run(visit); expect(await visits()).toBe(2 * rows); }); diff --git a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts index 96dc81b4..5597e5ab 100644 --- a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts @@ -15,7 +15,7 @@ declare module "@adobe/ecs" { } } -const bySpot = Index.create({ key: [spot], components: { has: [size] } }); +const bySpot = Index.create({ key: [spot], components: { with: [size] } }); const bySize = Index.create({ key: [size], sorted: true, unique: true }); describe("index lookups where code can't be generated", () => { @@ -29,7 +29,7 @@ describe("index lookups where code can't be generated", () => { for (let k = 0; k < 6; k++) commands.spawn([spot, size])({ [spot]: [k % 2, 0], [size]: k }); })); let found: number[] = []; - await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] }, sized: { index: bySize, read: [size] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { at: { index: bySpot }, sized: { index: bySize } } }, ({ lookup }) => { const list = lookup.at([1, 0]); const range = lookup.sized({ ">": 1, "<=": 4 }); found = [...Array.from({ length: list.length }, (_, i) => list.at(i)[size]), -1, ...Array.from({ length: range.length }, (_, i) => range.at(i)[size]), -1, lookup.sized(5)?.[size] ?? -2]; diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index 600b8577..4f5a0963 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -27,27 +27,27 @@ declare module "@adobe/ecs" { } /** By lane, then rank, of the entities that have a weight. */ -const ranked = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); +const ranked = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); /** Each row's lane and rank as one number, in the order rows ran. */ let order: number[] = []; -const visit = System.create({ components: { read: [lane, rank] }, order: ranked }, row => { +const visit = System.create({ components: { with: [lane, rank] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); -const visitChanged = System.create({ components: { read: [lane, rank], changed: [weight] }, order: ranked }, row => { +const visitChanged = System.create({ components: { with: [lane, rank], changed: [weight] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); /** A kernel spawning a marker for each row in turn, so the markers' entities, in order, are the rows' order. */ -const markEach = System.create({ kernel: true, components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { +const markEach = System.create({ kernel: true, components: { with: [lane, rank] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const markChanged = System.create({ kernel: true, components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { +const markChanged = System.create({ kernel: true, components: { with: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); const keepTicking = System.create({}, () => {}); /** Changes the weight of every row whose weight ends in 3, a tenth of them. */ -const nudge = System.create({ components: { write: [weight] } }, row => { +const nudge = System.create({ components: { with: [weight] } }, row => { if (row[weight] % 10 === 3) row[weight] = row[weight] + 1; }); @@ -72,7 +72,7 @@ const expected = async (world: CreatedWorld, only?: (weighed: number) => boolean }; const markerValues: number[] = []; -const readMarkers = System.create({ observer: true, components: { read: [marker] } }, row => { markerValues.push(row[marker]); }); +const readMarkers = System.create({ observer: true, components: { with: [marker] } }, row => { markerValues.push(row[marker]); }); /** Markers' values in the order they were spawned: their archetype's rows', in storage order. */ const markers = async (world: CreatedWorld): Promise => { @@ -100,7 +100,7 @@ describe("systems ordering their rows by a sorted index", () => { expect(order).toEqual(await expected(world, weighed => weighed % 10 === 4)); expect(order.length).toBeGreaterThan(100); // Most rows change: the index is scanned rather than the rows' positions sorted. - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 2; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = row[weight] + 2; })); order = []; await world.run(visitChanged); expect(order).toEqual(await expected(world)); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index ab436f71..af08231e 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -44,16 +44,16 @@ declare module "@adobe/ecs" { } } -const grid = Index.create({ key: [cell], components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); const lanes = Index.create({ key: [lane, rank], sorted: true }); -const serials = Index.create({ key: [serial], unique: true, components: { has: [weight] } }); -const weighted = Index.create({ key: [], components: { has: [weight] } }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ kernel: true, - components: { read: [cell], write: [total, count] }, - lookup: { near: { index: grid, read: [weight] } }, + components: { with: [cell, total, count] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; @@ -74,7 +74,7 @@ const neighbors = System.create({ /** Counts runs whose list of the row's own cell changed. */ const watch = System.create({ kernel: true, - components: { read: [cell], write: [changes] }, + components: { with: [cell, changes] }, lookup: { near: { index: grid, changed: [weight] } }, after: [neighbors], }, (row, { lookup }) => { @@ -85,7 +85,7 @@ const watch = System.create({ /** How many rows of the row's lane have a rank within 5 of its own, and the first of them. */ const window = System.create({ kernel: true, - components: { read: [lane, rank], write: [count, seen] }, + components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } }, after: [watch], }, (row, { lookup }) => { @@ -98,8 +98,8 @@ const window = System.create({ /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ kernel: true, - components: { read: [target], write: [total] }, - lookup: { bySerial: { index: serials, read: [weight] } }, + components: { with: [target, total] }, + lookup: { bySerial: { index: serials } }, after: [window], }, (row, { lookup }) => { const found = lookup.bySerial(row[target]); @@ -109,8 +109,8 @@ const follow = System.create({ /** Through an index without a key: how many entities it holds, a stride of their weights, and whether it changed. */ const census = System.create({ kernel: true, - components: { read: [target], write: [tally] }, - lookup: { weighted: { index: weighted, read: [weight], changed: [weight] } }, + components: { with: [target, tally] }, + lookup: { weighted: { index: weighted, changed: [weight] } }, after: [follow], }, (row, { lookup }) => { const all = lookup.weighted(); @@ -132,16 +132,16 @@ const seed = System.create({}, ({ commands }) => { }); /** Moves some cells, changes some weights and ranks, despawns some serials and spawns more: a different input each time. */ -const stir = System.create({ components: { write: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { +const stir = System.create({ components: { with: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((x + y + input.round) % 11 === 0) row[cell] = [(x + 1) % 31, y]; if ((x * y + input.round) % 13 === 0) row[weight] = row[weight] + 1; }); -const rerank = System.create({ components: { write: [rank] } }, (row, _context, input: { readonly round: number }) => { +const rerank = System.create({ components: { with: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; }); // As written: spawned on workers, entities would take ids in an order that depends on the pool, which the census samples by. -const churn = System.create({ kernel: false, components: { read: [serial] } }, (row, { commands }, input: { readonly round: number }) => { +const churn = System.create({ kernel: false, components: { with: [serial] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); if (row[serial] % 101 === input.round) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); @@ -188,13 +188,13 @@ export const simulate = async (threads: number, wasm: boolean): Promise<{ readon }; /** Two long buckets, which changes leave unsorted until something reads them by position. */ -const bands = Index.create({ key: [band], components: { has: [serial] } }); +const bands = Index.create({ key: [band], components: { with: [serial] } }); /** Each row's band's serials, each times its position plus one: a sum that tells the order. */ const bandOrder = System.create({ kernel: true, - components: { read: [target], write: [bandSum] }, - lookup: { inBand: { index: bands, read: [serial] } }, + components: { with: [target, bandSum] }, + lookup: { inBand: { index: bands } }, }, (row, { lookup }) => { const list = lookup.inBand(row[target] % 2); let sum = 0; @@ -202,8 +202,8 @@ const bandOrder = System.create({ row[bandSum] = sum; }); const bandOrderByHand = System.create({ - components: { read: [target], write: [bandSumByHand] }, - lookup: { inBand: { index: bands, read: [serial] } }, + components: { with: [target, bandSumByHand] }, + lookup: { inBand: { index: bands } }, }, (row, { lookup }) => { const list = lookup.inBand(row[target] % 2); let sum = 0; @@ -215,7 +215,7 @@ const seedBands = System.create({}, ({ commands }) => { for (let k = 0; k < 512; k++) commands.spawn([target, bandSum, bandSumByHand])({ [target]: k, [bandSum]: 0, [bandSumByHand]: 0 }); }); /** Moves some entities to the other band, despawns some and spawns others. */ -const stirBands = System.create({ components: { read: [serial], write: [band] } }, (row, { commands }, input: { readonly round: number }) => { +const stirBands = System.create({ components: { with: [serial, band] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index e3ea4b7a..c73eb84a 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -35,7 +35,7 @@ declare module "@adobe/ecs" { } -const grid = Index.create({ key: [cell], components: { has: [weight], not: [hidden] } }); +const grid = Index.create({ key: [cell], components: { with: [weight], not: [hidden] } }); const byServerId = Index.create({ key: [serverId], unique: true }); const byLabel = Index.create({ key: [label] }); const children = Index.create({ key: [owner] }); @@ -48,7 +48,7 @@ const spawnCells = System.create({}, ({ commands }, input: { readonly cells: rea let seen: { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly changed: boolean; readonly length: number } | null = null; const look = System.create({ - lookup: { near: { index: grid, read: [weight] } }, + lookup: { near: { index: grid } }, }, ({ lookup }, input: { readonly x: number; readonly y: number }) => { const near = lookup.near([input.x, input.y]); const entities: Entity[] = []; @@ -61,7 +61,7 @@ const look = System.create({ }); /** Moves every entity in cell [0, 0] to [1, 0], before `lookAfter` in the same run. */ -const shift = System.create({ components: { write: [cell] } }, row => { +const shift = System.create({ components: { with: [cell] } }, row => { if (row[cell][0] === 0 && row[cell][1] === 0) row[cell] = [1, 0]; }); let afterShift: number | null = null; @@ -203,7 +203,7 @@ describe("call sites", () => { await using world = createWorld({ [workers]: 0 }); await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 1]] }); let pairs: number[] = []; - const both = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const both = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { const a = lookup.near([0, 0]); const b = lookup.near([1, 1]); pairs = [a.length, b.length, a.at(0)[weight] + a.at(1)[weight], b.at(0)[weight]]; @@ -228,7 +228,7 @@ describe("call sites", () => { describe("finalizers", () => { const released: number[] = []; - Finalizer.create({ components: { read: [order, cell] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { + Finalizer.create({ components: { with: [order, cell] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { released.push(lookup.near(row[cell]).length); }); it("look up through indexes as they are before the sync point applies", async () => { @@ -256,7 +256,7 @@ describe("index lifetime", () => { await using world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); await world.run(spawnCells, { cells: [[0, 0], [1, 1]] }); // Ticks of what an index is keyed by stay once started, as any component's do. - await world.run(System.create({ components: { read: [cell], changed: [cell] } }, row => { void row[cell]; })); + await world.run(System.create({ components: { with: [cell], changed: [cell] } }, row => { void row[cell]; })); const before = await tablesAfterCollecting(world); // A system made for one run is collected, and its index with it. await world.run(System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; })); @@ -325,7 +325,7 @@ const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity /** A kernel folding the entities of its row's own cell's list, so kernels' lookups are checked against the same index. */ const foldNear = System.create({ kernel: true, - components: { read: [cell], write: [folded] }, + components: { with: [cell, folded] }, lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; @@ -337,7 +337,7 @@ const foldNear = System.create({ /** Moves each row whose weight is below `share` of 1000 to the cell `cell` gives it, so keys change during runs. */ const rekey = System.create({ - components: { read: [weight], write: [cell] }, before: [probe], + components: { with: [weight, cell] }, before: [probe], }, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[cell] = cells[(row[weight] + input.seed) % cells.length]; }); @@ -368,7 +368,7 @@ describe("an incrementally refreshed hash index", () => { })); // Now and then most rows' keys change, past what a patch takes. await world.run(System.create({ systems: [rekey, probe] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); - const scratch = Index.create({ key: [cell], components: { has: [weight], not: [hidden] } }); + const scratch = Index.create({ key: [cell], components: { with: [weight], not: [hidden] } }); let rebuilt: readonly (readonly number[])[] = []; await world.run(System.create({ lookup: { near: { index: scratch } } }, ({ lookup }) => { rebuilt = bucketsOf(key => lookup.near(key)); })); expect(incremental).toEqual(rebuilt); @@ -397,7 +397,7 @@ declare module "@adobe/ecs" { } const byRank = Index.create({ key: [rank], sorted: true }); -const byLaneRank = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); +const byLaneRank = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); const byName = Index.create({ key: [label], sorted: true, unique: true }); const byFlagPair = Index.create({ key: [flag, pair], sorted: true }); @@ -413,7 +413,7 @@ describe("sorted indexes on the main thread", () => { for (const value of input.ranks) commands.spawn([rank])({ [rank]: value }); }); const query = System.create({ - lookup: { ranks: { index: byRank, read: [rank] } }, + lookup: { ranks: { index: byRank } }, }, ({ lookup }, input: { readonly kind: string; readonly value: number; readonly other: number }) => { const { value, other } = input; switch (input.kind) { @@ -471,7 +471,7 @@ describe("sorted indexes on the main thread", () => { model.forEach(([laneValue, rankValue], k) => commands.spawn([lane, rank, weight])({ [lane]: laneValue, [rank]: rankValue, [weight]: k })); })); let found: (readonly (readonly number[])[])[] = []; - await world.run(System.create({ lookup: { lanes: { index: byLaneRank, read: [lane, rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { const read = (list: { readonly length: number; at(index: number): { readonly [lane]: number; readonly [rank]: number } }) => Array.from({ length: list.length }, (_, i) => [list.at(i)[lane], list.at(i)[rank]]); found = [read(lookup.lanes(0)), read(lookup.lanes(1, { "<": 10 })), read(lookup.lanes({ "<": 0 })), read(lookup.lanes(-1, 26)), read(lookup.lanes(-1, 27))]; })); @@ -494,7 +494,7 @@ describe("sorted indexes on the main thread", () => { let pairs: (readonly [boolean, readonly [number, number]])[] = []; let one: string | null = null; let none: string | null = "x"; - await world.run(System.create({ lookup: { names: { index: byName, read: [label] }, pairs: { index: byFlagPair, read: [flag, pair] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { names: { index: byName }, pairs: { index: byFlagPair } } }, ({ lookup }) => { const all = lookup.names(); names = Array.from({ length: all.length }, (_, i) => all.at(i)[label]); const ordered = lookup.pairs(); @@ -557,7 +557,7 @@ const probeSorted = System.create({ lookup: { lanes: { index: byLaneRank } } }, }); }); const rerank = System.create({ - components: { read: [weight], write: [rank] }, before: [probeSorted], + components: { with: [weight, rank] }, before: [probeSorted], }, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[rank] = (row[weight] * 31 + input.seed * 17) % 1000; }); @@ -565,7 +565,7 @@ const rerank = System.create({ /** A kernel folding the entities of its row's lane's range of ranks within 50 of its own, so kernels' sorted lookups are checked against the same index. */ const foldLane = System.create({ kernel: true, - components: { read: [lane, rank], write: [folded] }, + components: { with: [lane, rank, folded] }, lookup: { lanes: { index: byLaneRank } }, }, (row, { lookup }) => { const r = row[rank]; @@ -598,7 +598,7 @@ describe("an incrementally refreshed sorted index", () => { } })); await world.run(System.create({ systems: [rerank, probeSorted] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); - const scratch = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); + const scratch = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); let rebuiltRanks: readonly number[] = []; let rebuiltLanes: readonly (readonly number[])[] = []; await world.run(System.create({ lookup: { lanes: { index: scratch } } }, ({ lookup }) => { @@ -628,7 +628,7 @@ describe("an incrementally refreshed sorted index", () => { expect(kernel.map(values => values?.[folded] ?? -1)).toEqual(expected); held = Math.max(held, incrementalRanks.length); // Once its blocks have split, every entity of a lane goes, so whole blocks empty and are dropped. - if (step === 40) await world.run(System.create({ components: { read: [lane] } }, (row, { commands }) => { if (row[lane] < 1) commands.despawn(row.entity); })); + if (step === 40) await world.run(System.create({ components: { with: [lane] } }, (row, { commands }) => { if (row[lane] < 1) commands.despawn(row.entity); })); } expect(held).toBeGreaterThan(250); }); @@ -655,7 +655,7 @@ describe("lists and their rows", () => { })); let hashed: readonly object[] = []; let sorted: readonly object[] = []; - await world.run(System.create({ lookup: { near: { index: grid, read: [weight] }, ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { near: { index: grid }, ranks: { index: byRank } } }, ({ lookup }) => { const first = lookup.near([0, 0]); const second = lookup.near([1, 1]); hashed = [first, second, first.at(0), first.at(1), second.at(0)]; @@ -676,7 +676,7 @@ describe("a lookup's rows across sync points", () => { await using world = createWorld({ [workers]: 0 }); await world.run(spawnCells, { cells: [[0, 0], [0, 0]] }); let weights: number[] = []; - const read = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const read = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { const near = lookup.near([0, 0]); weights = Array.from({ length: near.length }, (_, i) => near.at(i)[weight]); }); @@ -684,7 +684,7 @@ describe("a lookup's rows across sync points", () => { expect(weights).toEqual([0, 1]); // Enough spawns into the same archetype to grow its arrays, and new weights for the two already read. await world.run(spawnCells, { cells: Array.from({ length: 200 }, () => [5, 5]) }); - await world.run(System.create({ components: { write: [weight], read: [cell] } }, row => { + await world.run(System.create({ components: { with: [cell, weight] } }, row => { if (row[cell][0] === 0) row[weight] += 10; })); await world.run(read); @@ -701,7 +701,7 @@ describe("a long list's rows", () => { })); let fromStart: number[] = []; let fromEnd: number[] = []; - await world.run(System.create({ lookup: { ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { ranks: { index: byRank } } }, ({ lookup }) => { const all = lookup.ranks(); // Every row held at once, then read. const held = Array.from({ length: all.length }, (_, i) => all.at(i)); diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index 7259a355..948db654 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -2,7 +2,7 @@ import type { IndexDefinition } from '../../interface/registry/index.js'; import { idOrNull, indexDefinitionOf, indexOfLookup, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Failure } from '../../interface/report/failure.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { readCell } from './cells.js'; import { blockShift, chunkShift } from './kernel.js'; @@ -1102,7 +1102,7 @@ const noFilters: readonly WorldIndex[] = []; const noLookups: IndexLookups = { byName: noIndexes, ordered: null, uses: noUses, filters: noFilters, release: () => {} }; /** Acquires the indexes a declaration's lookups go through, watching what they watch, once each per index. */ -export const acquireLookups = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined, order?: unknown): IndexLookups => { +export const acquireLookups = (state: State, lookup: { readonly [name: string]: SystemLookupLists } | undefined, order?: unknown): IndexLookups => { const ordering = indexDefinitionOf(order) ?? null; if (lookup === undefined && ordering === null) return noLookups; const byName = new Map(); diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts index 765ef0be..7e6a4284 100644 --- a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -149,7 +149,7 @@ const spawn = System.create({}, ({ commands }) => { } }); -const vec2f = System.create({ kernel: "gpu", components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2f = System.create({ kernel: "gpu", components: { with: [f2a, f2b, f2p, f2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; const p = row[f2p]; @@ -194,7 +194,7 @@ const vec2f = System.create({ kernel: "gpu", components: { read: [f2a, f2b, f2p] else if (op === 38) row[f2o] = Vec2.faceforward(a, b, p); }); -const vec2fScalar = System.create({ kernel: "gpu", components: { read: [f2a, f2b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2fScalar = System.create({ kernel: "gpu", components: { with: [f2a, f2b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; if (op === 0) row[fo] = Vec2.dot(a, b); @@ -202,7 +202,7 @@ const vec2fScalar = System.create({ kernel: "gpu", components: { read: [f2a, f2b else if (op === 2) row[fo] = Vec2.length(a); }); -const vec2i = System.create({ kernel: "gpu", components: { read: [i2a, i2b], write: [i2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2i = System.create({ kernel: "gpu", components: { with: [i2a, i2b, i2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i2a]; const b = row[i2b]; if (op === 0) row[i2o] = Vec2.I32.add(a, b); @@ -223,7 +223,7 @@ const vec2i = System.create({ kernel: "gpu", components: { read: [i2a, i2b], wri else if (op === 15) row[i2o] = Vec2.I32.clamp(a, [-1000, -1000], [1000, 1000]); }); -const vec2u = System.create({ kernel: "gpu", components: { read: [u2a, u2b], write: [u2o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec2u = System.create({ kernel: "gpu", components: { with: [u2a, u2b, u2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u2a]; const b = row[u2b]; if (op === 0) row[u2o] = Vec2.U32.add(a, b); @@ -242,7 +242,7 @@ const vec2u = System.create({ kernel: "gpu", components: { read: [u2a, u2b], wri else if (op === 13) row[u2o] = Vec2.U32.clamp(a, [1000, 1000], [100000, 100000]); }); -const vec3f = System.create({ kernel: "gpu", components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3f = System.create({ kernel: "gpu", components: { with: [f3a, f3b, f3p, f3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; const p = row[f3p]; @@ -289,7 +289,7 @@ const vec3f = System.create({ kernel: "gpu", components: { read: [f3a, f3b, f3p] else if (op === 40) row[f3o] = Vec3.faceforward(a, b, p); }); -const vec3fScalar = System.create({ kernel: "gpu", components: { read: [f3a, f3b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3fScalar = System.create({ kernel: "gpu", components: { with: [f3a, f3b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; if (op === 0) row[fo] = Vec3.dot(a, b); @@ -297,7 +297,7 @@ const vec3fScalar = System.create({ kernel: "gpu", components: { read: [f3a, f3b else if (op === 2) row[fo] = Vec3.length(a); }); -const vec3i = System.create({ kernel: "gpu", components: { read: [i3a, i3b], write: [i3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3i = System.create({ kernel: "gpu", components: { with: [i3a, i3b, i3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i3a]; const b = row[i3b]; if (op === 0) row[i3o] = Vec3.I32.add(a, b); @@ -319,7 +319,7 @@ const vec3i = System.create({ kernel: "gpu", components: { read: [i3a, i3b], wri else if (op === 16) row[i3o] = Vec3.I32.clamp(a, [-1000, -1000, -1000], [1000, 1000, 1000]); }); -const vec3u = System.create({ kernel: "gpu", components: { read: [u3a, u3b], write: [u3o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec3u = System.create({ kernel: "gpu", components: { with: [u3a, u3b, u3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u3a]; const b = row[u3b]; if (op === 0) row[u3o] = Vec3.U32.add(a, b); @@ -339,7 +339,7 @@ const vec3u = System.create({ kernel: "gpu", components: { read: [u3a, u3b], wri else if (op === 14) row[u3o] = Vec3.U32.clamp(a, [1000, 1000, 1000], [100000, 100000, 100000]); }); -const vec4f = System.create({ kernel: "gpu", components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4f = System.create({ kernel: "gpu", components: { with: [f4a, f4b, f4p, f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; const p = row[f4p]; @@ -385,7 +385,7 @@ const vec4f = System.create({ kernel: "gpu", components: { read: [f4a, f4b, f4p] else if (op === 39) row[f4o] = Vec4.faceforward(a, b, p); }); -const vec4fScalar = System.create({ kernel: "gpu", components: { read: [f4a, f4b], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4fScalar = System.create({ kernel: "gpu", components: { with: [f4a, f4b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; if (op === 0) row[fo] = Vec4.dot(a, b); @@ -393,7 +393,7 @@ const vec4fScalar = System.create({ kernel: "gpu", components: { read: [f4a, f4b else if (op === 2) row[fo] = Vec4.length(a); }); -const vec4i = System.create({ kernel: "gpu", components: { read: [i4a, i4b], write: [i4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4i = System.create({ kernel: "gpu", components: { with: [i4a, i4b, i4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i4a]; const b = row[i4b]; if (op === 0) row[i4o] = Vec4.I32.add(a, b); @@ -415,7 +415,7 @@ const vec4i = System.create({ kernel: "gpu", components: { read: [i4a, i4b], wri else if (op === 16) row[i4o] = Vec4.I32.clamp(a, [-1000, -1000, -1000, -1000], [1000, 1000, 1000, 1000]); }); -const vec4u = System.create({ kernel: "gpu", components: { read: [u4a, u4b], write: [u4o] } }, (row, _context, { op }: { readonly op: number }) => { +const vec4u = System.create({ kernel: "gpu", components: { with: [u4a, u4b, u4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u4a]; const b = row[u4b]; if (op === 0) row[u4o] = Vec4.U32.add(a, b); @@ -435,7 +435,7 @@ const vec4u = System.create({ kernel: "gpu", components: { read: [u4a, u4b], wri else if (op === 14) row[u4o] = Vec4.U32.clamp(a, [1000, 1000, 1000, 1000], [100000, 100000, 100000, 100000]); }); -const mat4x4 = System.create({ kernel: "gpu", components: { read: [ma, mb], write: [mo] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4 = System.create({ kernel: "gpu", components: { with: [ma, mb, mo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[mb]; if (op === 0) row[mo] = Mat4x4.multiply(a, b); @@ -445,18 +445,18 @@ const mat4x4 = System.create({ kernel: "gpu", components: { read: [ma, mb], writ else if (op === 4) row[mo] = Mat4x4.scale(a, 0.5); }); -const mat4x4Vector = System.create({ kernel: "gpu", components: { read: [ma, f4a], write: [f4o] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4Vector = System.create({ kernel: "gpu", components: { with: [ma, f4a, f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[f4a]; if (op === 0) row[f4o] = Mat4x4.multiplyVec4(a, b); }); -const mat4x4Scalar = System.create({ kernel: "gpu", components: { read: [ma], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const mat4x4Scalar = System.create({ kernel: "gpu", components: { with: [ma, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; if (op === 0) row[fo] = Mat4x4.determinant(a); }); -const quat = System.create({ kernel: "gpu", components: { read: [qa, qb], write: [qo] } }, (row, _context, { op }: { readonly op: number }) => { +const quat = System.create({ kernel: "gpu", components: { with: [qa, qb, qo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[qo] = Quat.add(a, b); @@ -465,14 +465,14 @@ const quat = System.create({ kernel: "gpu", components: { read: [qa, qb], write: else if (op === 3) row[qo] = Quat.negate(a); }); -const quatScalar = System.create({ kernel: "gpu", components: { read: [qa, qb], write: [fo] } }, (row, _context, { op }: { readonly op: number }) => { +const quatScalar = System.create({ kernel: "gpu", components: { with: [qa, qb, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[fo] = Quat.dot(a, b); else if (op === 1) row[fo] = Quat.length(a); }); -const u32 = System.create({ kernel: "gpu", components: { read: [ua], write: [uo] } }, (row, _context, { op }: { readonly op: number }) => { +const u32 = System.create({ kernel: "gpu", components: { with: [ua, uo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ua]; if (op === 0) row[uo] = U32.countLeadingZeros(a); else if (op === 1) row[uo] = U32.countOneBits(a); diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index a95dab38..dab80092 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -31,17 +31,17 @@ declare module "@adobe/ecs" { } /** Adds its struct input to `total`, and its object input's names to `names`. */ -const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const append = System.create({ resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { +const append = System.create({ }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); /** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ -const tally = System.create({ resources: { write: [counts] } }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { +const tally = System.create({ }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { const next = new Map(resources[counts]); for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); resources[counts] = next; diff --git a/packages/ecs/src/implementation/world/joined-observer.test.ts b/packages/ecs/src/implementation/world/joined-observer.test.ts index 00c0a0eb..8857636f 100644 --- a/packages/ecs/src/implementation/world/joined-observer.test.ts +++ b/packages/ecs/src/implementation/world/joined-observer.test.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { /** What the observer system read, were it run: this test stands for the thread, so it never runs. */ const seen: number[] = []; -const watch = System.create({ observer: true, components: { read: [amount] } }, row => { seen.push(row[amount]); }); +const watch = System.create({ observer: true, components: { with: [amount] } }, row => { seen.push(row[amount]); }); describe("an observer system on a joined thread", () => { it("is waited for, not cancelled, by a sync point after a writer copied the rest of a post the thread claimed partway", async () => { diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts index cac44437..6c8b8fa7 100644 --- a/packages/ecs/src/implementation/world/joined-observer.ts +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import { idOfItem, idOfKey, idOrNull, indexOfLookup, labelOf, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Archetype } from './archetype.js'; import { readCell } from './cells.js'; @@ -173,7 +173,7 @@ export const createObserverTarget = (thread: number, label: string, post: (messa const unique = (ids: readonly number[]): readonly number[] => [...new Set(ids)]; -const lookupReadsOf = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined): { readonly reads: readonly number[]; readonly watched: readonly number[] } => { +const lookupReadsOf = (state: State, lookup: { readonly [name: string]: SystemLookupLists } | undefined): { readonly reads: readonly number[]; readonly watched: readonly number[] } => { const reads: number[] = []; const watched: number[] = []; for (const name in lookup) { diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 53723112..5841e128 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -24,10 +24,10 @@ declare module "@adobe/ecs" { } /** Appends a digit to `total`, so the order of runs shows in it. */ -export const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +export const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -export const append = System.create({ resources: { write: [names] } }, ({ resources }, input: { readonly name: string }) => { +export const append = System.create({ }, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); export const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts index 891c77d1..4f68036c 100644 --- a/packages/ecs/src/implementation/world/kernel-imports.test.ts +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -24,15 +24,15 @@ const spawn = System.create({}, ({ commands }) => { }); // `kernel: true` compiles the body at build time, to run in chunks across workers as WASM where it can. -const integrate = System.create({ kernel: true, components: { read: [velocity], write: [position] } }, row => { +const integrate = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); // Kernels use lookups, imported pure functions, commands and imported lists too. const chase = System.create({ kernel: true, - components: { read: [position, target], write: [velocity] }, - lookup: { targets: { read: [position] } }, + components: { with: [position, target, velocity] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup, commands }) => { const other = lookup.targets(row[target]); if (other === null) return; @@ -58,7 +58,7 @@ it("runs systems compiled as kernels", async () => { const ignite = System.create({}, ({ commands }) => { commands.spawn(sparks)({ [position]: [0, 0, 0] }); }); -const settle = System.create({ kernel: true, components: { has: [spark] } }, (row, { commands }) => { +const settle = System.create({ kernel: true, components: { with: [spark] } }, (row, { commands }) => { commands.remove(row.entity, spark); }); diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts index c042ee3f..d505a8f1 100644 --- a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -18,9 +18,8 @@ declare module "@adobe/ecs" { export const labels = System.create({ kernel: true, - components: { read: [target], write: [offset] }, - // @ts-expect-error kernel lookups read linear values - lookup: { named: { read: [label] } }, + components: { with: [target, offset] }, + lookup: { named: { with: [label] } }, }, (row, { lookup: { named } }) => { const found = named(row[target]); row[offset] = found === null ? 0 : +found[label]; diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts index c52dbb8b..89f6682f 100644 --- a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -12,7 +12,7 @@ declare module "@adobe/ecs" { } } -export const beyond = System.create({ kernel: true, components: { read: [pair], write: [sum] } }, row => { +export const beyond = System.create({ kernel: true, components: { with: [pair, sum] } }, row => { // @ts-expect-error a pair has no third item row[sum] = row[pair][2]; }); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index a104bc94..19b7e39c 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -38,7 +38,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** One spawn per row, from a kernel. */ -const fromKernel = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { +const fromKernel = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { commands.spawn(spawned)({ [value]: row[index] }); }); @@ -48,25 +48,25 @@ const fromMain = System.create({ before: [fromKernel] }, ({ commands }) => { }); /** Spawns, then adds to and removes from what it spawned, in the same kernel and tick. */ -const spawnAndEdit = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { +const spawnAndEdit = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { const entity = commands.spawn([value, flag, made])({ [value]: row[index] }); commands.add(entity, extra, row[index] * 10); commands.remove(entity, flag); }); /** A spawn with a tuple, and an object component left to its start. */ -const withObjects = System.create({ kernel: true, components: { read: [index] } }, (row, { commands }) => { +const withObjects = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { commands.spawn([value, position, name, made])({ [value]: row[index], [position]: [row[index], -row[index]] }); }); -const despawnMade = System.create({ components: { has: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const despawnMade = System.create({ components: { with: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); const added = { seen: 0 }; -const countAdded = System.create({ components: { added: [value], has: [made] } }, () => { added.seen++; }); +const countAdded = System.create({ components: { added: [value], with: [made] } }, () => { added.seen++; }); /** Each made entity's value, in storage order. */ const madeValues: number[] = []; -const readMade = System.create({ observer: true, components: { read: [value], has: [made] } }, row => { madeValues.push(row[value]); }); +const readMade = System.create({ observer: true, components: { with: [value, made] } }, row => { madeValues.push(row[value]); }); const valuesOf = async (world: ReturnType): Promise => { madeValues.length = 0; await world.run(readMade); diff --git a/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts index b2583e12..e5603e7f 100644 --- a/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts @@ -16,13 +16,13 @@ declare module "@adobe/ecs" { } } -export const doubled = System.create({ components: { write: [offset] } }, row => { +export const doubled = System.create({ components: { with: [offset] } }, row => { row[offset] = row[offset] * 2; }); export const labels = System.create({ - components: { read: [target], write: [offset] }, - lookup: { named: { read: [label] } }, + components: { with: [target, offset] }, + lookup: { named: { with: [label] } }, }, (row, { lookup: { named } }) => { const found = named(row[target]); row[offset] = found === null ? 0 : found[label].length; diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts index 12e2e277..adbe103e 100644 --- a/packages/ecs/src/implementation/world/kernels-csp.test.ts +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -19,7 +19,7 @@ const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ kernel: true, components: { write: [value] } }, row => { +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts index e78a93fd..0da093e5 100644 --- a/packages/ecs/src/implementation/world/kernels.test.ts +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -26,12 +26,12 @@ declare module "@adobe/ecs" { const label = Symbol("kernelsTest/label"); /** Reads `value` and `other`, writes nothing, reads no resources. */ -const system = System.create({ kernel: true, components: { read: [value, other] } }, row => { +const system = System.create({ kernel: true, components: { with: [value, other] } }, row => { if (row[value] > row[other]) return; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); -const double = System.create({ kernel: true, components: { write: [value] } }, row => { +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); @@ -68,7 +68,7 @@ describe("attachKernel", () => { expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [0] }))).not.toThrow(); }); it("attaches nothing, and throws nothing, for a kernel the build tried that doesn't match", () => { - const tried = System.create({ components: { read: [value, other] } }, row => { + const tried = System.create({ components: { with: [value, other] } }, row => { if (row[value] > row[other]) return; }); expect(attachKernel(tried, describing({ keys: [value, label], structuredValues: [1] }), true)).toBe(tried); diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index 11c4ce7b..dd6eb39b 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -46,13 +46,13 @@ const fill = System.create({}, ({ commands }, input: { readonly count: number }) for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ kernel: true, components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ components: { read: [value] } }, row => { +const sum = System.create({ components: { with: [value] } }, row => { total.sum += row[value]; total.count++; }); @@ -68,23 +68,23 @@ const spawnMany = System.create({}, ({ commands }, input: { readonly count: numb }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ components: { write: [target] } }, row => { +const aim = System.create({ components: { with: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { with: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ kernel: true, - components: { read: [target], write: [seen] }, - lookup: { targets: { read: [value] } }, + components: { with: [target, seen] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ kernel: true, components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { with: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index e2986993..d6e3bce8 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -48,13 +48,13 @@ const fill = System.create({}, ({ commands }, input: { readonly count: number }) for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ kernel: true, components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ components: { read: [value] } }, row => { +const sum = System.create({ components: { with: [value] } }, row => { total.sum += row[value]; total.count++; }); @@ -70,23 +70,23 @@ const spawnMany = System.create({}, ({ commands }, input: { readonly count: numb }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ components: { write: [target] } }, row => { +const aim = System.create({ components: { with: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { with: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ kernel: true, - components: { read: [target], write: [seen] }, - lookup: { targets: { read: [value] } }, + components: { with: [target, seen] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ kernel: true, components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { with: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 8f146b0d..85430946 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -38,9 +38,8 @@ const seed = System.create({}, ({ commands }) => { /** A kernel that holds what kernels can: a lookup's directory, reduction partials and its load on each worker. */ const measure = System.create({ kernel: true, - components: { read: [value, target], write: [out] }, - lookup: { targets: { read: [value] } }, - resources: { reduce: [total] }, + components: { with: [value, target, out] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets }, reduce }) => { const other = targets(row[target]); row[out] = other === null ? -1 : row[value] - other[value]; diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts index 717f512a..90958da8 100644 --- a/packages/ecs/src/implementation/world/lifetime.test.ts +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -29,16 +29,16 @@ const seed = System.create({}, ({ commands }) => { }); /** A module-level system, run between and after the fresh ones, as an app's own systems are. */ -const steady = System.create({ components: { read: [count], changed: [count] } }, row => { void row[count]; }); +const steady = System.create({ components: { with: [count], changed: [count] } }, row => { void row[count]; }); let copied = 0; /** Fresh systems of each shape that holds something: a body alone, a resource read, a change filter, and `up`. */ const freshSystems = (k: number): readonly System[] => [ System.create(() => {}), - System.create({ resources: { read: [score] } }, ({ resources }) => { copied = resources[score] + k; }), - System.create({ components: { write: [count], changed: [count] } }, row => { row[count] += 1; }), - System.create({ components: { read: [count] }, up: { through: parent, read: [count] } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), + System.create({ }, ({ resources }) => { copied = resources[score] + k; }), + System.create({ components: { with: [count], changed: [count] } }, row => { row[count] += 1; }), + System.create({ components: { with: [count] }, up: { through: parent } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), ]; const fresh = 10_000; diff --git a/packages/ecs/src/implementation/world/lookup-keys.ts b/packages/ecs/src/implementation/world/lookup-keys.ts index e6d1fd9e..bf26439a 100644 --- a/packages/ecs/src/implementation/world/lookup-keys.ts +++ b/packages/ecs/src/implementation/world/lookup-keys.ts @@ -1,25 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import { indexDefinitionOf, indexOfLookup, watchedBy } from '../../interface/registry/index.js'; -import type { Component } from '../../interface/component/component.js'; +type Key = symbol; /** Every component a lookup names: what it reads, matches on and watches, and for an index lookup the index's own. */ -export const lookupComponentsOf = (lookup: SystemLookup): readonly Component.Key[] => { +export const lookupComponentsOf = (lookup: SystemLookupLists): readonly Key[] => { const index = indexOfLookup(lookup); if (index === undefined) return [...lookup.read ?? [], ..."has" in lookup ? lookup.has ?? [] : [], ..."not" in lookup ? lookup.not ?? [] : []]; const { key, components } = index.declaration; return [...lookup.read ?? [], ...watchedBy(lookup), ...key, ...components?.has ?? [], ...components?.not ?? []]; }; -const none: readonly Component.Key[] = []; +const none: readonly Key[] = []; /** * The components whose changes the indexes of a declaration's lookups follow, which keep ticks and kernels' stamps * while it lives: each index's key parts, and what its lookups watch. */ -export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { +export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookupLists } | undefined): readonly Key[] => { if (lookup === undefined) return none; - const keys: Component.Key[] = []; + const keys: Key[] = []; for (const name in lookup) { const declared = lookup[name]; const index = indexOfLookup(declared); @@ -30,9 +30,9 @@ export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookup } }; /** The components a declaration's lookups by entity watch: their `changed`, which keep ticks and kernels' stamps while it lives. */ -export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { +export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookupLists } | undefined): readonly Key[] => { if (lookup === undefined) return none; - const keys: Component.Key[] = []; + const keys: Key[] = []; for (const name in lookup) { const declared = lookup[name]; if (indexOfLookup(declared) === undefined) keys.push(...watchedBy(declared)); @@ -41,7 +41,7 @@ export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookup }; /** Every component the index a declaration orders its rows by names: its key parts, `has` and `not`. */ -export const orderComponentsOf = (declaration: { readonly order?: unknown }): readonly Component.Key[] => { +export const orderComponentsOf = (declaration: { readonly order?: unknown }): readonly Key[] => { const index = indexDefinitionOf(declaration.order); if (index === undefined) return none; const { key, components } = index.declaration; diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts index db1b466f..f8e37a6a 100644 --- a/packages/ecs/src/implementation/world/loop.test.ts +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -16,9 +16,9 @@ declare module "@adobe/ecs" { } } -const increment = requireDefinition(System.create({ components: { write: [count] } }, row => { row[count] += 1; })); -const reader = requireDefinition(System.create({ components: { read: [count] } }, row => { void row[count]; })); -const ordered = requireDefinition(System.create({ components: { read: [count] }, after: [System.create({}, () => {})] }, row => { void row[count]; })); +const increment = requireDefinition(System.create({ components: { with: [count] } }, row => { row[count] += 1; })); +const reader = requireDefinition(System.create({ components: { with: [count] } }, row => { void row[count]; })); +const ordered = requireDefinition(System.create({ components: { with: [count] }, after: [System.create({}, () => {})] }, row => { void row[count]; })); const takesInput = requireDefinition(System.create({}, (_context, input: { readonly by: number }) => {})); /** A host whose frames and timers run only when the test says, at the times it gives. */ @@ -164,7 +164,7 @@ describe("a world's loop platform", () => { it("can be given, so a back-to-back driver ticks as fast as tasks run", async () => { const driver = backToBack(); const world = createWorld({ [always]: true }, { loop: driver }); - world.add(System.create({ components: { has: [count] } }, () => {})); + world.add(System.create({ components: { with: [count] } }, () => {})); const start = performance.now(); // At 60 frames a second, a timer's frames would take 1.6 s. await new Promise(resolve => { diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index 3b1f5179..8892d1bf 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -26,8 +26,9 @@ declare module "@adobe/ecs" { // Two libraries, written apart, each declare a rule, a finalizer and a system labeled "linked". const library = (finalizer: (row: { readonly [value]: number }) => void, derived: typeof first | typeof second) => { - Rule.create({ components: { has: [value] }, derive: [derived] }); - Finalizer.create({ components: { read: [value] } }, finalizer); + Rule.create({ components: { with: [value] }, derive: [derived] }); + // Its body is written in place, so the build reads it, and calls what it's given with the row. + Finalizer.create({ components: { with: [value] } }, row => finalizer(row)); }; const released: string[] = []; let failing = false; @@ -36,12 +37,12 @@ library(row => { released.push(`second ${row[value]}`); if (failing) throw new E const ran: string[] = []; const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); -const despawn = System.create({ components: { has: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const despawn = System.create({ components: { with: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); const failOnce = System.create(() => { throw new Error("unnamed"); }); describe("systems, rules and finalizers of one label", () => { it("are distinct in schedules, both run, and are told apart in reports by their ids", async () => { - const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { write: [value] }, after }, body); + const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { with: [value] }, after }, body); const a = linked([], row => { ran.push("a"); row[value] += 1; }); const b = linked([a], row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); const c = linked([b], row => { ran.push(`c${row[value]}`); }); @@ -64,7 +65,7 @@ describe("systems, rules and finalizers of one label", () => { expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "library" }]); }); it("are labeled by the bindings of their ids, which reports give beside them", async () => { - const [one, two] = ["one.ts#library", "two.ts#library"].map(id => System.create(identify({ components: { write: [other] } }, id), row => { row[other] = 1; throw new Error(id); })); + const [one, two] = ["one.ts#library", "two.ts#library"].map(id => System.create(identify({ components: { with: [other] } }, id), row => { row[other] = 1; throw new Error(id); })); await using world = createWorld({ [workers]: 0 }); await world.run(System.create({}, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); const labels = async (system: System | undefined) => system === undefined ? [] : (await runReported(world, system)).failures.map(failure => [failure.name, failure.id]); diff --git a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts index 304761ef..8d2bd37f 100644 --- a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts @@ -29,7 +29,7 @@ const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < 100; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 })); }); -const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 28c46b34..6a05186c 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -31,14 +31,14 @@ declare module "@adobe/ecs" { } -const raise = System.create({ resources: { write: [level] } }, ({ resources }, input: { readonly by: number }) => { +const raise = System.create({ }, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; resources[level] += input.by; }); -const touchOther = System.create({ resources: { write: [other] } }, ({ resources }) => { resources[other] += 1; }); +const touchOther = System.create({ }, ({ resources }) => { resources[other] += 1; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); -const sum = System.create({ components: { read: [weight] }, resources: { reduce: [total] } }, (row, { reduce }) => { reduce[total](row[weight]); }); -const keep = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] = resources[level] + 0; }); +const sum = System.create({ components: { with: [weight] } }, (row, { reduce }) => { reduce[total](row[weight]); }); +const keep = System.create({ }, ({ resources }) => { resources[level] = resources[level] + 0; }); /** Errors thrown out of band while `catching` runs, as uncaught errors would be, caught instead. */ const catching = async (run: () => Promise): Promise => { @@ -82,12 +82,12 @@ declare module "@adobe/ecs" { let target = Entity.none; const create = System.create({}, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); const despawn = System.create({}, ({ commands }) => { commands.despawn(target); }); -const weigh = System.create({ components: { write: [weight], not: [filler] } }, row => { +const weigh = System.create({ components: { with: [weight], not: [filler] } }, row => { row[weight] += 1; row[weight] += 1; }); -const warm = System.create({ components: { write: [heat] } }, row => { row[heat] += 1; }); -const double = System.create({ kernel: true, components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); +const warm = System.create({ components: { with: [heat] } }, row => { row[heat] += 1; }); +const double = System.create({ kernel: true, components: { with: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); const addTag = System.create({}, ({ commands }) => { commands.add(target, tag, true); }); const removeTag = System.create({}, ({ commands }) => { commands.remove(target, tag); }); const removeWeight = System.create({}, ({ commands }) => { commands.remove(target, weight); }); @@ -120,10 +120,10 @@ const swap = System.create({}, ({ commands }) => { }); const mark = System.create({}, ({ commands }) => { commands.add(roster[0], marker, true); }); const leave = System.create({}, ({ commands }) => { commands.despawn(roster[1]); }); -const score10 = System.create({ components: { write: [score] } }, row => { row[score] += 10; }); -const nudge = System.create({ components: { write: [score] } }, row => { row[score] += 0; }); -const invert = System.create({ components: { write: [score] } }, row => { row[score] = -row[score]; }); -const invertKernel = System.create({ kernel: true, components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); +const score10 = System.create({ components: { with: [score] } }, row => { row[score] += 10; }); +const nudge = System.create({ components: { with: [score] } }, row => { row[score] += 0; }); +const invert = System.create({ components: { with: [score] } }, row => { row[score] = -row[score]; }); +const invertKernel = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = 0 - row[score]; }); /** Swaps the bench like `swap`, then despawns more entities than the journal holds, so it drops the swap's entries. */ const swapAndCrowd = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); @@ -157,7 +157,7 @@ let coarseValues: readonly number[] = []; const spawnCoarse = System.create({}, ({ commands }) => { for (const each of coarseValues) commands.spawn([coarse])({ [coarse]: each }); }); -const shiftCoarse = System.create({ components: { write: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); +const shiftCoarse = System.create({ components: { with: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); type Condition = number | { readonly "<"?: number; readonly ">"?: number; readonly "<="?: number; readonly ">="?: number; readonly "=="?: number; readonly "!="?: number }; @@ -447,7 +447,7 @@ describe("observing entities", () => { const subscribe = System.create({}, () => { entity(world, target, { required: [weight] })(value => seen.push(value)); }); - const later = System.create({ components: { write: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); + const later = System.create({ components: { with: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); await world.run(System.create({ systems: [subscribe, later] })); await settle(world); await world.run(warm); @@ -580,7 +580,7 @@ describe("observing queries", () => { const subscribe = System.create({}, () => { count(world, { required: [score], where: { [score]: { "<": 0 } } })(value => seen.push(value)); }); - const later = System.create({ components: { write: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); + const later = System.create({ components: { with: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); await world.run(System.create({ systems: [subscribe, later] })); await settle(world); expect(seen).toEqual([3]); diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index c95a41b7..b9f105f1 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -31,8 +31,8 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) spawned = []; for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); }); -const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); -const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); +const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); const nothing = System.create({}, () => {}); /** Counts passes: the observer passes after each run's sync point. */ @@ -115,7 +115,7 @@ describe("observer factories", () => { const stop = Observe.entity(world, spawned[0], { required: [amount] })(value => entities.push(value?.[amount])); await settle(world); await world.run(grow); - await world.run(System.create({ components: { write: [amount] } }, row => { + await world.run(System.create({ components: { with: [amount] } }, row => { if (row.entity !== spawned[0]) row[amount] += 1; })); expect(entities).toEqual([4, 5]); @@ -170,7 +170,7 @@ describe("observer factories", () => { it("read only what they list, so a write elsewhere notifies nothing", async () => { await using world = await seeded(3); - const writeOther = System.create({ components: { write: [other] } }, row => { row[other] = 1; }); + const writeOther = System.create({ components: { with: [other] } }, row => { row[other] = 1; }); let notified = 0; Observe.entity(world, spawned[0], { required: [amount] })(() => notified++); Observe.select(world, { required: [amount], order: { [amount]: false }, page: { offset: 0, limit: 2 } })(() => notified++); diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index e3cc4db6..ebe64110 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -44,7 +44,7 @@ const none = { text: "none" }; type Caption = { readonly text: string } | null; const Caption = { schema: { default: none } }; -const add = System.create({ resources: { write: [total] } }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { @@ -53,9 +53,9 @@ const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => const spawnPlaced = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([place, caption, widget, kept])({ [place]: input.x, [widget]: null, [kept]: `k${input.x}` }); }); -const nullCaptions = System.create({ components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); +const nullCaptions = System.create({ components: { with: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); +const restartCaptions = System.create({ components: { with: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); +const dropCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); /** A world shared with a thread joined through a channel claiming `threads`, and the joined world. */ const joined = (threads: readonly "observeJoinedTest"[] = []): { readonly world: CreatedWorld; readonly remote: World } => { diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 9f045a67..bb560e55 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -25,17 +25,17 @@ declare module "@adobe/ecs" { } } -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const watch = System.create({ resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); +const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); +const watch = System.create({ }, ({ resources }) => { resources[seen] = resources[clock].tick; }); const fail = System.create({}, () => { throw new Error("tick failed"); }); const nothing = System.create({}, () => {}); const idle = System.create({ observer: true }, () => {}); -const fast = System.create({ resources: { write: [cadence, always] } }, ({ resources }) => { +const fast = System.create({ }, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1 }; resources[always] = true; }); -const calm = System.create({ resources: { write: [always] } }, ({ resources }) => { resources[always] = false; }); -const later = System.create({ resources: { read: [clock], reduce: [wake] } }, ({ resources, reduce }) => { +const calm = System.create({ }, ({ resources }) => { resources[always] = false; }); +const later = System.create({ }, ({ resources, reduce }) => { if (resources[clock].tick === 0) reduce[wake](resources[clock].now + 20); }); @@ -43,17 +43,17 @@ const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, watched])({ [value]: i, [watched]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ kernel: true, components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); -const watchChanged = System.create({ kernel: true, components: { write: [watched], changed: [value] }, after: [bump] }, row => { +const watchChanged = System.create({ kernel: true, components: { with: [watched], changed: [value] }, after: [bump] }, row => { row[watched] = row[watched] + 1; }); /** Counts rows whose `value` was added since it last ran. */ -const fresh = System.create({ components: { write: [watched], added: [value] } }, row => { +const fresh = System.create({ components: { with: [watched], added: [value] } }, row => { row[watched] = row[watched] + 1; }); -const flagAll = System.create({ components: { has: [value] } }, (row, { commands }) => { +const flagAll = System.create({ components: { with: [value] } }, (row, { commands }) => { commands.add(row.entity, flag, true); }); const spawnOne = System.create({}, ({ commands }) => { diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts index 46dac09d..ed86b7b6 100644 --- a/packages/ecs/src/implementation/world/observed-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -23,11 +23,11 @@ const seed = System.create({}, ({ commands }) => { }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ kernel: true, components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); -const watch = System.create({ kernel: true, components: { write: [seen], changed: [value] }, after: [bump] }, row => { +const watch = System.create({ kernel: true, components: { with: [seen], changed: [value] }, after: [bump] }, row => { row[seen] = row[seen] + 1; }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index a50698e1..57607118 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -25,14 +25,14 @@ export const gate: { words: Int32Array | null } = { words: null }; /** What the watcher's runs saw, in order. */ export const tallies: number[] = []; -export const bump = System.create({ resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); +export const bump = System.create({ }, ({ resources }) => { resources[tally] += 1; }); export const nothing = System.create({}, () => {}); -export const setHolding = System.create({ resources: { write: [holding] } }, ({ resources }, input: { readonly holding: number }) => { +export const setHolding = System.create({ }, ({ resources }, input: { readonly holding: number }) => { resources[holding] = input.holding; }); /** After each sync point that changed the tally: notes it, and, while `holding` says so, waits there to be released. */ -export const watchTally = System.create({ observer: true, resources: { read: [tally, holding], changed: [tally] } }, ({ resources }) => { +export const watchTally = System.create({ observer: true, resources: { changed: [tally] } }, ({ resources }) => { const value = resources[tally]; tallies.push(value); const { words } = gate; diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index ac856192..e7ef18a3 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -18,21 +18,21 @@ declare module "@adobe/ecs" { } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ -const widen = System.create({ resources: { write: [wide] } }, ({ resources }) => { +const widen = System.create({ }, ({ resources }) => { const first = resources[wide][0] + 1; const base = first * 100; resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; }); -const step = System.create({ resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); -const pause = System.create({ resources: { write: [paused] } }, ({ resources }, input: { readonly paused: boolean }) => { +const step = System.create({ }, ({ resources }) => { resources[steps] += 1; }); +const pause = System.create({ }, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); const seen: { wide: number[][]; steps: number[] } = { wide: [], steps: [] }; -const watchWide = System.create({ observer: true, resources: { read: [wide], changed: [wide] } }, ({ resources }) => { +const watchWide = System.create({ observer: true, resources: { changed: [wide] } }, ({ resources }) => { seen.wide.push([...resources[wide]]); }); -const watchSteps = System.create({ observer: true, resources: { read: [steps], not: [paused], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ observer: true, resources: { not: [paused], changed: [steps] } }, ({ resources }) => { seen.steps.push(resources[steps]); }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 3f9bf022..2c2cf844 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -81,10 +81,10 @@ export const hold = (): void => { export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: the counter moves on once a tick, then every row's stamp, spot and word take it. */ -export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({ }, ({ resources }) => { resources[counter] += 1; }); -export const advance = System.create({ components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { with: [stamp, spot, word] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; row[spot] = [value, -value, value]; @@ -94,7 +94,7 @@ export const ticked = System.create({ systems: [count, advance] }); export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ resources: { write: [stall] } }, ({ resources }, input: { readonly pause: number }) => { +export const setStall = System.create({ }, ({ resources }, input: { readonly pause: number }) => { resources[stall] = input.pause; }); @@ -103,7 +103,7 @@ export type Seen = { counter: number; rows: number; wrong: number }; export const seen: Seen[] = []; /** Reads every row, which must all be of one tick, pausing at the end of its first chunk while `stall` says so. */ -export const watchRows = System.create({ observer: true, components: { read: [stamp, spot, word] }, resources: { read: [counter, stall] } }, (row, { resources }) => { +export const watchRows = System.create({ observer: true, components: { with: [stamp, spot, word] } }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); if (last === undefined || last.counter !== value || last.rows === 1000) seen.push(last = { counter: value, rows: 0, wrong: 0 }); @@ -117,7 +117,7 @@ export const watchRows = System.create({ observer: true, components: { read: [st export const looked: Entity[] = []; /** Looks up every row by entity, which must all be of one tick, spinning halfway while `stall` says so. */ -export const lookRows = System.create({ observer: true, resources: { read: [counter, stall] }, lookup: { found: { read: [stamp, spot] } } }, ({ resources, lookup }) => { +export const lookRows = System.create({ observer: true, lookup: { found: { with: [stamp, spot] } } }, ({ resources, lookup }) => { const value = resources[counter]; const run: Seen = { counter: value, rows: 0, wrong: 0 }; seen.push(run); @@ -135,10 +135,10 @@ export const lookRows = System.create({ observer: true, resources: { read: [coun export const spawnHeated = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -export const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +export const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -export const nextPhase = System.create({ resources: { write: [phase] } }, ({ resources }) => { +export const nextPhase = System.create({ }, ({ resources }) => { resources[phase] += 1; }); @@ -152,7 +152,7 @@ export const countHot = System.create({ observer: true, lookup: { hot: { index: /** On the world's thread: the motto of a round, and the round, every motto of one size. */ export const mottoOf = (round: number): string => `motto ${round % 10}`; -export const setMotto = System.create({ resources: { write: [motto, mottoRound] } }, ({ resources }, input: { readonly round: number }) => { +export const setMotto = System.create({ }, ({ resources }, input: { readonly round: number }) => { resources[motto] = mottoOf(input.round); resources[mottoRound] = input.round; }); @@ -164,7 +164,7 @@ export const mottos: { readonly round: number; readonly motto: string }[] = []; * Reads only resources, a string among them: waits on the gate first, while `stall` says so, its round plus one, then * reads the motto, which must be its round's. */ -export const watchMotto = System.create({ observer: true, resources: { read: [motto, mottoRound, stall] } }, ({ resources }) => { +export const watchMotto = System.create({ observer: true }, ({ resources }) => { const round = resources[mottoRound]; pauseAt(resources[stall], round + 1); mottos.push({ round, motto: resources[motto] }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index a6caf336..941262ae 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -37,17 +37,17 @@ const blank = { text: "" }; type Label = { readonly text: string }; const Label = { schema: { default: blank } }; -const byCell = Index.create({ key: [cell], components: { has: [amount] } }); -const allTagged = Index.create({ key: [], components: { has: [tagged] } }); +const byCell = Index.create({ key: [cell], components: { with: [amount] } }); +const allTagged = Index.create({ key: [], components: { with: [tagged] } }); -const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); const nothing = System.create({}, () => {}); const spawn = System.create({}, ({ commands }, input: { readonly at: number }) => { commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); }); const spawnLabelled = System.create({}, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); -const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); -const move = System.create({ components: { write: [cell] } }, row => { row[cell] += 10; }); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); +const move = System.create({ components: { with: [cell] } }, row => { row[cell] += 10; }); /** What the joined thread's observer bodies saw, reset by each test. */ const seen: { levels: number[]; amounts: number[][]; looked: (number | boolean)[][]; tagged: number[]; labels: string[]; calls: number } = { levels: [], amounts: [], looked: [], tagged: [], labels: [], calls: 0 }; @@ -59,16 +59,16 @@ const reset = (): void => { /** The entities the tests look up, by the joined thread's systems. */ const watched: Entity[] = []; -const watchLevel = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevel = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { seen.calls++; seen.levels.push(resources[level]); }); -const watchAmounts = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { +const watchAmounts = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { (seen.amounts[seen.amounts.length - 1] ??= []).push(row[amount]); }); const startAmounts = System.create({ observer: true }, () => { seen.amounts.push([]); }); const amountsPass = System.create([startAmounts, watchAmounts]); -const lookChanged = System.create({ observer: true, lookup: { found: { read: [amount], changed: [amount] } } }, ({ lookup }) => { +const lookChanged = System.create({ observer: true, lookup: { found: { with: [amount], changed: [amount] } } }, ({ lookup }) => { seen.looked.push(watched.flatMap(entity => { const row = lookup.found(entity); return row === null ? [] : [row[amount], row.changed]; @@ -77,14 +77,14 @@ const lookChanged = System.create({ observer: true, lookup: { found: { read: [am const countTagged = System.create({ observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { seen.tagged.push(lookup.all().length); }); -const inCell = System.create({ observer: true, lookup: { found: { index: byCell, read: [amount] } } }, ({ lookup }) => { +const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { const found = lookup.found(11); seen.tagged.push(found.length === 0 ? 0 : found.length * 100 + (found.at(0)?.[amount] ?? -1)); }); -const failing = System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true }, ({ resources }) => { if (resources[level] > 1) throw new Error("observer joined failure"); }); -const readLabels = System.create({ observer: true, components: { read: [label, amount] } }, row => { +const readLabels = System.create({ observer: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); @@ -208,12 +208,12 @@ describe("observer systems on a joined thread", () => { await world.run(spawn, { at: 1 }); await world.run(spawn, { at: 11 }); // Made in a function, so the build gives them no id. - const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly has: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly has: readonly [typeof amount] } }>] => [ - Index.create({ key: [], components: { has: [tagged, cell] } }), - Index.create({ key: [cell], sorted: true, components: { has: [amount] } }), + const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly with: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly with: readonly [typeof amount] } }>] => [ + Index.create({ key: [], components: { with: [tagged, cell] } }), + Index.create({ key: [cell], sorted: true, components: { with: [amount] } }), ]; const [madeTagged, madeByCell] = made(); - const sizes = System.create({ observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell, read: [amount] } } }, ({ lookup }) => { + const sizes = System.create({ observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell } } }, ({ lookup }) => { const all = lookup.all(); seen.looked.push([all.changed]); const high = lookup.cells({ ">=": 5 }); @@ -309,7 +309,7 @@ describe("observer systems on a joined thread", () => { const { world, close } = await joined(["observerJoinedTest"]); await world.run(spawnLabelled); // Made in a function, so the build gives it no id. - const made = () => System.create({ observer: true, components: { read: [label, amount] } }, row => { + const made = () => System.create({ observer: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); const madeLabels = made(); diff --git a/packages/ecs/src/implementation/world/observer.browser.test.ts b/packages/ecs/src/implementation/world/observer.browser.test.ts index a81b3e87..b02a3e94 100644 --- a/packages/ecs/src/implementation/world/observer.browser.test.ts +++ b/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -21,12 +21,12 @@ declare module "@adobe/ecs" { const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); let total = 0; -const sum = System.create({ observer: true, components: { read: [heat] } }, row => { total += row[heat]; }); +const sum = System.create({ observer: true, components: { with: [heat] } }, row => { total += row[heat]; }); const byHeat = Index.create({ key: [heat], sorted: true }); let hot = 0; diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index de82d53c..061b9f87 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { Index, Report, System, workers } from "../../index.js"; import { isObserver, requireDefinition } from "../../interface/registry/index.js"; import type { World } from "../../interface/world/world.js"; @@ -30,28 +31,29 @@ const reset = (): void => { for (const list of Object.values(seen)) list.length = 0; }; -const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); -const grow = System.create({ components: { write: [amount] } }, row => { row[amount] += 1; }); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); const nothing = System.create({}, () => {}); -const watchLevel = System.create({ observer: true, resources: { read: [level] } }, context => { +// It keeps its context, which the build can't follow, so it's given what it reads. +const watchLevel = System.create(giveAccess({ observer: true }, { resources: { read: [level] } }), context => { seen.levels.push(context.resources[level]); seen.contexts.push(context); }); -const watchAmounts = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); -const count = System.create({ observer: true, components: { has: [amount] } }, () => { seen.counted.push(1); }); +const watchAmounts = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); +const count = System.create({ observer: true, components: { with: [amount] } }, () => { seen.counted.push(1); }); const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { seen.cells.push(lookup.found(7).length); }); -const failing = System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); -const watchReport = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const watchReport = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { seen.reports.push(row[Report.errors].failures.length); }); const raiseThenWatch = System.create([raise, watchLevel]); -const watchLevelChanges = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevelChanges = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { seen.levels.push(resources[level]); }); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index 5babfab4..2b6c6c87 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -34,8 +34,8 @@ const seed = System.create({}, ({ commands }) => { const few = System.create({}, ({ commands }) => { for (let i = 0; i < 16; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ kernel: true, components: { write: [value] } }, row => { row[value] = row[value] * 2; }); -const resize = System.create({ resources: { write: [workers] } }, ({ resources }, input: { readonly size: number | null }) => { +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); +const resize = System.create({ }, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts index 4bab5430..a485d8b7 100644 --- a/packages/ecs/src/implementation/world/ports.fixture.ts +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -22,7 +22,7 @@ const seed = System.create({}, ({ commands }) => { }); /** Long enough per row that a task over 100,000 rows is still running when its worker is killed. */ -const heavy = System.create({ kernel: true, components: { write: [value] } }, row => { +const heavy = System.create({ kernel: true, components: { with: [value] } }, row => { let total = row[value]; for (let k = 0; k < 2000; k++) total = total * 0.5 + k; row[value] = total; diff --git a/packages/ecs/src/implementation/world/precision.test.ts b/packages/ecs/src/implementation/world/precision.test.ts index 512f39a3..3ff824f9 100644 --- a/packages/ecs/src/implementation/world/precision.test.ts +++ b/packages/ecs/src/implementation/world/precision.test.ts @@ -26,14 +26,14 @@ declare module "@adobe/ecs" { const seed = System.create(({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([a, c, difference])({ [a]: 1 + 2 ** -23, [c]: 1 + 2 ** -22, [difference]: 0 }); }); -const tried = System.create({ components: { read: [a, c], write: [difference] } }, row => { +const tried = System.create({ components: { with: [a, c, difference] } }, row => { row[difference] = row[a] * row[a] - row[c]; }); -const written = System.create({ kernel: false, components: { read: [a, c], write: [difference] } }, row => { +const written = System.create({ kernel: false, components: { with: [a, c, difference] } }, row => { row[difference] = row[a] * row[a] - row[c]; }); const seen: number[] = []; -const read = System.create({ kernel: false, components: { read: [difference] } }, row => { +const read = System.create({ kernel: false, components: { with: [difference] } }, row => { seen.push(row[difference]); }); @@ -66,25 +66,25 @@ const rows = 1027; const seedMany = System.create(({ commands }) => { for (let i = 0; i < rows; i++) commands.spawn([a, c, difference, position, velocity])({ [a]: hard(i), [c]: hard(i + 1), [difference]: 0, [position]: [hard(i + 2), hard(i + 3), hard(i + 4)], [velocity]: [hard(i + 5), hard(i + 6), hard(i)] }); }); -const touch = System.create({ kernel: false, components: { write: [c] } }, row => { +const touch = System.create({ kernel: false, components: { with: [c] } }, row => { if (row.entity % 3 === 0) row[c] = row[c] * 3; }); const pairs = [ [ - System.create({ components: { read: [a, c], write: [difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), - System.create({ kernel: false, components: { read: [a, c], write: [difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + System.create({ components: { with: [a, c, difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + System.create({ kernel: false, components: { with: [a, c, difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), ], [ - System.create({ components: { read: [velocity], write: [position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), - System.create({ kernel: false, components: { read: [velocity], write: [position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + System.create({ components: { with: [velocity, position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + System.create({ kernel: false, components: { with: [velocity, position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), ], [ - System.create({ components: { read: [a, c], write: [difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), - System.create({ kernel: false, components: { read: [a, c], write: [difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + System.create({ components: { with: [a, c, difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + System.create({ kernel: false, components: { with: [a, c, difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), ], ] as const; const values: number[] = []; -const readAll = System.create({ kernel: false, components: { read: [difference, position] } }, row => { +const readAll = System.create({ kernel: false, components: { with: [difference, position] } }, row => { values.push(row[difference], ...row[position]); }); diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index 930389a0..37459ebf 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -36,7 +36,7 @@ const spawn = System.create({}, ({ commands }) => { } }); -const reduce = System.create({ kernel: true, components: { read: [weight] }, resources: { reduce: [count, sum, largest] } }, (row, { reduce }) => { +const reduce = System.create({ kernel: true, components: { with: [weight] } }, (row, { reduce }) => { reduce[count](1); reduce[sum](row[weight]); reduce[largest](row[weight]); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index 6bdf894d..b885d1df 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -9,6 +9,7 @@ import { definitionOf } from "../../interface/registry/index.js"; import type { Name } from "../../interface/system/name.js"; import { createRegistrar } from "./register.js"; import { createState } from "./state.js"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { runReported } from "./reports.test-helper.js"; @@ -55,7 +56,7 @@ const spawn = System.create({}, ({ commands }) => { } }); -const kernel = System.create({ kernel: true, components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { +const kernel = System.create({ kernel: true, components: { with: [position, mass] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -65,7 +66,7 @@ const kernel = System.create({ kernel: true, components: { read: [position, mass }); /** The same reductions, run as written on the main thread. */ -const body = System.create({ components: { read: [position, mass] }, resources: { reduce: [total, low, bits, least] } }, (row, { reduce }) => { +const body = System.create({ components: { with: [position, mass] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -74,29 +75,29 @@ const body = System.create({ components: { read: [position, mass] }, resources: reduce[least](row[mass]); }); -const filtered = System.create({ kernel: true, components: { read: [mass], has: [heavy] }, resources: { changed: [level], reduce: [total, least] } }, (row, { reduce }) => { +const filtered = System.create({ kernel: true, components: { with: [mass, heavy] }, resources: { changed: [level] } }, (row, { reduce }) => { reduce[total](row[mass]); reduce[least](row[mass]); }); -const flags = System.create({ kernel: true, components: { read: [mass] }, resources: { reduce: [any] } }, (row, { reduce }) => { +const flags = System.create({ kernel: true, components: { with: [mass] } }, (row, { reduce }) => { reduce[any](row[mass] > 4000); }); /** Two contributors to `total`, the heavy rows and the light ones, and a reader of the finished fold. */ -const heavyMass = System.create({ kernel: true, components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { +const heavyMass = System.create({ kernel: true, components: { with: [mass, heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const lightMass = System.create({ components: { read: [mass], not: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { +const lightMass = System.create({ components: { with: [mass], not: [heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const reader = System.create({ resources: { read: [total], write: [seen] } }, ({ resources }) => { +const reader = System.create({ }, ({ resources }) => { resources[seen] = resources[total]; }); const frame = System.create({ systems: [reader, heavyMass, lightMass] }); -const weigh = System.create({ components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); -const kernelChanged = System.create({ kernel: true, components: { read: [mass], changed: [mass] }, resources: { reduce: [total] } }, (row, { reduce }) => { +const weigh = System.create({ components: { with: [mass, heavy] } }, row => { row[mass] = 1; }); +const kernelChanged = System.create({ kernel: true, components: { with: [mass], changed: [mass] } }, (row, { reduce }) => { reduce[total](row[mass]); }); @@ -157,7 +158,7 @@ describe("reductions", () => { it("keep the previous value when a contributor fails, or when every contributor skips the run", async () => { let fail = false; - const fails = System.create({ components: { read: [mass], has: [heavy] }, resources: { reduce: [total] } }, (row, { reduce }) => { + const fails = System.create({ components: { with: [mass, heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); if (fail) throw new Error("contributor failed"); }); @@ -200,7 +201,7 @@ describe("reductions", () => { it("stamp their resources changed only when the fold differs", async () => { let ran = 0; - const watcher = System.create({ components: { has: [mass] }, resources: { changed: [total] } }, () => { ran++; }); + const watcher = System.create({ components: { with: [mass] }, resources: { changed: [total] } }, () => { ran++; }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(kernel); @@ -214,7 +215,7 @@ describe("reductions", () => { }); it("store an infinite sum as any write of it, and only an empty min or max as the integer type's extreme", async () => { - const infinite = System.create({ components: { has: [mass] }, resources: { reduce: [wrapped] } }, (_, { reduce }) => { + const infinite = System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[wrapped](Infinity); }); const world = createWorld({ [workers]: 0 }); @@ -236,21 +237,22 @@ describe("reductions", () => { // Booleans reduce bitwise. expect(() => registerResource(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error only a resource with a reduce trait is reduced - expect(() => System.create({ components: { has: [mass] }, resources: { reduce: [level] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); + expect(() => System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written - expect(() => System.create({ resources: { write: [total] } }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); - // @ts-expect-error a contributor doesn't read what it reduces into - expect(() => System.create({ components: { has: [mass] }, resources: { read: [total], reduce: [total] } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); - // @ts-expect-error nor filters on it - expect(() => System.create({ components: { has: [mass] }, resources: { changed: [total], reduce: [total] } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + expect(() => System.create({ }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); + // A contributor doesn't read what it reduces into. + expect(() => System.create({ components: { with: [mass] } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + // Nor filters on it. + expect(() => System.create({ components: { with: [mass] }, resources: { changed: [total] } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); }); it("leave nothing subscribed to sync points from a registration that failed", () => { const state = createState(true); const registrar = createRegistrar(state); - // A declaration the build doesn't see is a kernel it didn't compile, which only a world's registration finds. - const uncompiled = { name: "reductionsTest/other/6", kernel: true, components: { read: [mass] } } as const; - const both = System.create({ systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); + // A call the build doesn't see is a kernel it didn't compile, which only a world's registration finds. + const unseen = System.create; + const uncompiled = giveAccess({ kernel: true, components: { with: [mass] } } as const, { read: [mass] }); + const both = System.create({ systems: [kernel, unseen(uncompiled, row => { void row[mass]; })] }); const definition = (system: System) => { const found = definitionOf(system); if (found === undefined) throw new Error("not a system"); diff --git a/packages/ecs/src/implementation/world/reductions.ts b/packages/ecs/src/implementation/world/reductions.ts index 12b2f80d..5aaea5b4 100644 --- a/packages/ecs/src/implementation/world/reductions.ts +++ b/packages/ecs/src/implementation/world/reductions.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Reduction } from '../../interface/resource/reduction.js'; -import type { Declaration } from '../../interface/system/declaration.js'; +import type { Resolved } from '../../interface/system/declaration.js'; import { isReduction } from '../../interface/registry/reduction.js'; import type { Layout, Path, View } from '../../interface/registry/layout.js'; import { stampChanged } from './archetype.js'; @@ -24,11 +24,11 @@ const bitwise: ReadonlySet = new Set(["&", "|", "^"]); * Creating the system checked each has a trait, which registering the resource checked against its schema, so a throw * here is an invariant broken. */ -export const reductionTargets = (state: State, name: string, declaration: Declaration): readonly ReductionTarget[] => +export const reductionTargets = (state: State, name: string, declaration: Resolved): readonly ReductionTarget[] => reductionTargetsOf(key => state.layouts[idOf(state, key)], name, declaration); /** The resources a system `name` reduces into, by `layoutFor`, each resource's layout. */ -export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, name: string, declaration: Declaration): readonly ReductionTarget[] => { +export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, name: string, declaration: Resolved): readonly ReductionTarget[] => { const reduce = declaration.resources?.reduce; if (reduce === undefined) return []; return reduce.map((key): ReductionTarget => { diff --git a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts index 42308ba6..169593aa 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts @@ -11,6 +11,6 @@ declare module "@adobe/ecs" { } -export const bump = System.create({ components: { write: [score] } }, row => { +export const bump = System.create({ components: { with: [score] } }, row => { row[score] += 1; }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 0374e4e7..e15bbaad 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -77,7 +77,7 @@ export const finalized: Entity[] = []; export const throwing = new Set(); /** On the finalizers' thread: what each stop held there, its structs before the sync point applied. */ -export const release = Finalizer.create({ components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { +export const release = Finalizer.create({ components: { with: [serial, place, marked, widget, label] } }, (row, { resources }) => { finalized.push(row.entity); released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone].text }); if (throwing.has(row[serial])) throw new Error(`remoteFinalizersTest: ${row[serial]} throws`); @@ -87,7 +87,7 @@ export const release = Finalizer.create({ components: { read: [serial, place, ma export const entitled: { readonly serial: number; readonly title: string }[] = []; /** On the finalizers' thread: what each titled stop's title was before its sync point applied. */ -export const entitle = Finalizer.create({ components: { read: [serial, title, widget] } }, row => { +export const entitle = Finalizer.create({ components: { with: [serial, title, widget] } }, row => { entitled.push({ serial: row[serial], title: row[title] }); }); @@ -95,7 +95,7 @@ export const entitle = Finalizer.create({ components: { read: [serial, title, wi export const headlined: { readonly serial: number; readonly heading: string }[] = []; /** On the finalizers' thread: the heading as it was before each headed stop's sync point applied. */ -export const headline = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [heading] } }, (row, { resources }) => { +export const headline = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { headlined.push({ serial: row[serial], heading: resources[heading] }); }); @@ -103,7 +103,7 @@ export const headline = Finalizer.create({ components: { read: [serial, widget], export const headlinedBoth: { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; /** On the finalizers' thread: two string resources as they were before each headed stop's sync point applied. */ -export const headlines = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [subheading, heading] } }, (row, { resources }) => { +export const headlines = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); }); @@ -111,7 +111,7 @@ export const headlines = Finalizer.create({ components: { read: [serial, widget] export const gauged: { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; /** On the finalizers' thread: a number and a struct resource as they were before each headed stop's sync point applied. */ -export const gauge = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [origin, level] } }, (row, { resources }) => { +export const gauge = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); }); @@ -120,7 +120,7 @@ export type Surveyed = { readonly serial: number; readonly caption: string; read export const surveyed: Surveyed[] = []; /** On the finalizers' thread: resources of mixed widths as they were before each headed stop's sync point applied. */ -export const survey = Finalizer.create({ components: { read: [serial, widget], has: [headed] }, resources: { read: [material, caption, sample, lit, tint] } }, (row, { resources }) => { +export const survey = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { const { alpha, color, glossy } = resources[material]; surveyed.push({ serial: row[serial], caption: resources[caption], lit: resources[lit], tint: [...resources[tint]], @@ -136,7 +136,7 @@ export const surveyOf = (at: number): Omit => ({ }); /** From the world's thread: the level, the origin at it, and the resources of mixed widths at it. */ -export const regauge = System.create({ resources: { write: [level, origin, caption, lit, tint, sample, material] } }, ({ resources }, input: { readonly level: number }) => { +export const regauge = System.create({ }, ({ resources }, input: { readonly level: number }) => { resources[level] = input.level; resources[origin] = [input.level, input.level + 10]; const values = surveyOf(input.level); @@ -152,7 +152,7 @@ export const spawnHeaded = System.create({}, ({ commands }, input: { readonly fr for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); }); /** From the world's thread: the heading and subheading. */ -export const rehead = System.create({ resources: { write: [heading, subheading] } }, ({ resources }, input: { readonly heading: string }) => { +export const rehead = System.create({ }, ({ resources }, input: { readonly heading: string }) => { resources[heading] = input.heading; resources[subheading] = `s${input.heading}`; }); @@ -162,7 +162,7 @@ export const spawnTitled = System.create({}, ({ commands }, input: { readonly fr for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); }); /** From the world's thread: every title as another of the same size, so the records of titles let go are reused. */ -export const retitle = System.create({ components: { write: [title] } }, (row, _context, input: { readonly round: number }) => { +export const retitle = System.create({ components: { with: [title] } }, (row, _context, input: { readonly round: number }) => { row[title] = `r${input.round}`; }); @@ -171,36 +171,36 @@ export const spawn = System.create({}, ({ commands }, input: { readonly from: nu for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, place, marked, widget, label])({ [serial]: k, [place]: [k, -k], [widget]: null }); }); /** On the thread: widgets written in place. */ -export const attach = System.create({ components: { read: [serial], write: [widget] } }, row => { +export const attach = System.create({ components: { with: [serial, widget] } }, row => { const tag = `w${row[serial]}`; row[widget] = { tag, draw: () => tag }; }); /** On the thread: labels given by command, staged there, for odd serials. */ -export const relabel = System.create({ components: { read: [serial, label] } }, (row, { commands }) => { +export const relabel = System.create({ components: { with: [serial, label] } }, (row, { commands }) => { const relabelled = { text: `l${row[serial]}${row[label]?.text === "none" ? "" : "+"}` }; if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); }); /** On the thread: its object resource. */ -export const retone = System.create({ resources: { write: [tone] } }, ({ resources }, input: { readonly tone: string }) => { +export const retone = System.create({ }, ({ resources }, input: { readonly tone: string }) => { resources[tone] = { text: input.tone }; }); /** From the world's thread: places move, so a stop's values are those before the sync point applies. */ -export const shift = System.create({ components: { write: [place] } }, row => { +export const shift = System.create({ components: { with: [place] } }, row => { row[place] = [row[place][0] + 100, row[place][1]]; }); /** From the world's thread: despawns these serials. */ -export const despawn = System.create({ components: { read: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { +export const despawn = System.create({ components: { with: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.despawn(row.entity); }); /** From the world's thread: removes widgets of these serials, a stop that despawns nothing. */ -export const strip = System.create({ components: { read: [serial], has: [widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { +export const strip = System.create({ components: { with: [serial, widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.remove(row.entity, widget); }); /** From the world's thread: every row moves archetype, flagged on one run and not on the next. */ -export const flag = System.create({ components: { has: [serial], not: [flagged] } }, (row, { commands }) => { +export const flag = System.create({ components: { with: [serial], not: [flagged] } }, (row, { commands }) => { commands.add(row.entity, flagged); }); -export const unflag = System.create({ components: { has: [serial, flagged] } }, (row, { commands }) => { +export const unflag = System.create({ components: { with: [serial, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index a620639d..13c357b7 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -23,13 +23,13 @@ declare module "@adobe/ecs" { [third]: { type: number }; } } -const bump = System.create({ resources: { write: [later] } }, ({ resources }) => { +const bump = System.create({ }, ({ resources }) => { resources[later] += 1; }); -const bumpSecond = System.create({ resources: { write: [second] } }, ({ resources }) => { +const bumpSecond = System.create({ }, ({ resources }) => { resources[second] += 1; }); -const bumpThird = System.create({ resources: { write: [third] } }, ({ resources }) => { +const bumpThird = System.create({ }, ({ resources }) => { resources[third] += 1; }); @@ -229,9 +229,9 @@ describe("finalizers on a joined thread", () => { const { world, remote } = await joined(); await world.run(spawn, { from: 0, count: 2 }); const late: string[] = []; - Finalizer.create(identify({ components: { read: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); + Finalizer.create(identify({ components: { with: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); // Declared in a function, so the build gives it no id. - Finalizer.create({ components: { read: [widget] } }, () => {}); + Finalizer.create({ components: { with: [widget] } }, row => { void row[widget]; }); const first = await runReported(world, despawn, { serials: [0] }); expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", expect.stringMatching(/^finalizer #\d+$/), expect.stringMatching(/^Finalizer "finalizer #\d+" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level finalizers one\.$/)]]); expect(await eventually(() => late.length === 1)).toBe(true); @@ -241,7 +241,7 @@ describe("finalizers on a joined thread", () => { // Placed there by the thread's object resource alone: stops of rows holding none of its objects, which its log // has no entries for, run there too. const heard: string[] = []; - Finalizer.create(identify({ components: { read: [serial], not: [widget] }, resources: { read: [tone] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); + Finalizer.create(identify({ components: { with: [serial], not: [widget] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); const plain = System.create({}, ({ commands }) => { for (let k = 0; k < 3; k++) commands.spawn([serial, place])({ [serial]: 50 + k, [place]: [0, 0] }); }); diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index d7f0e567..bdcd4858 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -41,7 +41,7 @@ export const seed = System.create({}, ({ commands }) => { * flagged when it's odd, so it moves archetype every tick; the other entities despawn, and four more spawn into its * archetypes, so rows swap into the holes they leave. */ -export const churn = System.create({ components: { read: [serial], write: [phase] } }, (row, { commands }) => { +export const churn = System.create({ components: { with: [serial, phase] } }, (row, { commands }) => { if (row[serial] !== 0) { commands.despawn(row.entity); return; @@ -72,7 +72,7 @@ export type Observers = "factory" | "observer"; const observer: { entity: Entity | null; seen: ((odd: boolean, held: boolean, text: string | null | undefined) => void) | null } = { entity: null, seen: null }; /** After each sync point, on the joined thread: the watched entity's phase and caption, and whether it's flagged. */ -export const observerWatch = System.create({ observer: true, lookup: { values: { read: [phase, caption] }, flagged: { has: [flag] } } }, ({ lookup }) => { +export const observerWatch = System.create({ observer: true, lookup: { values: { with: [phase, caption] }, flagged: { with: [flag] } } }, ({ lookup }) => { if (observer.entity === null) return; const row = lookup.values(observer.entity); if (row === null) return; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 069b268d..3e62c7d9 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -43,35 +43,35 @@ const Caption = { schema: { default: none } }; const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); }); -const nullCaptions = System.create({ components: { has: [caption, point] } }, (row, { commands }) => { +const nullCaptions = System.create({ components: { with: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ components: { has: [caption, point] } }, (row, { commands }) => { +const restartCaptions = System.create({ components: { with: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ components: { has: [caption] } }, (row, { commands }) => { +const dropCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); -const giveCaption = System.create({ components: { has: [point] } }, (row, { commands }) => { +const giveCaption = System.create({ components: { with: [point] } }, (row, { commands }) => { commands.add(row.entity, caption, { text: "a caption only the thread gives" }); }); const spawnCaptioned = System.create({}, ({ commands }) => { commands.spawn([point, caption])({ [point]: 1, [caption]: { text: "a caption only the thread gives" } }); }); /** A kernel giving the thread's objects null, which they take, and a number, which they don't. */ -const nullCaptionsKernel = System.create({ kernel: true, components: { has: [caption], read: [point] } }, (row, { commands }) => { +const nullCaptionsKernel = System.create({ kernel: true, components: { with: [point, caption] } }, (row, { commands }) => { if (row[point] > 0) commands.add(row.entity, caption, null); }); -const numberHandlesKernel = System.create({ kernel: true, components: { read: [point] } }, (row, { commands }) => { +const numberHandlesKernel = System.create({ kernel: true, components: { with: [point] } }, (row, { commands }) => { commands.add(row.entity, handle, row[point]); }); /** Reaches its commands, so it isn't terminal: its tier blocks rather than running as a tail. */ -const readCaptions = System.create({ components: { read: [caption] } }, (row, { commands }) => { +const readCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { if (row[caption]?.text === "never") commands.despawn(row.entity); }); const idle = System.create({}, () => {}); const after = System.create({}, () => {}); -Finalizer.create({ components: { read: [guarded] } }, () => {}); +Finalizer.create({ components: { with: [guarded] } }, row => { void row[guarded]; }); /** A world shared with a thread on this one, which claims `threads`. */ const joined = (threads: readonly ("remoteTest" | "remoteTestFinalized")[] = ["remoteTest"]): { readonly world: CreatedWorld; readonly remote: World } => { @@ -165,7 +165,7 @@ describe("objects a joined thread fills", () => { await taken.remote.dispose(); await taken.world.dispose(); // Declared in a function, so the build gives it no id, and another thread can't name it. - const unnamed = System.create({ components: { read: [caption] } }, row => { void row[caption]; }); + const unnamed = System.create({ components: { with: [caption] } }, row => { void row[caption]; }); await world.run(unnamed); const channel = new MessageChannel(); world.share(channel.port1); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index e643cf28..ae66023b 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -38,35 +38,35 @@ const spin = (milliseconds: number): void => { }; /** Blocking, since its body reaches `commands`: a tier on the thread, and with `second`, one task. */ -export const first = System.create({ resources: { read: [mark, pause] } }, ({ resources, commands }) => { +export const first = System.create({ }, ({ resources, commands }) => { void commands; calls.first += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(second); }); -export const second = System.create({ resources: { read: [mark], write: [secondRuns] }, after: [first] }, ({ resources, commands }) => { +export const second = System.create({ after: [first] }, ({ resources, commands }) => { void commands; calls.second += resources[mark].text.length; resources[secondRuns] += 1; }); /** Terminal, so a tail, run with `secondTail` as one task after the tick's sync point. */ -export const firstTail = System.create({ resources: { read: [mark, pause] } }, ({ resources }) => { +export const firstTail = System.create({ }, ({ resources }) => { calls.firstTail += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(secondTail); }); /** Terminal, so a tail: removes `removal.target`, once, when it's set. */ -export const removerTail = System.create({ resources: { read: [mark] } }, ({ resources }) => { +export const removerTail = System.create({ }, ({ resources }) => { void resources[mark]; const { target } = removal; removal.target = null; if (target !== null) removal.world?.remove(target); }); -export const secondTail = System.create({ resources: { read: [mark] }, after: [firstTail] }, ({ resources }) => { +export const secondTail = System.create({ after: [firstTail] }, ({ resources }) => { calls.secondTail += resources[mark].text.length; }); @@ -77,6 +77,6 @@ export const secondTail = System.create({ resources: { read: [mark] }, after: [f export const ticking = System.create({ after: [second] }, () => {}); /** Sets how long `first` and `firstTail` spin. */ -export const setPause = System.create({ resources: { write: [pause] } }, ({ resources }, input: { readonly milliseconds: number }) => { +export const setPause = System.create({ }, ({ resources }, input: { readonly milliseconds: number }) => { resources[pause] = input.milliseconds; }); diff --git a/packages/ecs/src/implementation/world/remove.test.ts b/packages/ecs/src/implementation/world/remove.test.ts index c356715f..d5d7b66b 100644 --- a/packages/ecs/src/implementation/world/remove.test.ts +++ b/packages/ecs/src/implementation/world/remove.test.ts @@ -43,7 +43,7 @@ const ticksValue = System.create({ components: { added: [value, parent] } }, () const kept = System.create({}, () => { calls.kept++; }); /** Holds what systems can on the world's thread: an `up` order, a change filter's count, command buffers. */ -const held = System.create({ components: { read: [value], changed: [value] }, up: { through: parent, read: [value] } }, (row, { up }) => { +const held = System.create({ components: { with: [value], changed: [value] }, up: { through: parent } }, (row, { up }) => { calls.held++; void (row[value] + (up?.[value] ?? 0)); }); @@ -53,19 +53,19 @@ const other = System.create({}, () => { calls.other++; }); const pair = System.create({ systems: [alone, other] }); /** In a later tier than `remover`, which removes it during the tick. */ -const late = System.create({ components: { read: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); +const late = System.create({ components: { with: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); const remover = System.create({ before: [late] }, () => { current?.remove(late); }); /** Removes itself partway through its rows. */ -const selfRemoving = System.create({ components: { read: [value] } }, row => { +const selfRemoving = System.create({ components: { with: [value] } }, row => { calls.self += row[value] >= 0 ? 1 : 0; if (calls.self === removeAfter) current?.remove(selfRemoving); }); /** Ordered only through `phase`: removing `phase` would leave them conflicting with no order between them. */ const phase = System.create({}, () => {}); -const writer = System.create({ components: { write: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); -const reader = System.create({ components: { read: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); +const writer = System.create({ components: { with: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); +const reader = System.create({ components: { with: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); /** A world ticking back to back once something is added, with `kept` added. */ const ticking = async (): Promise<{ readonly world: CreatedWorld; readonly ticks: (count: number) => Promise; readonly close: () => Promise }> => { diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts index f8333d0d..4a00f7e8 100644 --- a/packages/ecs/src/implementation/world/render-order.browser.test.ts +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -64,7 +64,7 @@ describe("render systems with an order", () => { const output = own.createBuffer({ size: (count + 16) * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); const tableCounts: number[][] = []; const draw = System.render({ - components: { has: [visible], not: [hidden] }, + components: { with: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth, paintCode: code } }, }, ({ encoder, tables }) => { @@ -131,7 +131,7 @@ describe("render systems with an order", () => { const withImage = expected.flatMap(i => i === 500 ? [[i, i], [500, 5001]] : [[i, i]]); expect(await read()).toEqual(withImage); - const toBack = System.create({ components: { read: [code], write: [depth] } }, row => { + const toBack = System.create({ components: { with: [code, depth] } }, row => { if (row[code] === 10) row[depth] = count + 5; }); await world.run(toBack); @@ -151,7 +151,7 @@ describe("render systems with an order", () => { it("write their order only when the index's entries change or move", async () => { const own = await device(); - const draw = System.render({ components: { has: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); + const draw = System.render({ components: { with: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); const world = createWorld({ [gpuDevice]: own }); await world.run(spawn); await world.run(draw); @@ -169,7 +169,7 @@ describe("render systems with an order", () => { await world.run(draw); expect([sizes, records]).toEqual([[], 0]); // A new key writes its chunk of the column and the order, in elements; the record stays. - const toBack = System.create({ components: { read: [code], write: [depth] } }, row => { + const toBack = System.create({ components: { with: [code, depth] } }, row => { if (row[code] === 10) row[depth] = count + 5; }); await world.run(toBack); @@ -185,7 +185,7 @@ describe("render systems with an order", () => { it("give a shader one table when nothing matches", async () => { const own = await device(); let tables: readonly System.RenderTable[] = []; - const draw = System.render({ components: { has: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); + const draw = System.render({ components: { with: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); const world = createWorld({ [gpuDevice]: own }); await world.run(spawn); expect((await runReported(world, draw)).failures).toEqual([]); diff --git a/packages/ecs/src/implementation/world/reports.test-helper.ts b/packages/ecs/src/implementation/world/reports.test-helper.ts index aec3cb39..ac8544fa 100644 --- a/packages/ecs/src/implementation/world/reports.test-helper.ts +++ b/packages/ecs/src/implementation/world/reports.test-helper.ts @@ -23,7 +23,7 @@ const readerOf = (world: World): Reader => { const known = readers.get(world); if (known !== undefined) return known; const asked: Reader["asked"] = []; - const system = System.create({ observer: true, components: { read: [Report.run] }, lookup: { errorsOf: { read: [Report.errors] } } }, (row, { lookup }) => { + const system = System.create({ observer: true, components: { with: [Report.run] }, lookup: { errorsOf: { with: [Report.errors] } } }, (row, { lookup }) => { const read = asked[0]; const values = row[Report.run]; if (read === undefined || (read.run >= 0 ? values.number !== read.run : values.number <= (read.values?.number ?? -1))) return; @@ -90,7 +90,7 @@ export const followReports = (world: World) => { type Seen = { readonly run: number; readonly failures: readonly Report.Failure[] }; const seen: Seen[] = []; const waiting: (() => void)[] = []; - const system = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + const system = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { seen.push({ run: row[Report.run].number, failures: row[Report.errors].failures }); for (const wake of waiting.splice(0)) wake(); }); @@ -106,7 +106,7 @@ export const followReports = (world: World) => { }; /** Despawns every report, for tests that compare entity order with a world that has none: later runs spawn them again. */ -export const dropReports = System.create({ components: { has: [Report.run] } }, (row, { commands }) => { +export const dropReports = System.create({ components: { with: [Report.run] } }, (row, { commands }) => { commands.despawn(row.entity); }); diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index 094f3f25..20bae7b8 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -18,29 +18,29 @@ declare module "@adobe/ecs" { const nothing = System.create({}, () => {}); const fail = System.create({}, () => { throw new Error("run failed"); }); -const raise = System.create({ resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); const idle = System.create({ observer: true }, () => {}); const spawn = System.create({}, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); -const despawnAll = System.create({ components: { has: [amount] } }, (row, { commands }) => { +const despawnAll = System.create({ components: { with: [amount] } }, (row, { commands }) => { commands.despawn(row.entity); commands.despawn(row.entity); }); -const failingObserver = System.create({ observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const failingObserver = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); /** What a logger following reports saw, by pass: each changed report's run and failures' messages. */ const logged: (readonly [number, readonly string[]])[] = []; -const logger = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { +const logger = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { logged.push([row[Report.run].number, row[Report.errors].failures.map(failure => failure.message)]); }); /** The run numbers of every report, in entity order, and of those with errors. */ let numbers: number[] = []; let withErrors: number[] = []; -const allReports = Index.create({ key: [], components: { has: [Report.run] } }); -const erredReports = Index.create({ key: [], components: { has: [Report.run, Report.errors] } }); -const listReports = System.create({ observer: true, lookup: { all: { index: allReports, read: [Report.run] }, erred: { index: erredReports, read: [Report.run] } } }, ({ lookup }) => { +const allReports = Index.create({ key: [], components: { with: [Report.run] } }); +const erredReports = Index.create({ key: [], components: { with: [Report.run, Report.errors] } }); +const listReports = System.create({ observer: true, lookup: { all: { index: allReports }, erred: { index: erredReports } } }, ({ lookup }) => { const all = lookup.all(); numbers = []; for (let i = 0; i < all.length; i++) numbers.push(all.at(i)[Report.run].number); diff --git a/packages/ecs/src/implementation/world/rows.test.ts b/packages/ecs/src/implementation/world/rows.test.ts index 92e826d4..1d48eb4c 100644 --- a/packages/ecs/src/implementation/world/rows.test.ts +++ b/packages/ecs/src/implementation/world/rows.test.ts @@ -34,17 +34,17 @@ describe("accessor rows", () => { const seen: { entity: Entity; position: Vec3; count: number; flag: boolean }[] = []; const kept: Vec3[] = []; // Both run over two archetypes (with and without the tag), with a system using a different row between them. - const first = System.create({ components: { read: [position, flag], write: [count] } }, row => { + const first = System.create({ components: { with: [position, flag, count] } }, row => { const value = row[position]; kept.push(value); seen.push({ entity: row.entity, position: value, count: row[count], flag: row[flag] }); row[count] = row[count] + 10; }); - const other = System.create({ components: { write: [position] }, after: [first] }, row => { + const other = System.create({ components: { with: [position] }, after: [first] }, row => { const [x, y, z] = row[position]; row[position] = [x + 100, y, z]; }); - const second = System.create({ components: { read: [position, flag], write: [count] }, after: [other] }, row => { + const second = System.create({ components: { with: [position, flag, count] }, after: [other] }, row => { seen.push({ entity: row.entity, position: row[position], count: row[count], flag: row[flag] }); row[count] = row[count] + 1; }); diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index be16c3a5..0a60fb9e 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from '../../interface/entity/entity.js'; -import type { Declaration } from '../../interface/system/declaration.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { Resolved } from '../../interface/system/declaration.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { Commands } from '../../interface/system/commands.js'; import type { Layout, TypedArray } from '../../interface/registry/layout.js'; import { stampChanged, writeF32Lanes, writeGeneral, writeOf, type Archetype, type Storage } from './archetype.js'; @@ -285,7 +285,7 @@ export const rowBinder = (state: State, reads: readonly Key[], writes: readonly * A lookup's matcher, with its component ids resolved once. `find` returns the row of a live entity that matches, or -1, * leaving its archetype in `archetype`. */ -const createMatcher = (state: State, lookup: SystemLookup) => { +const createMatcher = (state: State, lookup: SystemLookupLists) => { const ids = (keys: readonly Key[] | undefined): number[] => (keys ?? []).map(key => idOf(state, key)); const read = ids(lookup.read); const has = ids("has" in lookup ? lookup.has : undefined); @@ -348,7 +348,7 @@ const changedAt = (archetype: Archetype, row: number, ids: readonly number[], si * A lookup returning `read` values keyed by component, for main-thread systems: a new object each call, the shared * empty one for a lookup reading nothing, and with `changed` one row reused, its `changed` as of `since`. */ -export const createObjectLookup = (state: State, lookup: SystemLookup, since: Since) => { +export const createObjectLookup = (state: State, lookup: SystemLookupLists, since: Since) => { const matcher = createMatcher(state, lookup); const keys = lookup.read ?? []; const watched = "index" in lookup ? undefined : lookup.changed; @@ -379,12 +379,12 @@ export const createObjectLookup = (state: State, lookup: SystemLookup, since: Si * A lookup returning `read` values in declared order, for kernels: a new array each call, and with `changed` one array * reused, whether a component it watches may have changed after `since` last. */ -export const createArrayLookup = (state: State, lookup: SystemLookup, since: Since) => { +export const createArrayLookup = (state: State, lookup: SystemLookupLists, since: Since) => { const watched = "index" in lookup ? undefined : lookup.changed; return watched === undefined ? createPlainArrayLookup(state, lookup) : createChangedArrayLookup(state, lookup, watched.map(key => idOf(state, key)), since); }; -const createChangedArrayLookup = (state: State, lookup: SystemLookup, ids: readonly number[], since: Since) => { +const createChangedArrayLookup = (state: State, lookup: SystemLookupLists, ids: readonly number[], since: Since) => { const matcher = createMatcher(state, lookup); const { read } = matcher; const values = new Array(read.length + 1); @@ -398,7 +398,7 @@ const createChangedArrayLookup = (state: State, lookup: SystemLookup, ids: reado }; }; -const createPlainArrayLookup = (state: State, lookup: SystemLookup) => { +const createPlainArrayLookup = (state: State, lookup: SystemLookupLists) => { const matcher = createMatcher(state, lookup); const { read } = matcher; return (entity: Entity): readonly unknown[] | null => { @@ -436,7 +436,7 @@ const never: Since = { value: -1 }; const own = (): object => Object.create({}); /** Resource cells are resolved once per run: they move only at sync points. An unresolved resource throws on access. */ -export const createContext = (state: State, declaration: Pick, { targets = [], commands, indexes, since = never }: Extras = {}): Context => { +export const createContext = (state: State, declaration: Pick, { targets = [], commands, indexes, since = never }: Extras = {}): Context => { const value: { up?: unknown } = own(); const { lookup, resources } = declaration; const names = Object.keys(lookup ?? {}); diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index 23b4e8b6..b4ce135d 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -47,10 +47,10 @@ declare module "@adobe/ecs" { } -Rule.create(identify({ components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); -Rule.create({ components: { has: [slot] }, derive: [visible] }); -Rule.create({ components: { has: [sprite] }, derive: [visible] }); -Rule.create({ components: { has: [tagged] }, derive: [tag] }); +Rule.create(identify({ components: { with: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); +Rule.create({ components: { with: [slot] }, derive: [visible] }); +Rule.create({ components: { with: [sprite] }, derive: [visible] }); +Rule.create({ components: { with: [tagged] }, derive: [tag] }); /** Runs `edit` with commands once, as its own run. */ const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { @@ -71,8 +71,8 @@ const components = async (world: ReturnType, entity: Entity) describe("rules", () => { it("accept a rule declared again the same under its id, as a module evaluated again declares it, and reject a different one", () => { const rules = programRules().length; - Rule.create(identify({ components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); - expect(() => Rule.create(identify({ components: { has: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); + Rule.create(identify({ components: { with: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); + expect(() => Rule.create(identify({ components: { with: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); expect(programRules()).toHaveLength(rules); }); it("derive components where they match, from the schema default, and remove them where they stop", async () => { @@ -96,7 +96,7 @@ describe("rules", () => { }); it("write the default again where a derived component starts over, on a reused row", async () => { - const write = System.create({ components: { write: [slot] } }, row => { row[slot] = 99; }); + const write = System.create({ components: { with: [slot] } }, row => { row[slot] = 99; }); const world = createWorld({ [workers]: 0 }); const step = at(world); let a = Entity.none; @@ -127,7 +127,7 @@ describe("rules", () => { await world.run(spawn); await world.run(observe); expect(await count(world, { required: [late] })).toBe(0); - Rule.create({ components: { has: [mark] }, derive: [late] }); + Rule.create({ components: { with: [mark] }, derive: [late] }); await world.run(spawn); expect(await count(world, { required: [late] })).toBe(6); await world.run(observe); @@ -137,10 +137,10 @@ describe("rules", () => { it("are rejected when they can't hold", async () => { // @ts-expect-error a rule derives only derived components - expect(() => Rule.create({ components: { has: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); - expect(() => Rule.create({ components: { has: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); + expect(() => Rule.create({ components: { with: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); + expect(() => Rule.create({ components: { with: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); // @ts-expect-error a reference changes only through commands - expect(() => System.create({ components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); + expect(() => System.create({ components: { with: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. registerComponent(left, { type: { const: true }, within: right }); expect(() => registerComponent(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts index 78bf6b9d..831fec52 100644 --- a/packages/ecs/src/implementation/world/schedule.test.ts +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import type { Composite } from "../../interface/system/composite.js"; -import type { Declaration } from "../../interface/system/declaration.js"; +import type { Resolved } from "../../interface/system/declaration.js"; import type { Definition } from "../../interface/registry/index.js"; import type { Leaf } from "../../interface/system/leaf.js"; import { scheduleOf } from "../../interface/registry/schedule.js"; @@ -18,7 +18,7 @@ declare module "@adobe/ecs" { /** Systems built as values, not registered, so each test's are its own. */ -const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); +const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration }); const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name)); diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts index d5708cbb..ef53b1bc 100644 --- a/packages/ecs/src/implementation/world/semantics.test.ts +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -31,8 +31,8 @@ const seed = System.create({}, ({ commands }) => { }); const chase = System.create({ kernel: true, - components: { read: [target], write: [score] }, - lookup: { targets: { read: [position] } }, + components: { with: [target, score] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other === null) { @@ -83,7 +83,7 @@ describe("semantics", () => { let entity = Entity.none; const spawn = System.create({}, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); const retag = System.create({}, ({ commands }) => { commands.add(entity, tag, true); }); - const watch = System.create({ components: { has: [score], added: [tag] } }, row => { seen.push(row.entity); }); + const watch = System.create({ components: { with: [score], added: [tag] } }, row => { seen.push(row.entity); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(watch); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index 4236f2a3..e22b4f63 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { createResolved } from '../../interface/system/create-resolved.js'; +import type { Resolved } from '../../interface/system/declaration.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { World } from '../../interface/world/world.js'; import type { Failure } from '../../interface/report/failure.js'; import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, requireDefinition, type Definition } from '../../interface/registry/index.js'; @@ -12,7 +15,6 @@ import { import { createObserverTarget, droppedObserver, endTarget, joinedRun, type ObserverTarget } from './joined-observer.js'; import type { Leaf } from '../../interface/system/leaf.js'; import { Index } from '../../interface/indexes/index-type.js'; -import { System } from '../../interface/system/system.js'; import { createMoveLog, freeMoveLog, type MoveLog } from './move-log.js'; import { lineWords } from './protocol.js'; import { claimThreads, releaseThreads } from './remote.js'; @@ -520,7 +522,7 @@ export const createSharing = (host: SharingHost) => { let index = madeIndexes.get(identity); if (index === undefined) { // `Index.create` checks the declaration as the joined thread's was checked. - index = indexDefinitionOf(Reflect.apply(Index.create, undefined, [{ name: declared.name, key, sorted: declared.sorted, unique: declared.unique, components: { has, not } }])); + index = indexDefinitionOf(Reflect.apply(Index.create, undefined, [{ name: declared.name, key, sorted: declared.sorted, unique: declared.unique, components: { with: has, not } }])); madeIndexes.set(identity, index); } return index; @@ -538,7 +540,7 @@ export const createSharing = (host: SharingHost) => { for (const [field, numbers] of entries) if (numbers !== null && (numbers.length > 0 || field === "changed")) result[field] = keys(numbers); return result; }; - const lookup: { [name: string]: object } = {}; + const lookup: { [name: string]: SystemLookupLists } = {}; for (const each of declared.lookup) { if (each.index === null) { lookup[each.name] = listed([["read", each.read], ["has", each.has], ["not", each.not], ["changed", each.changed]]); @@ -550,14 +552,14 @@ export const createSharing = (host: SharingHost) => { } const { components, resources } = declared; // Unnamed, so other systems' labels stay as they are; its own is the thread's. - const declaration = { + const declaration: Resolved = { observer: true, ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, ...resources === null ? {} : { resources: listed([["read", resources.read], ["changed", resources.changed], ["is", resources.is], ["not", resources.not]]) }, ...declared.lookup.length === 0 ? {} : { lookup }, }; - // `System.create` checks the declaration as the joined thread's was checked; its body runs only there. - const created = requireDefinition(Reflect.apply(System.create, undefined, [declaration, () => {}])); + // Checked as the joined thread's was; its body runs only there. + const created = requireDefinition(createResolved(declaration, () => {})); if (created.kind === "composite") return; proxy = created; } diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index c1703c9e..9cddb7b4 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -41,35 +41,35 @@ const spawnLong = System.create({}, ({ commands }, input: { readonly count: numb }); /** A run over every row that changes nothing, for its sync point. */ -const pass = System.create({ components: { read: [order] } }, row => { void row[order]; }); +const pass = System.create({ components: { with: [order] } }, row => { void row[order]; }); -const rename = System.create({ components: { read: [order], write: [label] } }, (row, _context, input: { readonly suffix: string }) => { +const rename = System.create({ components: { with: [order, label] } }, (row, _context, input: { readonly suffix: string }) => { row[label] = `${textOf(row[order])}${input.suffix}`; }); -const relabel = System.create({ components: { read: [order], has: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { +const relabel = System.create({ components: { with: [order, label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const tag = System.create({ components: { has: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); -const untag = System.create({ components: { has: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); -const unlabel = System.create({ components: { has: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); -const despawn = System.create({ components: { has: [order] } }, (row, { commands }) => commands.despawn(row.entity)); -const annotate = System.create({ components: { read: [order], write: [note] } }, row => { +const tag = System.create({ components: { with: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); +const untag = System.create({ components: { with: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); +const unlabel = System.create({ components: { with: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); +const despawn = System.create({ components: { with: [order] } }, (row, { commands }) => commands.despawn(row.entity)); +const annotate = System.create({ components: { with: [order, note] } }, row => { row[note] = row[order] % 3 === 0 ? null : `note ${row[order]}`; }); -const unannotate = System.create({ components: { write: [note] } }, row => { +const unannotate = System.create({ components: { with: [note] } }, row => { row[note] = null; }); -const noted = System.create({ components: { has: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); -const despawnEven = System.create({ components: { read: [order] } }, (row, { commands }) => { +const noted = System.create({ components: { with: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); +const despawnEven = System.create({ components: { with: [order] } }, (row, { commands }) => { if (row[order] % 2 === 0) commands.despawn(row.entity); }); -const labelAgain = System.create({ components: { read: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { +const labelAgain = System.create({ components: { with: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const edit = System.create({ resources: { write: [mode, title] } }, ({ resources }, input: { readonly title: string }) => { +const edit = System.create({ }, ({ resources }, input: { readonly title: string }) => { resources[mode] = "edit"; resources[title] = input.title; }); diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index a692aab3..15128db3 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -52,25 +52,25 @@ const spin = (milliseconds: number): void => { }; /** On the world's thread: the counter moves on once a tick. */ -export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({ }, ({ resources }) => { resources[counter] += 1; }); /** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: every row's stamp, spot and word take the counter. */ -export const advance = System.create({ components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { with: [stamp, spot, word] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); /** On the world's thread: every row's word as another, of the size of the first ticks', so their records are reused. */ -export const reword = System.create({ components: { write: [word] } }, (row, _context, input: { readonly round: number }) => { +export const reword = System.create({ components: { with: [word] } }, (row, _context, input: { readonly round: number }) => { row[word] = `r${input.round}`; }); /** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ -export const grow = System.create({ resources: { read: [counter], write: [spawning] }, after: [count] }, ({ resources, commands }) => { +export const grow = System.create({ after: [count] }, ({ resources, commands }) => { const value = resources[counter]; for (let k = 0; k < resources[spawning]; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); resources[spawning] = 0; @@ -79,13 +79,13 @@ export const grow = System.create({ resources: { read: [counter], write: [spawni export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ resources: { write: [stall] } }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { +export const setStall = System.create({ }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; }); -export const setSpawning = System.create({ resources: { write: [spawning] } }, ({ resources }, input: { readonly count: number }) => { +export const setSpawning = System.create({ }, ({ resources }, input: { readonly count: number }) => { resources[spawning] = input.count; }); -export const setFailAt = System.create({ resources: { write: [failAt] } }, ({ resources }, input: { readonly at: number }) => { +export const setFailAt = System.create({ }, ({ resources }, input: { readonly at: number }) => { resources[failAt] = input.at; }); @@ -94,7 +94,7 @@ export type Seen = { counter: number; rows: number; wrong: number; labels: numbe export const seen: Seen[] = []; /** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ -export const lead = System.create({ resources: { read: [stall, canvas] } }, ({ resources }) => { +export const lead = System.create({ }, ({ resources }) => { if (resources[stall].mode === stallBefore && resources[canvas].text === "canvas") spin(resources[stall].pause); }); @@ -103,7 +103,7 @@ export const lead = System.create({ resources: { read: [stall, canvas] } }, ({ r * must be the last tick's where it wrote one. */ export const watch = System.create({ - components: { read: [stamp, spot, word], write: [label] }, resources: { read: [counter, stall, failAt] }, after: [advance, grow, lead], + components: { with: [stamp, spot, word, label] }, after: [advance, grow, lead], }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); @@ -119,7 +119,7 @@ export const watch = System.create({ }); /** Not terminal: it reaches its commands, so its tier blocks. */ -export const blocking = System.create({ components: { read: [label] } }, (row, { commands }) => { +export const blocking = System.create({ components: { with: [label] } }, (row, { commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); }); @@ -129,7 +129,7 @@ export const changed = new Map(); * On the tail's thread, ahead of the watcher, reaching its commands, so its tier blocks: counts the labels the watcher * wrote since it last ran, which it sees once the world stamped them, from the watcher's snapshot too. */ -export const tally = System.create({ components: { read: [label], changed: [label] }, resources: { read: [counter] }, after: [count], before: [watch] }, (row, { resources, commands }) => { +export const tally = System.create({ components: { with: [label], changed: [label] }, after: [count], before: [watch] }, (row, { resources, commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); changed.set(resources[counter], (changed.get(resources[counter]) ?? 0) + 1); }); @@ -138,7 +138,7 @@ export const tally = System.create({ components: { read: [label], changed: [labe export const gate: { words: Int32Array | null } = { words: null }; /** On the tail's thread, terminal, reading only that thread's objects: held in its body until the world opens the gate. */ -export const held = System.create({ resources: { read: [canvas] } }, ({ resources }) => { +export const held = System.create({ }, ({ resources }) => { const { words } = gate; if (words === null || resources[canvas].text !== "canvas") return; Atomics.store(words, 1, 1); diff --git a/packages/ecs/src/implementation/world/tails.test.ts b/packages/ecs/src/implementation/world/tails.test.ts index cb38ee55..0fce3674 100644 --- a/packages/ecs/src/implementation/world/tails.test.ts +++ b/packages/ecs/src/implementation/world/tails.test.ts @@ -74,7 +74,7 @@ describe("tails", () => { const { world, remote } = await joined({ loop: driver }, { [always]: true }); await world.run(seed, { count: 10 }); const counters: number[] = []; - const values = System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { counters.push(resources[counter]); }); + const values = System.create({ observer: true }, ({ resources }) => { counters.push(resources[counter]); }); world.add(ticked); world.add(values); await until(Observe.resource(world, counter), value => value >= 5); @@ -83,7 +83,7 @@ describe("tails", () => { const first = counters.findIndex(value => value > 0); expect(counters.slice(first, first + 4)).toEqual([0, 1, 2, 3].map(k => counters[first] + k)); const reports: (readonly [number, readonly string[]])[] = []; - const reading = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] }, resources: { read: [counter] } }, (row, { resources }) => { + const reading = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, (row, { resources }) => { reports.push([resources[counter], row[Report.errors].failures.map(failure => failure.message)]); }); world.add(reading); diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts index f0bff91b..8de7cf41 100644 --- a/packages/ecs/src/implementation/world/target.test.ts +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -16,16 +16,16 @@ let spawned: Entity[] = []; const spawn = System.create({}, ({ commands }) => { spawned = [commands.spawn([amount, tagged])({ [amount]: 1 }), commands.spawn([amount, tagged])({ [amount]: 2 }), commands.spawn([amount])({ [amount]: 3 })]; }); -const bump = System.create({ target: "entity", components: { write: [amount], has: [tagged] } }, (row, _context, input: { readonly entity: Entity }) => { +const bump = System.create({ target: "entity", components: { with: [amount, tagged] } }, (row, _context, input: { readonly entity: Entity }) => { row[amount] += 10; }); -const remove = System.create({ target: "entity", components: { has: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { +const remove = System.create({ target: "entity", components: { with: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); let amounts: number[] = []; let changed: number[] = []; -const readAmounts = System.create({ observer: true, components: { read: [amount] } }, row => { amounts.push(row[amount]); }); -const watchChanged = System.create({ observer: true, components: { read: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); +const readAmounts = System.create({ observer: true, components: { with: [amount] } }, row => { amounts.push(row[amount]); }); +const watchChanged = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); const inline = () => createWorld({ [workers]: 0 }); const amountsOf = async (world: ReturnType): Promise => { @@ -62,7 +62,7 @@ describe("systems on one entity", () => { it("take their entity from an entity field of their input, and have components, no kernel, up, order or reduce", () => { // @ts-expect-error its target isn't an entity field - expect(() => System.create({ target: "count", components: { has: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) + expect(() => System.create({ target: "count", components: { with: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) .toThrow('runs on one entity, so it takes it from an input field "count" holding an entity.'); // @ts-expect-error it needs components expect(() => System.create({ target: "entity" }, (_context, input: { readonly entity: Entity }) => {})).toThrow('runs on one entity, so it needs components.'); diff --git a/packages/ecs/src/implementation/world/thread-systems.fixture.ts b/packages/ecs/src/implementation/world/thread-systems.fixture.ts index aefc3f99..9b10af64 100644 --- a/packages/ecs/src/implementation/world/thread-systems.fixture.ts +++ b/packages/ecs/src/implementation/world/thread-systems.fixture.ts @@ -16,7 +16,7 @@ import { reportOf, runReported } from "./reports.test-helper.js"; */ /** A system on the worker's thread, which only this module declares, so the worker never imported it. */ -const onlyHere = System.create({ components: { read: [caption] } }, row => { void row[caption]; }); +const onlyHere = System.create({ components: { with: [caption] } }, row => { void row[caption]; }); type Worker = { readonly ask: (message: ToThreadSystemsWorker, transfer?: readonly unknown[]) => Promise; diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 5ffee975..34aa83d4 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -53,7 +53,7 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con const makeNote = (text: string): Note => ({ text, read: () => text }); /** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ -export const seed = System.create({ resources: { write: [nextUid] } }, ({ resources, commands }, input: { readonly count: number }) => { +export const seed = System.create({ }, ({ resources, commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); @@ -61,18 +61,18 @@ export const seed = System.create({ resources: { write: [nextUid] } }, ({ resour }); /** On the world's thread: positions move by their velocity. */ -export const move = System.create({ components: { read: [velocity], write: [position] } }, row => { +export const move = System.create({ components: { with: [velocity, position] } }, row => { const [x, y] = row[position]; row[position] = [x + row[velocity], y - row[velocity]]; }); /** On the thread: captions written in place, from the structs it reads. */ -export const describe = System.create({ components: { read: [position, velocity], write: [caption] }, after: [move] }, row => { +export const describe = System.create({ components: { with: [position, velocity, caption] }, after: [move] }, row => { if (row[velocity] !== 0) row[caption] = { text: `at ${row[position][0]}` }; }); /** On the thread: commands giving its objects values it stages, struct values and tags, and despawns. */ -export const relabel = System.create({ components: { read: [uid, caption, position], has: [velocity] }, after: [describe] }, (row, { commands }) => { +export const relabel = System.create({ components: { with: [uid, caption, position, velocity] }, after: [describe] }, (row, { commands }) => { const id = row[uid]; if (row[position][0] > 40) { commands.despawn(row.entity); @@ -85,44 +85,44 @@ export const relabel = System.create({ components: { read: [uid, caption, positi }); /** On the thread, reading its object resource: spawns giving its objects values, with a struct resource's next uid. */ -export const breed = System.create({ resources: { read: [paint], write: [nextUid] }, after: [relabel] }, ({ resources, commands }) => { +export const breed = System.create({ after: [relabel] }, ({ resources, commands }) => { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, note])({ [uid]: id, [position]: [0, id], [velocity]: 1, [caption]: { text: `${resources[paint].text} ${id}` }, [note]: makeNote(`bred ${id}`) }); }); /** On the thread: a reduction over its objects and structs. */ -export const tally = System.create({ components: { read: [caption, position] }, resources: { reduce: [total] }, after: [breed] }, (row, { reduce }) => { +export const tally = System.create({ components: { with: [caption, position] }, after: [breed] }, (row, { reduce }) => { reduce[total]((row[caption]?.text.length ?? 0) + row[position][1]); }); /** On the thread: a change filter on its own objects, writing a struct resource. */ -export const watchCaptions = System.create({ components: { changed: [caption] }, resources: { write: [changes] }, after: [tally] }, (_row, { resources }) => { +export const watchCaptions = System.create({ components: { changed: [caption] }, after: [tally] }, (_row, { resources }) => { resources[changes] += 1; }); /** On the thread, while `enabled` holds: writes its object resource, and a struct resource from it. */ -export const repaint = System.create({ resources: { write: [paint, painted], is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { +export const repaint = System.create({ resources: { is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { resources[paint] = { text: `${resources[paint].text}+` }; resources[painted] = resources[paint].text.length; }); /** On the thread: a lookup reading an entity's structs and this thread's objects. */ -export const peek = System.create({ components: { has: [uid, note] }, lookup: { self: { read: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { +export const peek = System.create({ components: { with: [uid, note] }, lookup: { self: { with: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { const self = lookup.self(row.entity); if (self !== null && ((self[caption]?.text.length ?? 0) + self[position][1]) % 2 === 1) commands.add(row.entity, flagged); }); /** On the world's thread, beside `tally`: a world object from the structs. */ -export const keep = System.create({ components: { read: [uid], write: [kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { +export const keep = System.create({ components: { with: [uid, kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { row[kept] = `${row[kept]}.${row[uid] % 10}`; }); /** On the thread, failing after its commands: later tiers are skipped, and its commands still apply. */ -export const fail = System.create({ components: { read: [uid, caption] } }, (row, { commands }) => { +export const fail = System.create({ components: { with: [uid, caption] } }, (row, { commands }) => { commands.add(row.entity, flagged); if (row[uid] === 3 && row[caption] !== undefined) throw new Error("threadSystemsTest failure"); }); -export const afterFail = System.create({ components: { read: [caption] }, after: [fail] }, (row, { commands }) => { +export const afterFail = System.create({ components: { with: [caption] }, after: [fail] }, (row, { commands }) => { if (row[caption] !== undefined) commands.despawn(row.entity); }); export const failing = System.create({ systems: [fail, afterFail] }); @@ -131,12 +131,12 @@ export const failing = System.create({ systems: [fail, afterFail] }); export const tick = System.create({ systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); /** On the thread, with input, which comes to it by message. */ -export const stamp = System.create({ components: { read: [uid], write: [caption] } }, (row, _context, input: { readonly text: string }) => { +export const stamp = System.create({ components: { with: [uid, caption] } }, (row, _context, input: { readonly text: string }) => { if (row[uid] === 1) row[caption] = { text: input.text }; }); /** Disables `repaint`, from the world's thread. */ -export const disable = System.create({ resources: { write: [enabled] } }, ({ resources }) => { +export const disable = System.create({ }, ({ resources }) => { resources[enabled] = false; }); @@ -173,7 +173,7 @@ const reading: { entities: readonly Entity[]; objects: readonly { readonly [name /** Each entity's objects of the thread, as data, a note by its text: none for an entity that's dead or has no uid. */ const readObjects = System.create({ observer: true, - lookup: { ids: { has: [uid] }, captions: { read: [caption] }, notes: { read: [note] } }, + lookup: { ids: { with: [uid] }, captions: { with: [caption] }, notes: { with: [note] } }, }, ({ lookup }) => { reading.objects = reading.entities.map(entity => { const objects: { [name: string]: unknown } = {}; @@ -245,36 +245,36 @@ export const crowd = System.create({}, ({ commands }, input: { readonly count: n for (let k = 0; k < input.count; k++) commands.spawn([uid, caption])({ [uid]: 1000 + k }); }); /** On the world's thread: every crowd entity but uid 1000 moves archetype, flagged on one run and not on the next. */ -export const flagCrowd = System.create({ components: { read: [uid], has: [caption], not: [flagged] } }, (row, { commands }) => { +export const flagCrowd = System.create({ components: { with: [uid, caption], not: [flagged] } }, (row, { commands }) => { if (row[uid] !== 1000) commands.add(row.entity, flagged); }); -export const unflagCrowd = System.create({ components: { has: [caption, flagged] } }, (row, { commands }) => { +export const unflagCrowd = System.create({ components: { with: [caption, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); /** On the thread: writes uid 1000's caption in place. */ -export const write = System.create({ components: { read: [uid], write: [caption] } }, row => { +export const write = System.create({ components: { with: [uid, caption] } }, row => { if (row[uid] === 1000) row[caption] = { text: "written" }; }); /** On the thread: counts captions it holds that the thread wrote, into a struct resource. */ -export const countWritten = System.create({ components: { read: [caption] }, resources: { write: [changes] } }, (row, { resources }) => { +export const countWritten = System.create({ components: { with: [caption] } }, (row, { resources }) => { if (row[caption]?.text === "written") resources[changes] += 1; }); /** On the thread: gives an object of the world's thread a value, which can't cross, so its command drops and it fails. */ -export const giveKept = System.create({ components: { read: [caption] } }, (row, { commands }) => { +export const giveKept = System.create({ components: { with: [caption] } }, (row, { commands }) => { if (row[caption] !== undefined) commands.add(row.entity, kept, "given there"); }); /** On the thread: spawns giving an object of the world's thread a value, which throws when it's issued. */ -export const spawnKept = System.create({ resources: { read: [paint] } }, ({ resources, commands }) => { +export const spawnKept = System.create({ }, ({ resources, commands }) => { commands.spawn([uid, kept])({ [uid]: resources[paint].text.length, [kept]: "spawned there" }); }); /** On the thread: runs until its thread ends, so its task never completes. */ -export const stall = System.create({ resources: { read: [paint] } }, ({ resources }) => { +export const stall = System.create({ }, ({ resources }) => { while (resources[paint].text !== "never") { /* until the thread ends */ } }); /** On the world's thread: counts its runs, ticking with nothing on the thread. */ -export const count = System.create({ resources: { write: [changes] } }, ({ resources }) => { +export const count = System.create({ }, ({ resources }) => { resources[changes] += 1; }); /** @@ -294,12 +294,12 @@ const setQueued = (task: () => void): void => { ranHere.atDue = -1; }; /** On the thread: counts its runs, so the world's ticks post it a task each. */ -export const countThere = System.create({ resources: { read: [paint], write: [changes] } }, ({ resources }) => { +export const countThere = System.create({ }, ({ resources }) => { if (resources[paint].text !== "never") resources[changes] += 1; }); /** On the thread: counts its runs there in `ranHere`; it reaches its commands, so it blocks its tier, a task a tick. */ -export const counted = System.create({ resources: { read: [paint] } }, ({ resources, commands }) => { +export const counted = System.create({ }, ({ resources, commands }) => { void resources[paint]; void commands; ranHere.runs++; diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 67493b64..937e3268 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -2,7 +2,7 @@ import type { Entity } from '../../interface/entity/entity.js'; import type { Commands } from '../../interface/system/commands.js'; import type { Leaf } from '../../interface/system/leaf.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy, whyUnregistered } from '../../interface/registry/index.js'; import { layoutOf, objectLayout, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; import type { StringStore } from './strings.js'; @@ -1144,7 +1144,7 @@ const localStorage = (component: number, layout: Layout, views: Views, values: u }; /** A lookup returning `read` values keyed by component, from this thread's views. */ -const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { +const lookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const read = (lookup.read ?? []).map(host.numberOf); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); @@ -1170,7 +1170,7 @@ const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { }; /** A lookup reading nothing, from this thread's views: the shared empty row for every entity it finds. */ -const emptyLookupOf = (host: RunnerHost, lookup: SystemLookup) => { +const emptyLookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); return (entity: Entity): object | null => { @@ -1191,7 +1191,7 @@ const emptyLookupOf = (host: RunnerHost, lookup: SystemLookup) => { * a component `watched` names may have changed in it after `since`. A GPU kernel's column stamps reach this thread as * row ticks; a component keeping no ticks counts as changed. */ -const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readonly Key[], since: Since) => { +const changedLookupOf = (host: RunnerHost, lookup: SystemLookupLists, watched: readonly Key[], since: Since) => { const read = (lookup.read ?? []).map(host.numberOf); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); @@ -1224,7 +1224,7 @@ const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readon }; /** The lookup by entity a declaration chooses, once: with `changed`, reading nothing, or reading values. */ -const entityLookupOf = (host: RunnerHost, lookup: SystemLookup, since: Since) => { +const entityLookupOf = (host: RunnerHost, lookup: SystemLookupLists, since: Since) => { const watched = "index" in lookup ? undefined : lookup.changed; if (watched !== undefined) return changedLookupOf(host, lookup, watched, since); return (lookup.read ?? []).length === 0 ? emptyLookupOf(host, lookup) : lookupOf(host, lookup); diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index d7a41f19..66789691 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -14,8 +14,8 @@ declare module "@adobe/ecs" { } } -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const start = System.create({ resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); +const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); +const start = System.create({ }, ({ resources }) => { resources[always] = true; }); describe("a frame cadence", () => { it("ticks on animation frames, with their time", async () => { diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 79dfd595..3111541a 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -12,11 +12,11 @@ declare module "@adobe/ecs" { } } -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); describe("ticks", () => { it("reject a system that conflicts with one added with no order between them, and one with input", async () => { - const other = System.create({ resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); + const other = System.create({ }, ({ resources }) => { resources[frames] = 0; }); const withInput = System.create({}, (_context, input: { readonly by: number }) => {}); await using world = createWorld({ [workers]: 0 }); world.add(count); diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts index 5302cf4c..0bfcce45 100644 --- a/packages/ecs/src/implementation/world/traits.test.ts +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -50,7 +50,7 @@ describe("traits", () => { let first = Entity.none; let found: unknown = undefined; const spawn = System.create({}, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); - const lookup = System.create({ lookup: { scores: { read: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); + const lookup = System.create({ lookup: { scores: { with: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(lookup); diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts index 752d6bb8..c5b7b8b5 100644 --- a/packages/ecs/src/implementation/world/transfers.test-helper.ts +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -43,28 +43,26 @@ export const stored: Stored[] = []; /** The values systems gave components, on the thread they ran on. */ export const kept: unknown[] = []; -const store = (value: unknown, resources: { [order]: number }): void => { +const store = (value: unknown, at: number): void => { const bytes = value instanceof ArrayBuffer ? value.byteLength : -1; - stored.push({ bytes, first: bytes > 0 && value instanceof ArrayBuffer ? new Uint8Array(value)[0] : -1, order: resources[order]++, value }); + stored.push({ bytes, first: bytes > 0 && value instanceof ArrayBuffer ? new Uint8Array(value)[0] : -1, order: at, value }); }; /** On "transfersTest": records the buffer it's given. */ -export const storeThere = System.create({ resources: { read: [anchor], write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { - if (resources[anchor].text === "anchor") store(input.buffer, resources); +export const storeThere = System.create({}, ({ resources }, input: { readonly buffer: unknown }) => { + if (resources[anchor].text === "anchor") store(input.buffer, resources[order]++); }); /** On the world's thread: records the buffer it's given. */ -export const storeHere = System.create({ resources: { write: [order] } }, ({ resources }, input: { readonly buffer: unknown }) => { - store(input.buffer, resources); +export const storeHere = System.create({}, ({ resources }, input: { readonly buffer: unknown }) => { + store(input.buffer, resources[order]++); }); /** On the world's thread: what a run from a joined thread saw of the order, by its value. */ export const marks: { readonly value: number; readonly order: number }[] = []; -export const mark = System.create({ resources: { write: [order] } }, ({ resources }, input: { readonly value: number }) => { +export const mark = System.create({}, ({ resources }, input: { readonly value: number }) => { marks.push({ value: input.value, order: resources[order]++ }); }); /** On "transfersTest": gives an entity the port or bitmap it's given. */ -export const attachThere = System.create({ - resources: { read: [anchor] }, -}, ({ resources, commands }, input: { readonly entity: Entity; readonly value: unknown }) => { +export const attachThere = System.create({}, ({ resources, commands }, input: { readonly entity: Entity; readonly value: unknown }) => { if (resources[anchor].text !== "anchor") return; kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, port, input.value); @@ -77,7 +75,7 @@ export const attachHere = System.create({}, ({ commands }, input: { readonly ent else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmapHere, input.value); }); /** On "transfersTest": says hello through the port it's given, which only moving it there, not copying it, gives. */ -export const greetThere = System.create({ resources: { read: [anchor] } }, ({ resources }, input: { readonly port: unknown }) => { +export const greetThere = System.create({}, ({ resources }, input: { readonly port: unknown }) => { if (input.port instanceof MessagePort) input.port.postMessage(resources[anchor].text); }); @@ -91,7 +89,7 @@ export const spawnHere = System.create({}, ({ commands }, input: { readonly valu * "transfersTest", it fails to register, so a run of it never runs. */ export const unregistered = System.create({ - components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, + components: { with: [port, depth] }, up: { through: parent }, }, (row, { up }, input: { readonly value: unknown }) => { row[depth] = (up === null ? 0 : up[depth]) + (row[port] === input.value ? 1 : 0); }); diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts index 35b2a968..4d061e0c 100644 --- a/packages/ecs/src/implementation/world/up-tree.test.ts +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -26,15 +26,15 @@ declare module "@adobe/ecs" { const propagateFull = System.create({ kernel: true, - components: { read: [local], write: [full], not: [hidden] }, - up: { through: parent, read: [full] }, + components: { with: [local, full], not: [hidden] }, + up: { through: parent }, }, (row, { up }) => { row[full] = (up === null ? 0 : up[full]) + row[local]; }); const propagateFiltered = System.create({ kernel: true, - components: { read: [local], write: [filtered], changed: [local], not: [hidden] }, - up: { through: parent, read: [filtered] }, + components: { with: [local, filtered], changed: [local], not: [hidden] }, + up: { through: parent }, }, (row, { up }) => { row[filtered] = (up === null ? 0 : up[filtered]) + row[local]; }); diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts index 670ab8d6..5c7d5f59 100644 --- a/packages/ecs/src/implementation/world/up-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -23,8 +23,8 @@ declare module "@adobe/ecs" { const propagate = System.create({ kernel: true, - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local] * 2; }); @@ -38,7 +38,7 @@ const grow = System.create({}, ({ commands }) => { } } }); -const nudge = System.create({ components: { write: [local] } }, row => { +const nudge = System.create({ components: { with: [local] } }, row => { if (row[local] === 7) row[local] = 70; }); diff --git a/packages/ecs/src/implementation/world/up.browser.test.ts b/packages/ecs/src/implementation/world/up.browser.test.ts index 51ada68b..2361fb58 100644 --- a/packages/ecs/src/implementation/world/up.browser.test.ts +++ b/packages/ecs/src/implementation/world/up.browser.test.ts @@ -26,8 +26,8 @@ import type { F32 } from "../../interface/math/index.js"; const place = System.create({ kernel: "gpu", - components: { read: [offset], write: [placed] }, - up: { through: parent, read: [placed] }, + components: { with: [offset, placed] }, + up: { through: parent }, }, (row, { up }) => { row[placed] = up === null ? row[offset] : up[placed] + row[offset]; }); diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index 22301dcf..05430733 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -33,37 +33,37 @@ declare module "@adobe/ecs" { const ran: string[] = []; const propagate = System.create({ - components: { read: [local, label], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChanged = System.create({ - components: { read: [local, label], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateAdded = System.create({ - components: { read: [local, label], write: [world], added: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world], added: [local] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChangedKernel = System.create({ kernel: true, - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateKernel = System.create({ kernel: true, - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); @@ -100,8 +100,8 @@ const worlds = async (value: (entity: Entity) => Promise): Promis const show = System.create({ kernel: true, - components: { read: [visible], write: [shown] }, - up: { through: parent, read: [shown] }, + components: { with: [visible, shown] }, + up: { through: parent }, }, (row, { up }) => { row[shown] = up === null ? row[visible] : up[shown] && row[visible]; }); @@ -121,7 +121,7 @@ const spawnMixed = System.create({}, ({ commands }) => { const growParents = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([local, world, anchor])({ [local]: 0, [world]: 0, [anchor]: true }); }); -const shift = System.create({ components: { write: [local], has: [anchor] } }, row => { row[local] += 1; }); +const shift = System.create({ components: { with: [local, anchor] } }, row => { row[local] += 1; }); describe("up", () => { for (const [kind, system] of [["main-thread", propagate], ["kernel", propagateKernel]] as const) { @@ -293,6 +293,6 @@ describe("up", () => { it("rejects a through that isn't a reference, for untyped declarations", async () => { // @ts-expect-error through is a reference component - expect(() => System.create({ components: { write: [world] }, up: { through: local, read: [world] } }, row => { row[world] = 0; })).toThrow('up.through "upTest/local" isn\'t a reference component.'); + expect(() => System.create({ components: { with: [world] }, up: { through: local } }, row => { row[world] = 0; })).toThrow('up.through "upTest/local" isn\'t a reference component.'); }); }); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts index eff4e4c4..14706db9 100644 --- a/packages/ecs/src/implementation/world/up.ts +++ b/packages/ecs/src/implementation/world/up.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Up } from '../../interface/system/access.js'; +import type { UpLists } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { generationOf, indexOf } from './entities.js'; import { journalEnd } from './journal.js'; @@ -76,7 +76,7 @@ let groupParents = new Int32Array(0); * of every row, patched for the entities whose row, level or parent changed. A filtered run marks the rows that may * pass, by depth, and lists only those. */ -export const createUp = (state: State, up: Up, isFiltered: boolean) => { +export const createUp = (state: State, up: UpLists, isFiltered: boolean) => { const through = idOf(state, up.through); const readKeys: readonly Key[] = up.read ?? []; const read = readKeys.map(key => idOf(state, key)); diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 83a74168..930dd4fd 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -50,7 +50,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Despawns every seventh entity and spawns replacements, so some targets go stale and their indexes are reused. */ -const cull = System.create({ components: { read: [position], has: [bounces] } }, (row, { commands }, input: { readonly frame: number }) => { +const cull = System.create({ components: { with: [position, bounces] } }, (row, { commands }, input: { readonly frame: number }) => { if ((row.entity >>> 0) % 7 !== input.frame % 7) return; commands.despawn(row.entity); commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: row[position], [velocity]: [1, 1], [bounces]: 0, [flipped]: true, [score]: 0, [target]: row.entity }); @@ -60,8 +60,7 @@ const halve = (value: number) => value * 0.5; const integrate = System.create({ kernel: true, - components: { write: [position, velocity] }, - resources: { read: [seconds] }, + components: { with: [position, velocity] }, }, (row, { resources }) => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; @@ -72,8 +71,7 @@ const integrate = System.create({ const bounce = System.create({ kernel: true, - components: { write: [position, velocity, bounces, flipped] }, - resources: { read: [floor] }, + components: { with: [position, velocity, bounces, flipped] }, after: [integrate], }, (row, { resources }) => { const [x, y] = row[position]; @@ -91,7 +89,7 @@ const bounce = System.create({ const tally = System.create({ kernel: true, - components: { read: [bounces, flipped, position], write: [score] }, + components: { with: [bounces, flipped, position, score] }, after: [bounce], }, row => { let total = 0; @@ -106,7 +104,7 @@ const tally = System.create({ const scale = System.create({ kernel: true, - components: { write: [score] }, + components: { with: [score] }, }, (row, _context, input: { readonly factor: number }) => { row[score] = row[score] * input.factor; }); @@ -114,8 +112,7 @@ const scale = System.create({ /** Component-wise vector math: one f32x4 operation per row. */ const drift = System.create({ kernel: true, - components: { read: [velocity], write: [position] }, - resources: { read: [seconds] }, + components: { with: [velocity, position] }, after: [tally], }, (row, { resources }) => { const [x, y] = row[position]; @@ -127,7 +124,7 @@ const drift = System.create({ /** Runs only for rows whose bounces changed since it last ran. */ const react = System.create({ kernel: true, - components: { write: [score], changed: [bounces] }, + components: { with: [score], changed: [bounces] }, after: [tally], }, row => { row[score] = row[score] + 1000; @@ -136,8 +133,8 @@ const react = System.create({ /** A lookup per row: found targets pull, missing or stale ones cost a point. */ const chase = System.create({ kernel: true, - components: { read: [target], write: [score] }, - lookup: { targets: { read: [position, flipped] } }, + components: { with: [target, score] }, + lookup: { targets: { with: [position, flipped] } }, after: [react, drift], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -175,17 +172,17 @@ const simulate = async (wasm: boolean, simd = false): Promise => { }; /** Counts the runs of rows whose score changed. */ -const recount = System.create({ kernel: true, components: { write: [bounces], changed: [score] } }, row => { +const recount = System.create({ kernel: true, components: { with: [bounces], changed: [score] } }, row => { row[bounces] += 1; }); const chosen = new Set(); -const rescore = System.create({ components: { write: [score] } }, row => { +const rescore = System.create({ components: { with: [score] } }, row => { if (chosen.has(row.entity)) row[score] += 1; }); /** Keeps ticks of `score` without observing its changes, so kernels writing it mute their stamps. */ const scoreAdded = System.create({ components: { added: [score] } }, () => {}); -const raise = System.create({ kernel: true, components: { write: [score] } }, row => { +const raise = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] += 1; }); let raised = 0; diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index 284196bb..a34af434 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -74,8 +74,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) const integrate = System.create({ kernel: true, - components: { read: [velocity], write: [position] }, - resources: { read: [seconds] }, + components: { with: [velocity, position] }, }, (row, { resources }) => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; @@ -85,7 +84,7 @@ const integrate = System.create({ /** Structural commands from workers: rows past a threshold spawn a marker and lose their velocity. */ const split = System.create({ kernel: true, - components: { read: [position], has: [velocity] }, + components: { with: [position, velocity] }, after: [integrate], }, (row, { commands }) => { const [x, y] = row[position]; @@ -98,8 +97,8 @@ const split = System.create({ /** Lookups from workers: offset by the distance to the target. */ const follow = System.create({ kernel: true, - components: { read: [position, target], write: [offset] }, - lookup: { targets: { read: [position] } }, + components: { with: [position, target, offset] }, + lookup: { targets: { with: [position] } }, after: [split], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -112,7 +111,7 @@ const main = System.create({ systems: [integrate, split, follow] }); /** Every row writes the same entity, so only merge order decides which value survives. */ const stamp = System.create({ kernel: true, - components: { read: [position] }, + components: { with: [position] }, }, (row, { commands }, input: { readonly entity: Entity }) => { const [x, y] = row[position]; commands.add(input.entity, offset, x * 1000 + y); @@ -121,7 +120,7 @@ const stamp = System.create({ /** Despawns entity 1, the resource entity, since index 0 is never an entity and registration reserves the next first. */ const unlink = System.create({ kernel: true, - components: { has: [target] }, + components: { with: [target] }, }, (_, { commands }) => { // @ts-expect-error an untyped caller can pass any number commands.despawn(1); @@ -137,7 +136,7 @@ const sources = System.create({}, ({ commands }) => { /** A spawn per row, from wherever its chunk runs; with `edit`, each spawn is also added to and removed from. */ const emit = System.create({ kernel: true, - components: { read: [offset], not: [spawned] }, + components: { with: [offset], not: [spawned] }, }, (row, { commands }, input: { readonly edit: boolean }) => { const entity = commands.spawn([position, spawned, tagC])({ [position]: [row[offset], -row[offset]] }); if (input.edit) { @@ -147,11 +146,11 @@ const emit = System.create({ }); const emitted = { seen: 0 }; -const countEmitted = System.create({ components: { added: [position], has: [spawned] } }, () => { emitted.seen++; }); +const countEmitted = System.create({ components: { added: [position], with: [spawned] } }, () => { emitted.seen++; }); const gathered: Entity[] = []; -const gatherPositioned = System.create({ observer: true, components: { has: [position] } }, row => { gathered.push(row.entity); }); -const gatherSpawned = System.create({ observer: true, components: { has: [spawned] } }, row => { gathered.push(row.entity); }); +const gatherPositioned = System.create({ observer: true, components: { with: [position] } }, row => { gathered.push(row.entity); }); +const gatherSpawned = System.create({ observer: true, components: { with: [spawned] } }, row => { gathered.push(row.entity); }); /** The entities an observer row system passes, in storage order. */ const storageOrder = async (world: World, gather: System): Promise => { diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts index 39961565..0bb3fe90 100644 --- a/packages/ecs/src/interface/archetype/archetype.type-test.ts +++ b/packages/ecs/src/interface/archetype/archetype.type-test.ts @@ -25,11 +25,11 @@ expectType>( export const one: readonly Component.Key[] = particle; // A plain readonly list of components is an archetype too. export const plain = [position] as const; -export const moveOver = System.create({ components: { write: plain } }, row => { row[position] += 1; }); +export const moveOver = System.create({ components: { with: [...plain] } }, row => { row[position] += 1; }); // @ts-expect-error a resource is never in an archetype Archetype.create([position, scale]); // @ts-expect-error nor is a string Archetype.create(["position"]); // Archetypes go wherever component lists do. -export const move = System.create({ components: { write: particle } }, row => { row[position] += row[velocity]; }); -export const read = System.create({ observer: true, components: { read: particle } }, row => { void row[velocity]; }); +export const move = System.create({ components: { with: [...particle] } }, row => { row[position] += row[velocity]; }); +export const read = System.create({ observer: true, components: { with: [...particle] } }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/interface/finalizer/body.ts b/packages/ecs/src/interface/finalizer/body.ts index 752acab7..45605032 100644 --- a/packages/ecs/src/interface/finalizer/body.ts +++ b/packages/ecs/src/interface/finalizer/body.ts @@ -1,28 +1,24 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; -import type { Resource } from '../resource/resource.js'; import type { Commands } from '../system/commands.js'; import type { Listed, Property } from '../system/property.js'; -import type { LookupFunction, ResourceValues, Values } from '../system/values.js'; +import type { LookupFunction } from '../system/values.js'; +import type { WriteResources } from '../system/body.js'; import type { ChangesDeclaration, Declaration } from './declaration.js'; import type { Essential, ReadonlyValue } from '../component/value.js'; import type { Elsewhere, ThreadsOf } from '../system/threads.js'; -/** The entity that stopped matching, with its `read` values as they are before the sync point applies. */ +/** The entity that stopped matching, with the values of its `with` as they are before the sync point applies. */ export type Row = - { readonly entity: Entity } & { readonly [K in Listed, "read"> & Component.Key]: Component.Value }; - -type Resources = Listed, "read"> | Listed, "write">; + { readonly entity: Entity } & { readonly [K in Listed, "with"> & Component.Key]: Component.Value }; /** - * Resources and lookups, each present only when declared, and the commands it issues for the next sync point: none on - * another thread than the world's, which runs after the sync point. + * Every resource, its lookups when declared, and the commands it issues for the next sync point: none on another + * thread than the world's, which runs after the sync point and writes no resources. */ export type Context = - ([Resources] extends [never] ? {} : { - readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues>; - }) + { readonly resources: WriteResources } & ([keyof Property] extends [never] ? {} : { readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; }) diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index 6ce84ca5..cb8f15be 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { identify } from "../registry/ids.test-helper.js"; +import { giveAccess, identify } from "../registry/ids.test-helper.js"; import { programFinalizers, programRules } from "../registry/index.js"; import { Rule } from "../rule/rule.js"; import { clock } from "../resources/index.js"; @@ -33,18 +33,20 @@ declare module "@adobe/ecs" { } -/** Declares as an untyped caller would, bypassing the types' checks. */ -const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object): unknown => Reflect.apply(constructor, undefined, [declaration, () => {}]); +/** Declares as an untyped caller would, bypassing the types' checks, its body using what `uses` says, as the build would infer. */ +const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object, uses: Parameters[1] = {}): unknown => + Reflect.apply(constructor, undefined, [giveAccess(declaration, uses), () => {}]); describe("Finalizer.create and Finalizer.changes", () => { it("record finalizers in creation order, keep the place of one declared again under its id with its new body, and reject its id with another declaration", () => { - const release = Finalizer.create(identify({ components: { read: [node] } }, "f.ts#release"), () => {}); + const release = Finalizer.create(identify({ components: { with: [node] } }, "f.ts#release"), () => {}); const save = Finalizer.changes({ watch: [label] }, () => {}); expect(programFinalizers().map(finalizer => finalizer.name)).toEqual([release.name, save.name]); const body = (): void => {}; - expect(Finalizer.create(identify({ components: { read: [node] } }, "f.ts#release"), body)).toBe(release); + // A body the build doesn't see is given what it uses. + expect(Finalizer.create(giveAccess(identify({ components: { with: [node] } }, "f.ts#release")), body)).toBe(release); expect(programFinalizers()[0]?.kind === "finalizer" ? programFinalizers()[0]?.body : null).toBe(body); - expect(() => Finalizer.create(identify({ components: { read: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.create(identify({ components: { with: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); expect(() => Finalizer.changes(identify({}, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); expect(programFinalizers()).toHaveLength(2); }); @@ -57,82 +59,82 @@ describe("Finalizer.create and Finalizer.changes", () => { expect(programFinalizers().slice(-3)).toEqual([second, third, first]); }); it("order finalizers by before and after, and reject a cycle or a resource conflict with no order", () => { - const late = Finalizer.create({ components: { read: [label] }, resources: { read: [time] } }, () => {}); - const early = Finalizer.create({ components: { read: [label] }, resources: { write: [time] }, before: [late] }, () => {}); + const late = Finalizer.create({ components: { with: [label] } }, (_, { resources }) => { void resources[time]; }); + const early = Finalizer.create({ components: { with: [label] }, before: [late] }, (_, { resources }) => { resources[time] = 1; }); expect(programFinalizers().map(finalizer => finalizer.name).filter(name => name === early.name || name === late.name)).toEqual([early.name, late.name]); - expect(() => Finalizer.create({ components: { read: [label] }, resources: { read: [time] } }, () => {})).toThrow('both access "typesFinalizerTest/time", one writing it'); - expect(() => Finalizer.create({ components: { read: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); - expect(() => untyped(Finalizer.create, { name: "clock", components: {}, resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(() => Finalizer.create({ components: { with: [label] } }, (_, { resources }) => { void resources[time]; })).toThrow('both access "typesFinalizerTest/time", one writing it'); + expect(() => Finalizer.create({ components: { with: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); expect(programFinalizers()).toHaveLength(7); }); it("reject what types reject, for untyped callers", () => { - expect(() => untyped(Finalizer.create, { name: "both", components: {}, resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); - expect(() => untyped(Finalizer.create, { name: "reduced", components: {}, resources: { write: [total] } })).toThrow("which is reduced"); - expect(() => untyped(Finalizer.create, { name: "span", components: { read: [node, tags] } })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { write: [total] } })).toThrow("which is reduced"); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { reduce: [total] } })).toThrow("only systems contribute to a reduction"); + expect(() => untyped(Finalizer.create, { components: { with: [node, tags] } }, { read: [node, tags] })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived"); expect(() => untyped(Finalizer.changes, { name: "resource", watch: [time] })).toThrow("which isn't a registered component"); - expect(() => untyped(Finalizer.create, { name: "hasResource", components: { has: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`); - expect(() => untyped(Finalizer.create, { name: "lookupNot", components: { read: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`); + expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component"); expect(programFinalizers()).toHaveLength(7); }); it("reject resource writes and lookups in a finalizer on another thread than the world's, and a changes finalizer watching or reading another thread's values", () => { - expect(() => untyped(Finalizer.create, { name: "detachWriting", components: { read: [node] }, resources: { write: [time] } })) + expect(() => untyped(Finalizer.create, { components: { with: [node] } }, { read: [node], resources: { write: [time] } })) .toThrow(`runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); - expect(() => untyped(Finalizer.create, { name: "pageWriting", components: { read: [tags] }, resources: { write: [page] } })) + expect(() => untyped(Finalizer.create, { components: { with: [tags] } }, { read: [tags], resources: { write: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.create, { name: "detachLooking", components: { read: [node] }, lookup: { labels: { read: [mark] } } })) + expect(() => untyped(Finalizer.create, { components: { with: [node] }, lookup: { labels: { with: [mark] } } }, { read: [node], lookup: { labels: [mark] } })) .toThrow(`runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); expect(() => untyped(Finalizer.changes, { name: "saveNodes", watch: [node] })) .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); - expect(() => untyped(Finalizer.changes, { name: "savePage", watch: [tags], resources: { read: [page] } })) + expect(() => untyped(Finalizer.changes, { watch: [tags] }, { resources: { read: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.changes, { name: "savePageOnly", resources: { read: [page] } })) + expect(() => untyped(Finalizer.changes, {}, { resources: { read: [page] } })) .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); // Reading another thread's values, without writing or looking up, it's created. - const detach = Finalizer.create({ components: { read: [node] }, resources: { read: [page] } }, () => {}); + const detach = Finalizer.create({ components: { with: [node] } }, row => { void row[node]; }); expect(programFinalizers()).toHaveLength(8); // Finalizers on different threads run at different times, so neither is ordered before the other. const late = programFinalizers().find(finalizer => finalizer.name === "late"); - expect(() => untyped(Finalizer.create, { name: "afterDetach", components: { read: [label] }, after: [detach] })) + expect(() => untyped(Finalizer.create, { components: { with: [label] }, after: [detach] }, { read: [label] })) .toThrow(`runs on thread "world" and is ordered with "detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); - expect(() => untyped(Finalizer.create, { name: "beforeLate", components: { read: [node] }, before: [late] })) + expect(() => untyped(Finalizer.create, { components: { with: [node] }, before: [late] }, { read: [node] })) .toThrow(`runs on thread "main" and is ordered with "late", which runs on thread "world"`); expect(() => untyped(Finalizer.changes, { name: "saveAfterDetach", watch: [label], after: [detach] })) .toThrow(`runs on thread "world" and is ordered with "detach"`); - Finalizer.create({ components: { read: [node] }, after: [detach] }, () => {}); + Finalizer.create({ components: { with: [node] }, after: [detach] }, row => { void row[node]; }); expect(programFinalizers()).toHaveLength(9); }); }); describe("Rule.create", () => { it("records a rule and returns it, the one already recorded when declared again under its id, and rejects its id with another declaration", () => { - const slots = Rule.create(identify({ components: { has: [label] }, derive: [slot] }, "r.ts#slots")); + const slots = Rule.create(identify({ components: { with: [label] }, derive: [slot] }, "r.ts#slots")); expect(programRules()).toEqual([slots]); - expect(Rule.create(identify({ components: { has: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); - expect(() => Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); - const twin = Rule.create({ components: { has: [node] }, derive: [slot] }); + expect(Rule.create(identify({ components: { with: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); + expect(() => Rule.create(identify({ components: { with: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); + const twin = Rule.create({ components: { with: [node] }, derive: [slot] }); expect(programRules()).toEqual([slots, twin]); expect([slots, twin].map(rule => rule.name)).toEqual(["slots", "twin"]); }); it("rejects rules that derive in a cycle through a negation", () => { Rule.create({ components: { not: [mark] }, derive: [other] }); - expect(() => Rule.create({ components: { has: [other] }, derive: [mark] })).toThrow("cycle through a negation"); + expect(() => Rule.create({ components: { with: [other] }, derive: [mark] })).toThrow("cycle through a negation"); expect(programRules()).toHaveLength(3); }); it("rejects a rule deriving a component that isn't derived, or matching on a resource", () => { const create = (declaration: object): unknown => Reflect.apply(Rule.create, undefined, [declaration]); expect(() => create({ components: {}, derive: [label] })).toThrow("which isn't declared derived"); - expect(() => create({ components: { has: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); + expect(() => create({ components: { with: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); expect(programRules()).toHaveLength(3); }); }); describe("rules' and finalizers' labels", () => { it("are the bindings of their ids, or else their kinds numbered", () => { - const [rule, release] = [Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "rules.ts#slotted")), Finalizer.create(identify({ components: { read: [node] } }, "finalizers.ts#release"), () => {})]; + const [rule, release] = [Rule.create(identify({ components: { with: [node] }, derive: [slot] }, "rules.ts#slotted")), Finalizer.create(identify({ components: { with: [node] } }, "finalizers.ts#release"), () => {})]; expect([rule?.name, release?.name]).toEqual(["slotted", "release"]); // Made in an expression no binding names, so the build labels them by nothing. - const [rule2, finalizer2] = [Rule.create({ components: { has: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})]; + const [rule2, finalizer2] = [Rule.create({ components: { with: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})]; expect([rule2, finalizer2].map(item => item.name.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); }); }); diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index 01549100..b61eebd2 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -6,28 +6,23 @@ import { addFinalizer, programFinalizers } from '../registry/program.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; import { checkLookup } from '../registry/indexes.js'; -import type { Resource } from '../resource/resource.js'; import type { Listed, Property } from '../system/property.js'; import type { ThreadOfComponent } from '../component/value.js'; import type { Component } from '../component/component.js'; import type { Elsewhere, OneThread, ThreadedBy, ThreadsOf } from '../system/threads.js'; import type { ChangeSet, Context, Row, Watched } from './body.js'; import type { Changes, Leaf } from './created.js'; -import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { ChangesDeclaration, Declaration, Resolved, ResolvedChanges } from './declaration.js'; +import { accessOf, compact, lookupsLists, resourceUses, type Access } from '../system/resolve.js'; import type { Finalizer } from './finalizer.js'; import type { Finalizer as Registered } from './created.js'; -/** A resource in `write` is readable too, so it isn't also in `read`. */ -type Validate = - { readonly resources?: { readonly read?: readonly Exclude, "write">>[] } }; - /** - * A finalizer on another thread than the world's runs there after the sync point, which doesn't wait for it: it writes - * no resources and looks up nothing. + * A finalizer on another thread than the world's runs there after the sync point, which doesn't wait for it: it looks + * up nothing, and, which registration checks, writes no resources. */ type Remote = true extends Elsewhere> - ? ([Listed, "write">] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's writes no resources": true }) - & ([keyof Property] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's looks up nothing": true }) + ? ([keyof Property] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's looks up nothing": true }) : {}; /** A changes finalizer runs on the world's thread, in the sync point: it watches and reads no other thread's objects. */ @@ -37,6 +32,28 @@ type OnWorld = true extends Elsewhere | ThreadOfComponent { + const uses = resourceUses(what, access, undefined); + if (uses.reduce.length > 0) throw new Error(`${what} reduces into resources; only systems contribute to a reduction.`); + const lookup = lookupsLists(declared.lookup, access); + return { + ...lookup === undefined ? {} : { lookup }, + resources: compact({ read: uses.read, write: uses.write }), + ...declared.before === undefined ? {} : { before: declared.before }, + ...declared.after === undefined ? {} : { after: declared.after }, + }; +}; + +/** A finalizer's row lists: what its body reads, the rest of `with` only required. */ +const componentReads = (components: Declaration["components"], access: Access): Resolved["components"] => { + const read = access.components?.read ?? components.with ?? []; + return compact({ read, has: (components.with ?? []).filter(key => !read.includes(key)), not: components.not }); +}; + /** Throws if a resource is in both `read` and `write`, or a reduced one is written. */ const checkResources = (name: string, resources: { readonly read?: readonly Key[]; readonly write?: readonly Key[] } | undefined): void => { const writes = new Set(resources?.write); @@ -73,12 +90,18 @@ const checkOrder = (name: string, thread: string, declaration: { readonly before * or with a resource conflict. */ export const create = ( - declaration: D & Validate & OneThread, ThreadedBy> & Remote, + declared: D & Inferred & { readonly components: { readonly read?: never; readonly has?: never } } & OneThread, ThreadedBy> & Remote, body: (row: Row, context: Context) => void, ): Finalizer => { - const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration, id, "finalizer"); - const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; + const id = takeIdOfDeclaration(declared); + const name = nameOf(declared, id, "finalizer"); + const what = `Finalizer "${name}"`; + const access = accessOf(what, declared); + const declaration: Resolved = { + ...resolveShared(what, declared, access), + components: componentReads(declared.components, access), + }; + const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; checkResources(name, declaration.resources); const thread = threadOf(declaration, "Finalizer", name); if (thread !== null && thread !== "world") { @@ -104,12 +127,14 @@ export const create = ( * one watching or reading another thread's values. */ export const changes = ( - declaration: D & Validate & OnWorld, + declared: D & Inferred & OnWorld, body: (changes: ChangeSet>, context: Context) => void, ): Finalizer => { - const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration, id, "finalizer"); - const finalizer: Changes = { kind: "changes", name, declaration, body }; + const id = takeIdOfDeclaration(declared); + const name = nameOf(declared, id, "finalizer"); + const what = `Finalizer "${name}"`; + const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), ...declared.watch === undefined ? {} : { watch: declared.watch } }; + const finalizer: Changes = { kind: "changes", name, declaration, body }; checkResources(name, declaration.resources); requireRegistered("Finalizer", name, "resource", declaration.resources?.read); requireRegistered("Finalizer", name, "resource", declaration.resources?.write); diff --git a/packages/ecs/src/interface/finalizer/created.ts b/packages/ecs/src/interface/finalizer/created.ts index 4e3f68f2..a9f87503 100644 --- a/packages/ecs/src/interface/finalizer/created.ts +++ b/packages/ecs/src/interface/finalizer/created.ts @@ -1,21 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { Resolved, ResolvedChanges } from './declaration.js'; // Bodies' parameters are checked where they're created, so a finalizer holds any body. /** Runs, inside a sync point, for each entity that stops matching its components. */ -export type Leaf = { +export type Leaf = { readonly kind: "finalizer"; readonly name: string; - readonly declaration: D; + readonly declaration: Resolved; body: (...parameters: never[]) => void; }; /** Runs, inside a sync point, with all its structural changes to the components it watches. */ -export type Changes = { +export type Changes = { readonly kind: "changes"; readonly name: string; - readonly declaration: D; + readonly declaration: ResolvedChanges; body: (...parameters: never[]) => void; }; diff --git a/packages/ecs/src/interface/finalizer/declaration.ts b/packages/ecs/src/interface/finalizer/declaration.ts index db4158e1..325de32c 100644 --- a/packages/ecs/src/interface/finalizer/declaration.ts +++ b/packages/ecs/src/interface/finalizer/declaration.ts @@ -1,24 +1,24 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Resource } from '../resource/resource.js'; +import type { SystemLookupLists, ResourceLists } from '../system/access.js'; import type { Lookup } from '../system/access.js'; import type { Finalizer } from './finalizer.js'; import type { Essential } from '../component/value.js'; -import type { Reduced } from '../resource/reduction.js'; -import type { WorldWritten } from '../resource/written.js'; +/** + * What every finalizer declares. What its body reads of its row and lookups, and reads and writes of resources, the + * build infers; resource writes take the sync point's tick, so each system sees them once. + */ type Shared = { /** Read-only access to other entities, as they are before the sync point applies. */ readonly lookup?: { readonly [name: string]: Lookup }; - /** Writes take the sync point's tick, so each system sees them once. A resource in `write` is also readable. */ - readonly resources?: { readonly read?: readonly Resource.Key[]; readonly write?: readonly Exclude[] }; readonly before?: readonly Finalizer[]; readonly after?: readonly Finalizer[]; }; -/** A finalizer that runs for each entity that stops matching its components. */ +/** A finalizer that runs for each entity that stops matching its components: those with every one in `with` and none in `not`. */ export type Declaration = Shared & { - readonly components: { readonly read?: readonly Component.Key[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; }; /** @@ -26,3 +26,20 @@ export type Declaration = Shared & { * isn't derived or an object a joined thread fills. */ export type ChangesDeclaration = Shared & { readonly watch?: readonly Essential[] }; + +type Key = symbol; + +/** What every finalizer's declaration holds as the world takes it, with what the build inferred its body uses. */ +type ResolvedShared = { + readonly lookup?: { readonly [name: string]: SystemLookupLists }; + readonly resources?: Pick; + readonly before?: readonly Finalizer[]; + readonly after?: readonly Finalizer[]; +}; + +/** A finalizer's declaration as the world takes it: what its body reads of its row in `read`, the rest of `with` in `has`. */ +export type Resolved = ResolvedShared & { + readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly not?: readonly Key[] }; +}; + +export type ResolvedChanges = ResolvedShared & { readonly watch?: readonly Key[] }; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts index ebdcf691..a0c0e8ce 100644 --- a/packages/ecs/src/interface/index.type-test.ts +++ b/packages/ecs/src/interface/index.type-test.ts @@ -18,17 +18,17 @@ declare module "@adobe/ecs" { [scale]: { type: number }; } } -export const move = System.create({ components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); -export const show = System.create({ components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); +export const move = System.create({ components: { with: [position] } }, (row, { resources }) => { row[position] += resources[scale]; }); +export const show = System.create({ components: { with: [position, element] } }, row => { row[element].textContent = String(row[position]); }); export const all = System.create({ systems: [move, show] }); export const draw = System.render({ shader: { accessors: { place: position } } }, () => {}); -export const release = Finalizer.create({ components: { read: [element] } }, row => { row[element].remove(); }); -Rule.create({ components: { has: [position] }, derive: [slot] }); +export const release = Finalizer.create({ components: { with: [element] } }, row => { row[element].remove(); }); +Rule.create({ components: { with: [position] }, derive: [slot] }); declare const create: Create; declare const wgsl: typeof Gpu.wgsl; export const world = create({ [workers]: 4, [gpuDevice]: null }); -export const read = System.create({ observer: true, components: { read: [position, slot] }, resources: { read: [gpuDevice, workers] } }, () => {}); -export const readOne = System.create({ observer: true, lookup: { at: { read: [position], has: [slot] } } }, () => {}); +export const read = System.create({ observer: true, components: { with: [position, slot] } }, () => {}); +export const readOne = System.create({ observer: true, lookup: { at: { with: [position, slot] } } }, () => {}); export const report = world.run(all); export const shader = wgsl(draw); export const added = world.add(read); @@ -39,7 +39,7 @@ export type NoThread = Ecs.Thread; // @ts-expect-error the cadence's value is Resource.Value export type NoCadence = Ecs.Cadence; // @ts-expect-error a rule is the value Rule.create returns: ReturnType -export const noRule: Rule = Rule.create({ components: { has: [position] }, derive: [slot] }); +export const noRule: Rule = Rule.create({ components: { with: [position] }, derive: [slot] }); // @ts-expect-error an archetype is a readonly list of components export const noArchetype: Ecs.Archetype = [position]; // @ts-expect-error a trait's key is keyof Traits diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index 6e5c4874..b80264d8 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -1,5 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { createResolved } from "../system/create-resolved.js"; +import { giveAccess } from "../registry/ids.test-helper.js"; import { Entity } from "../entity/entity.js"; import { Finalizer } from "../finalizer/finalizer.js"; import { identify } from "../registry/ids.test-helper.js"; @@ -49,11 +51,11 @@ describe("Index.create", () => { expect(Index.create({ key: [amount] }).name).toMatch(/^index #\d+$/); }); it("is the index already recorded when declared again under its id the same, and throws for another declaration", () => { - const first = Index.create(identify({ key: [cell], components: { has: [shown] } }, "i.ts#again")); - expect(Index.create(identify({ key: [cell], components: { has: [shown] } }, "i.ts#again"))).toBe(first); + const first = Index.create(identify({ key: [cell], components: { with: [shown] } }, "i.ts#again")); + expect(Index.create(identify({ key: [cell], components: { with: [shown] } }, "i.ts#again"))).toBe(first); expect(() => Index.create(identify({ key: [amount] }, "i.ts#again"))).toThrow("reload the page"); // Another value is another index. - expect(Index.create({ key: [cell], components: { has: [shown] } })).not.toBe(first); + expect(Index.create({ key: [cell], components: { with: [shown] } })).not.toBe(first); }); it("throws for key parts that aren't components of data, are listed twice, or are sets", () => { expect(() => untyped({ name: "bad" })).toThrow(`needs a key`); @@ -65,10 +67,10 @@ describe("Index.create", () => { }); it("takes an empty key, holding the entities with its has and not, and isn't unique then", () => { expect(() => Index.create({ key: [] })).not.toThrow(); - expect(() => Index.create({ key: [], sorted: true, components: { has: [amount], not: [shown] } })).not.toThrow(); + expect(() => Index.create({ key: [], sorted: true, components: { with: [amount], not: [shown] } })).not.toThrow(); expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`is unique without a key`); - expect(() => untyped({ name: "bad", key: [], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); - expect(() => untyped({ name: "bad", key: [], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => untyped({ name: "bad", key: [], components: { with: [shown], not: [shown] } })).toThrow("which it holds by"); + expect(() => untyped({ name: "bad", key: [], components: { with: [unregistered] } })).toThrow("isn't a registered component"); }); it("orders sorted parts that have an order: numbers, booleans, entities, strings and tuples of them", () => { expect(() => Index.create({ key: [cell, amount, name, parent], sorted: true })).not.toThrow(); @@ -80,21 +82,22 @@ describe("Index.create", () => { it("throws for object parts on two threads and for has and not naming what they can't", () => { expect(() => untyped({ name: "bad", key: [node, view] })).toThrow(`key parts on threads "world" and "main"`); expect(() => untyped({ name: "bad", key: [name, view] })).toThrow(`key parts on threads "world" and "main"; its object parts live on one thread, the world's beside a string`); - expect(() => untyped({ name: "bad", key: [amount], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => untyped({ name: "bad", key: [amount], components: { with: [unregistered] } })).toThrow("isn't a registered component"); expect(() => untyped({ name: "bad", key: [amount], components: { not: [unregistered] } })).toThrow("isn't a registered component"); expect(() => untyped({ name: "bad", key: [amount], components: { not: [amount] } })).toThrow(`excludes "indexCreateTest/amount", which it holds by`); - expect(() => untyped({ name: "bad", key: [amount], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); + expect(() => untyped({ name: "bad", key: [amount], components: { with: [shown], not: [shown] } })).toThrow("which it holds by"); }); }); describe("systems and finalizers looking up through an index", () => { - const grid = Index.create({ key: [cell], components: { has: [amount] } }); - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const grid = Index.create({ key: [cell], components: { with: [amount] } }); + /** A system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ + const untypedSystem = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const reader = System.create({ lookup: { near: { index: grid, read: [amount] } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); + const reader = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); const leaf = requireDefinition(reader); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([amount, cell]); - const writer = System.create({ components: { write: [cell] } }, row => { row[cell] = [0, 0]; }); + const writer = System.create({ components: { with: [cell] } }, row => { row[cell] = [0, 0]; }); expect(() => System.create([reader, writer])).toThrow(`both access "indexCreateTest/cell" and one writes it`); expect(() => System.create({ systems: [reader, writer], before: [] })).toThrow("order them"); const names = Index.create({ key: [view] }); @@ -107,19 +110,21 @@ describe("systems and finalizers looking up through an index", () => { expect(() => untypedSystem({ name: "bad", components: { write: [cell] }, lookup: { near: { index: grid } } })).toThrow(`goes through an index keyed by "indexCreateTest/cell", which the system writes`); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, lookup: { near: { index: grid, read: [amount] } } })).toThrow(`reads "indexCreateTest/amount", which the system writes`); expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); - expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`lookup "near" reads`); + const reading = giveAccess({ components: { with: [amount] }, lookup: { near: { index: grid } } }, { read: [amount], lookup: { near: [name] } }); + expect(() => Reflect.apply(Finalizer.create, undefined, [reading, () => {}])).toThrow(`lookup "near" reads`); expect(grid.name).toBe("grid"); }); }); describe("systems ordering their rows by an index", () => { - const ranked = Index.create({ key: [amount], sorted: true, components: { has: [cell] } }); - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const ranked = Index.create({ key: [amount], sorted: true, components: { with: [cell] } }); + /** A system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ + const untypedSystem = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const ordered = System.create({ components: { read: [cell] }, order: ranked }, row => { void row[cell]; }); + const ordered = System.create({ components: { with: [cell] }, order: ranked }, row => { void row[cell]; }); const leaf = requireDefinition(ordered); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([cell, amount]); - const writer = System.create({ components: { write: [amount] } }, row => { row[amount] = 0; }); + const writer = System.create({ components: { with: [amount] } }, row => { row[amount] = 0; }); expect(() => System.create([ordered, writer])).toThrow(`both access "indexCreateTest/amount" and one writes it`); // Every value a sorted index orders lives in shared memory, strings included, so its key places nothing. expect(threadOf({ order: Index.create({ key: [name], sorted: true }) }, "System", "unplaced")).toBe(null); @@ -134,7 +139,7 @@ describe("systems ordering their rows by an index", () => { expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); }); it("throw for a render system's order by what isn't a sorted index", () => { - const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [{ name: "bad", order, shader: { accessors: { amount } } }, () => {}]); + const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [giveAccess({ order, shader: { accessors: { amount } } }), () => {}]); expect(() => untypedRender({ name: "fake" })).toThrow(`orders its rows by a value Index.create didn't return`); expect(() => untypedRender(Index.create({ key: [amount] }))).toThrow(/orders its rows by index "index #\d+", which isn't sorted/); }); diff --git a/packages/ecs/src/interface/indexes/create.ts b/packages/ecs/src/interface/indexes/create.ts index 97edc350..c1d2a599 100644 --- a/packages/ecs/src/interface/indexes/create.ts +++ b/packages/ecs/src/interface/indexes/create.ts @@ -12,9 +12,9 @@ import type { Created } from './created.js'; import type { Declaration } from './declaration.js'; import type { Index } from './index-type.js'; -/** `not` excludes what the index holds by: its key parts and `has`. An index without a key isn't unique. */ +/** `not` excludes what the index holds by: its key parts and `with`. An index without a key isn't unique. */ type Validate = { - readonly components?: { readonly not?: readonly Exclude, "has">>[] }; + readonly components?: { readonly not?: readonly Exclude, "with">>[]; readonly has?: never }; } & (D["key"] extends readonly [] ? { readonly unique?: false } : unknown); /** Whether a schema's values have an order: numbers, booleans, entities and fixed-length tuples of them, and strings. */ @@ -29,9 +29,9 @@ const collection = (schema: Schema): boolean => "type" in schema && (schema.type /** * Declares an index over components' values and returns it; nothing is built until a world's system or finalizer uses - * it. It holds the entities with every key part and every component in `has`, and none in `not`, keyed by the values + * it. It holds the entities with every key part and every component in `with`, and none in `not`, keyed by the values * of `key`, compared part by part: numbers and tuples by value, an entity with its generation, so a stale one finds - * nothing. With an empty key it holds those entities, every entity but the resource entity when `has` and `not` are + * nothing. With an empty key it holds those entities, every entity but the resource entity when `with` and `not` are * empty too, and its lookup takes no key and gives them all, in entity order. * - `sorted`: keeps key order, numbers numerically, NaN last, booleans false first, strings by code point, tuples * element by element, then entity order, so a lookup takes a prefix of the key whose last part may be a comparison. @@ -40,14 +40,15 @@ const collection = (schema: Schema): boolean => "type" in schema && (schema.type * * Throws if a key part isn't a registered component of data, is listed twice or is a set or map, a sorted part has no * order, its object parts live on different threads, or on another than the world's beside a string, which the world - * keeps its keys of, it's unique without a key, or `has` or `not` names a key that isn't a registered component - * or `not` names a key part or a `has` component. + * keeps its keys of, it's unique without a key, or `with` or `not` names a key that isn't a registered component + * or `not` names a key part or a `with` component. */ export const create = (declaration: D & Validate): Index => { const id = takeIdOfDeclaration(declaration); const name = nameOf(declaration, id, "index"); const fail = (message: string): never => { throw new Error(`Index "${name}" ${message}.`); }; - const { key, components, sorted } = declaration; + const { key, sorted } = declaration; + const components = declaration.components === undefined ? undefined : { has: declaration.components.with, not: declaration.components.not }; if (!Array.isArray(key)) fail("needs a key: a list of components"); if (key.length === 0 && declaration.unique === true) fail("is unique without a key: it holds at most one entity per key"); const threads = new Set(); @@ -76,6 +77,6 @@ export const create = (declaration: D & Validate if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component: ${whyUnregistered(component)}`); if (held.has(firstKeyOf(component))) fail(`excludes "${labelOf(component)}", which it holds by`); } - const index: Created = { kind: "index", name, declaration }; + const index: Created = { kind: "index", name, declaration: { ...declaration, ...components === undefined ? {} : { components } } }; return addIndex(index, id); }; diff --git a/packages/ecs/src/interface/indexes/created.ts b/packages/ecs/src/interface/indexes/created.ts index 47ea8ec0..6744e660 100644 --- a/packages/ecs/src/interface/indexes/created.ts +++ b/packages/ecs/src/interface/indexes/created.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Declaration } from './declaration.js'; +import type { Stored } from './declaration.js'; /** What `Index.create` records: its kind, its label and its declaration. */ -export type Created = { readonly kind: "index"; readonly name: string; readonly declaration: Declaration }; +export type Created = { readonly kind: "index"; readonly name: string; readonly declaration: Stored }; diff --git a/packages/ecs/src/interface/indexes/declaration.ts b/packages/ecs/src/interface/indexes/declaration.ts index 0e2fbf0e..c036b537 100644 --- a/packages/ecs/src/interface/indexes/declaration.ts +++ b/packages/ecs/src/interface/indexes/declaration.ts @@ -4,7 +4,12 @@ import type { Component } from '../component/component.js'; /** What `Index.create` takes, its fields documented there; not exported from the package. */ export type Declaration = { readonly key: readonly Component.Key[]; - readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components?: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; readonly sorted?: boolean; readonly unique?: boolean; }; + +/** An index's declaration as the world takes it: what it requires in `has`. */ +export type Stored = Omit & { + readonly components?: { readonly has?: readonly symbol[]; readonly not?: readonly symbol[] }; +}; diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index c4916ccf..4008d123 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -40,25 +40,25 @@ declare module '@adobe/ecs' { } // An index is typed by its declaration, as written. -const grid = Index.create({ key: [cell], components: { has: [position] } }); -check>>(); -const byServerId = Index.create({ key: [serverId], unique: true, components: { has: [health] } }); -const clips = Index.create({ key: [track, start], sorted: true, components: { has: [clip] } }); +const grid = Index.create({ key: [cell], components: { with: [position] } }); +check>>(); +const byServerId = Index.create({ key: [serverId], unique: true, components: { with: [health] } }); +const clips = Index.create({ key: [track, start], sorted: true, components: { with: [clip] } }); const tasksOf = Index.create({ key: [owner], components: { not: [archived] } }); const named = Index.create({ key: [label, serverId], sorted: true, unique: true }); -// With an empty key, an index holds the entities with every `has` component and no `not` one; with neither, every entity. -const living = Index.create({ key: [], components: { has: [health], not: [archived] } }); -check>>(); +// With an empty key, an index holds the entities with every `with` component and no `not` one; with neither, every entity. +const living = Index.create({ key: [], components: { with: [health], not: [archived] } }); +check>>(); const everything = Index.create({ key: [] }); -const inOrder = Index.create({ key: [], sorted: true, components: { has: [start] } }); +const inOrder = Index.create({ key: [], sorted: true, components: { with: [start] } }); // @ts-expect-error a key is a list of components Index.create({ key: [health, 3] }); // @ts-expect-error an index without a key holds no key once Index.create({ key: [], unique: true }); // @ts-expect-error `not` excludes `has` -Index.create({ key: [], components: { has: [archived], not: [archived] } }); +Index.create({ key: [], components: { with: [archived], not: [archived] } }); // @ts-expect-error `not` excludes neither key parts nor `has` -Index.create({ key: [owner], components: { has: [archived], not: [owner] } }); +Index.create({ key: [owner], components: { with: [archived], not: [owner] } }); // Code accepting any sorted index takes `Index<{ readonly sorted: true }>`. const sortedOnly = (index: Index<{ readonly sorted: true }>): string => index.name; @@ -72,8 +72,8 @@ sortedOnly(anyIndex); // A hash lookup takes the whole key and gives the bucket's rows, with `length`, `changed` and `at`. export const separate = System.create({ - components: { read: [position, cell], write: [velocity] }, - lookup: { near: { index: grid, read: [position] } }, + components: { with: [position, cell, velocity] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const near = lookup.near(row[cell]); check>(); @@ -85,8 +85,9 @@ export const separate = System.create({ void entity; // @ts-expect-error rows are read-only other[position] = [0, 0, 0]; - // @ts-expect-error rows hold only the lookup's read void other[cell]; + // @ts-expect-error rows hold only what every entity the index holds has + void other[velocity]; // @ts-expect-error a hash lookup takes the whole key lookup.near(); // @ts-expect-error a key part's value has its type @@ -95,18 +96,18 @@ export const separate = System.create({ // A unique lookup gives the row or null. export const applyDelta = System.create({ - lookup: { known: { index: byServerId, read: [health] } }, + lookup: { known: { index: byServerId } }, }, ({ lookup, commands }, input: { readonly id: number; readonly hp: number }) => { const known = lookup.known(input.id); - check>(); + check>(); if (known === null) commands.spawn([serverId, health])({ [serverId]: input.id, [health]: input.hp }); else commands.add(known.entity, health, known[health] + input.hp); }); // A sorted lookup takes a prefix of the key, whose last part may be a comparison. export const cue = System.create({ - components: { read: [track, playhead] }, - lookup: { due: { index: clips, read: [clip, start], changed: [clip] } }, + components: { with: [track, playhead] }, + lookup: { due: { index: clips, changed: [clip] } }, }, (row, { lookup }) => { const t = row[playhead]; const due = lookup.due(row[track], { ">=": t, "<": t + 1 }); @@ -119,9 +120,9 @@ export const cue = System.create({ }); // A unique sorted lookup gives a row by a whole key of plain values, and rows by anything else. -System.create({ lookup: { names: { index: named, read: [label] } } }, ({ lookup }) => { +System.create({ lookup: { names: { index: named } } }, ({ lookup }) => { const one = lookup.names("a", 1); - check>(); + check>(); const some = lookup.names("a", { ">": 1 }); check>(); const all = lookup.names(); @@ -130,7 +131,7 @@ System.create({ lookup: { names: { index: named, read: [label] } } }, ({ lookup // A lookup through an index without a key takes none and gives every row it holds, in entity order. export const census = System.create({ - lookup: { living: { index: living, read: [health], changed: [health] }, all: { index: everything }, ordered: { index: inOrder, read: [start] } }, + lookup: { living: { index: living, changed: [health] }, all: { index: everything }, ordered: { index: inOrder } }, }, ({ lookup }) => { const rows = lookup.living(); check>(); @@ -145,114 +146,76 @@ export const census = System.create({ }); System.create({ kernel: true, - components: { write: [velocity] }, - lookup: { living: { index: living, read: [health] } }, -}, () => {}); -System.create({ - components: { write: [health] }, - // @ts-expect-error a system doesn't write what it reads through an index - lookup: { living: { index: living, read: [health] } }, + components: { with: [velocity] }, + lookup: { living: { index: living } }, }, () => {}); -System.create({ components: { read: [start], write: [playhead] }, order: inOrder }, row => { +System.create({ components: { with: [start, playhead] }, order: inOrder }, row => { row[playhead] = row[start]; }); // Finalizers look up through indexes too. -Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf } } }, (row, { lookup }) => { +Finalizer.create({ components: { with: [owner] }, lookup: { tasks: { index: tasksOf } } }, (row, { lookup }) => { const tasks = lookup.tasks(row[owner]); void tasks.at(0).entity; }); -System.create({ - components: { read: [cell] }, - // @ts-expect-error an index lookup reads only what every entity the index holds has - lookup: { near: { index: grid, read: [velocity] } }, -}, () => {}); System.create({ - components: { read: [cell] }, + components: { with: [cell] }, // @ts-expect-error an index lookup watches only what every entity the index holds has lookup: { near: { index: grid, changed: [velocity] } }, }, () => {}); -System.create({ - components: { write: [cell] }, - // @ts-expect-error a system doesn't write what it finds rows by - lookup: { near: { index: grid } }, -}, () => {}); - -System.create({ - components: { write: [position] }, - // @ts-expect-error a system doesn't write what it reads through an index - lookup: { near: { index: grid, read: [position] } }, -}, () => {}); System.create({ kernel: true, - components: { read: [cell], write: [velocity] }, - lookup: { near: { index: grid, read: [position] } }, + components: { with: [cell, velocity] }, + lookup: { near: { index: grid } }, }, () => {}); System.create({ kernel: true, - components: { read: [cell], write: [velocity] }, + components: { with: [cell, velocity] }, lookup: { near: { index: grid, changed: [position] } }, }, () => {}); -System.create({ - kernel: true, - components: { write: [velocity] }, - // @ts-expect-error kernels look up only through indexes of struct keys - lookup: { names: { index: named } }, -}, () => {}); System.create({ kernel: "gpu", - components: { write: [velocity] }, + components: { with: [velocity] }, // @ts-expect-error GPU kernels can't use lookups lookup: { near: { index: grid } }, }, () => {}); // `order` runs a system's rows in a sorted index's order. -System.create({ components: { read: [start], write: [playhead] }, order: clips }, row => { +System.create({ components: { with: [start, playhead] }, order: clips }, row => { row[playhead] = row[start]; }); -System.create({ kernel: true, components: { read: [start], write: [playhead] }, order: clips }, row => { +System.create({ kernel: true, components: { with: [start, playhead] }, order: clips }, row => { row[playhead] = row[start]; }); // A sorted index typed by its declaration is one of `Index<{ readonly sorted: true }>`. const ordering: Index<{ readonly sorted: true }> = clips; void ordering; // @ts-expect-error a hash index has no order -System.create({ components: { write: [velocity] }, order: grid }, () => {}); +System.create({ components: { with: [velocity] }, order: grid }, () => {}); // @ts-expect-error a hash index isn't one of `Index<{ readonly sorted: true }>` const unordered: Index<{ readonly sorted: true }> = grid; void unordered; // @ts-expect-error a system without components runs once, in no order System.create({ order: clips }, () => {}); System.create({ - components: { read: [clip], write: [playhead] }, + components: { with: [clip, playhead] }, up: { through: owner }, // @ts-expect-error `up` orders rows too order: clips, }, () => {}); -System.create({ - components: { write: [start] }, - // @ts-expect-error a system doesn't write what its rows are ordered by - order: clips, -}, () => {}); System.create({ kernel: "gpu", - components: { write: [velocity] }, + components: { with: [velocity] }, // @ts-expect-error GPU kernels have no order yet order: clips, }, () => {}); -System.create({ - kernel: true, - components: { write: [velocity] }, - // @ts-expect-error kernels are ordered only by indexes of struct keys - order: named, -}, () => {}); // An index lookup's `changed: true` runs its system only when the index's entries changed since it last ran: its // lists' `changed` then reports entries alone. Observer systems and CPU kernels take it; it's not a list of components. @@ -261,21 +224,21 @@ System.create({ observer: true, lookup: { living: { index: living, changed: true check>(); check>(); }); -System.create({ components: { read: [cell] }, lookup: { near: { index: grid, read: [position], changed: true } } }, (row, { lookup }) => { +System.create({ components: { with: [cell] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { void lookup.near(row[cell]).at(0)[position]; }); System.create({ kernel: true, - components: { read: [cell], write: [velocity] }, + components: { with: [cell, velocity] }, lookup: { near: { index: grid, changed: true } }, }, () => {}); System.create({ // @ts-expect-error only an index lookup's `changed` may be true - lookup: { player: { read: [health], changed: true } }, + lookup: { player: { with: [health], changed: true } }, }, () => {}); System.create({ // @ts-expect-error `changed` is true or a list of components lookup: { living: { index: living, changed: false } }, }, () => {}); // @ts-expect-error a finalizer runs on each entity that stops matching, so its lookups' `changed` is never true -Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf, changed: true } } }, () => {}); +Finalizer.create({ components: { with: [owner] }, lookup: { tasks: { index: tasksOf, changed: true } } }, () => {}); diff --git a/packages/ecs/src/interface/registry/ids.test-helper.ts b/packages/ecs/src/interface/registry/ids.test-helper.ts index 5df9cd1b..7d9bd990 100644 --- a/packages/ecs/src/interface/registry/ids.test-helper.ts +++ b/packages/ecs/src/interface/registry/ids.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationsName, idsName } from './ids.js'; +import { declarationsName, idsName, accessName } from './ids.js'; /** * Gives a key or a declaration an id, as code the plugin generates does for a top-level `Symbol(...)` declaration or a @@ -22,3 +22,27 @@ export const recordDeclaration = (key: symbol, kind: "component" | "resource" | map.set(key, { kind, declaration }); Reflect.set(globalThis, name, map); }; + +/** + * Gives a declaration, or the body of a system declaring nothing, what its body uses, as the plugin does where it sees + * the constructor's call: for calls it doesn't see. Each list names keys; what isn't given is used by nothing. + */ +export const giveAccess = (made: T, uses: { + readonly read?: readonly symbol[]; + readonly write?: readonly symbol[]; + readonly resources?: { readonly read?: readonly symbol[]; readonly write?: readonly symbol[]; readonly reduce?: readonly symbol[] }; + readonly lookup?: { readonly [name: string]: readonly symbol[] }; + readonly up?: readonly symbol[]; +} = {}): T => { + const name = Symbol.for(accessName); + let map: unknown = Reflect.get(globalThis, name); + if (!(map instanceof WeakMap)) Reflect.set(globalThis, name, map = new WeakMap()); + if (map instanceof WeakMap) map.set(made, { + components: { read: uses.read ?? [], write: uses.write ?? [] }, + resources: { read: uses.resources?.read ?? [], write: uses.resources?.write ?? [], reduce: uses.resources?.reduce ?? [] }, + lookup: uses.lookup ?? {}, + up: uses.up ?? [], + at: "a test", + }); + return made; +}; diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index 3579d85b..8972cdbf 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -5,7 +5,7 @@ import { Rule } from "../rule/rule.js"; import { System } from "../system/system.js"; import { registerComponent, registerResource } from "./declarations.js"; import { addTrait } from "./traits.js"; -import { identify } from "./ids.test-helper.js"; +import { giveAccess, identify } from "./ids.test-helper.js"; import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; // Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. @@ -78,34 +78,34 @@ describe("keys with ids", () => { }); it("keeps keys of different ids apart, though their descriptions match", () => { registerComponent(unnamed, { type: { type: "number" } }); - system(identify({ components: { write: [score] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[score] = 1; }); - expect(() => system(identify({ components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); + system(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#unnamed"), { write: [score] }), (row: Row) => { row[score] = 1; }); + expect(() => system(giveAccess(identify({ components: { with: [unnamed] } }, "idsTest/systems.ts#unnamed"), { write: [unnamed] }), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); }); }); describe("declarations evaluated again", () => { it("accept a system whose keys match by id, as a new system", () => { - const first = system(identify({ components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); - const again = system(identify({ components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); + const first = system(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#system"), { write: [score], resources: { read: [level] } }), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); + const again = system(giveAccess(identify({ components: { with: [scoreAgain] } }, "idsTest/systems.ts#system"), { write: [scoreAgain], resources: { read: [levelAgain] } }), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); expect(again).not.toBe(first); expect(sameItem(again, first)).toBe(true); }); it("accept a rule whose keys match by id, which is already recorded", () => { - const created = rule(identify({ components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); + const created = rule(identify({ components: { with: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); const rules = programRules().length; - expect(rule(identify({ components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); + expect(rule(identify({ components: { with: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); expect(programRules()).toHaveLength(rules); }); it("accept a finalizer whose keys match by id, keeping its place with the new body", () => { - const first = finalizer(identify({ components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); + const first = finalizer(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#finalizer")), () => {}); const body = (): void => {}; - expect(finalizer(identify({ components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); + expect(finalizer(giveAccess(identify({ components: { with: [scoreAgain] } }, "idsTest/systems.ts#finalizer")), body)).toBe(first); const found = programFinalizers().find(finalizer => finalizer.name === "finalizer"); expect(found?.kind === "finalizer" ? found.body : null).toBe(body); }); it("schedule symbols of one id as one key", () => { - const write = system({ name: "write", components: { write: [score] } }, (row: Row) => { row[score] = 1; }); - const read = system({ name: "read", components: { read: [scoreAgain] } }, (row: Row) => { void row[scoreAgain]; }); - expect(() => scheduleOf(requireDefinition(system({ name: "unordered", systems: [write, read] })))).toThrow("both access"); + const write = system(giveAccess({ components: { with: [score] } }, { write: [score] }), (row: Row) => { row[score] = 1; }); + const read = system(giveAccess({ components: { with: [scoreAgain] } }, { read: [scoreAgain] }), (row: Row) => { void row[scoreAgain]; }); + expect(() => scheduleOf(requireDefinition(system({ systems: [write, read] })))).toThrow("both access"); }); }); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts index 235f4f40..f95644a4 100644 --- a/packages/ecs/src/interface/registry/ids.ts +++ b/packages/ecs/src/interface/registry/ids.ts @@ -55,3 +55,22 @@ export const takeLabelOfDeclaration = (declaration: object): string | undefined map.delete(declaration); return typeof label === "string" ? label : undefined; }; + +/** + * The `Symbol.for` name of the global `WeakMap` from a declaration, or the body of a system declaring nothing, to what + * the build inferred its body uses (`Access` in `system/resolve.ts`), which the plugin fills where it calls a + * constructor; an internal factory making declarations at runtime gives its own first, which the plugin's doesn't + * replace. Its version changes with the format. + */ +export const accessName = "@adobe/ecs/access@1"; + +const access = Symbol.for(accessName); + +/** What the build inferred the body a declaration or body is made with uses, if it inferred it, taken out of the map. */ +export const takeAccessOf = (declaration: object): unknown => { + const map: unknown = Reflect.get(globalThis, access); + if (!(map instanceof WeakMap)) return undefined; + const found: unknown = map.get(declaration); + map.delete(declaration); + return found; +}; diff --git a/packages/ecs/src/interface/report/report.type-test.ts b/packages/ecs/src/interface/report/report.type-test.ts index 1aec9474..c1022204 100644 --- a/packages/ecs/src/interface/report/report.type-test.ts +++ b/packages/ecs/src/interface/report/report.type-test.ts @@ -39,14 +39,14 @@ check { +export const logger = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { const number: number = row[Report.run].number; for (const each of row[Report.errors].failures) logged.push(each); // @ts-expect-error a report's lists are read-only row[Report.errors].failures.push(logged[0]); void number; }); -export const timings = System.create({ observer: true, components: { read: [Report.run], changed: [Report.run] } }, row => { +export const timings = System.create({ observer: true, components: { with: [Report.run], changed: [Report.run] } }, row => { const { elapsed, systems, workers, joined, main: onMain, gpu, dropped } = row[Report.run]; void [elapsed, systems, workers, joined, onMain, gpu, dropped]; }); diff --git a/packages/ecs/src/interface/rule/create.ts b/packages/ecs/src/interface/rule/create.ts index 09d65950..6e964a32 100644 --- a/packages/ecs/src/interface/rule/create.ts +++ b/packages/ecs/src/interface/rule/create.ts @@ -11,16 +11,17 @@ import type { Declaration } from './declaration.js'; * if it matches on a key that isn't a registered component, derives a component that isn't derived, or derives in a * cycle through a negation. */ -export const create = (declaration: D): { readonly name: string } => { +export const create = (declaration: D & { readonly components: { readonly has?: never } }): { readonly name: string } => { const id = takeIdOfDeclaration(declaration); const name = nameOf(declaration, id, "rule"); - const { components, derive } = declaration; + const { derive } = declaration; + const components = { has: declaration.components.with, not: declaration.components.not }; for (const key of [...components.has ?? [], ...components.not ?? [], ...derive]) { if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`); } for (const key of derive) { if (declarationOf(key)?.derived !== true) throw new Error(`Rule "${name}" derives "${labelOf(key)}", which isn't declared derived.`); } - const rule: Created = { kind: "rule", name, declaration }; + const rule: Created = { kind: "rule", name, declaration: { ...declaration, components } }; return addRule(rule, id); }; diff --git a/packages/ecs/src/interface/rule/created.ts b/packages/ecs/src/interface/rule/created.ts index 45e69e97..1d23c26c 100644 --- a/packages/ecs/src/interface/rule/created.ts +++ b/packages/ecs/src/interface/rule/created.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Declaration } from './declaration.js'; +import type { Stored } from './declaration.js'; /** A rule: entities matching its components have the components it derives. */ -export type Rule = { readonly kind: "rule"; readonly name: string; readonly declaration: D }; +export type Rule = { readonly kind: "rule"; readonly name: string; readonly declaration: Stored }; diff --git a/packages/ecs/src/interface/rule/declaration.ts b/packages/ecs/src/interface/rule/declaration.ts index 8eda2d41..f334d0fc 100644 --- a/packages/ecs/src/interface/rule/declaration.ts +++ b/packages/ecs/src/interface/rule/declaration.ts @@ -4,7 +4,12 @@ import type { Derived } from '../component/value.js'; export type Declaration = { /** Which entities the rule matches, by the components they have and don't have. */ - readonly components: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** Components present on matching entities, while any rule deriving them matches. */ readonly derive: readonly Derived[]; }; + +/** A rule's declaration as the world takes it: what it requires in `has`. */ +export type Stored = Omit & { + readonly components: { readonly has?: readonly symbol[]; readonly not?: readonly symbol[] }; +}; diff --git a/packages/ecs/src/interface/rule/rule.type-test.ts b/packages/ecs/src/interface/rule/rule.type-test.ts index 54db9b59..e5597f1c 100644 --- a/packages/ecs/src/interface/rule/rule.type-test.ts +++ b/packages/ecs/src/interface/rule/rule.type-test.ts @@ -32,13 +32,12 @@ declare module '@adobe/ecs' { } // Rules derive only derived components. -const slots = Rule.create({ components: { has: [source], not: [hidden] }, derive: [slot] }); +const slots = Rule.create({ components: { with: [source], not: [hidden] }, derive: [slot] }); // @ts-expect-error a rule derives only derived components -Rule.create({ components: { has: [source] }, derive: [source] }); +Rule.create({ components: { with: [source] }, derive: [source] }); -// A finalizer's context holds only what it declares, and its commands. -const other = Finalizer.create({ components: { read: [source] } }, (_, context) => { - // @ts-expect-error no resources declared +// A finalizer's context holds every resource, the lookups it declares, and its commands. +const other = Finalizer.create({ components: { with: [source] } }, (_, context) => { void context.resources; // @ts-expect-error no lookups declared void context.lookup; @@ -47,8 +46,7 @@ const other = Finalizer.create({ components: { read: [source] } }, (_, context) // Per-query finalizers read the stopping row; it's read-only. Finalizer.create({ - components: { read: [slot, parent], not: [hidden] }, - resources: { read: [time] }, + components: { with: [slot, parent], not: [hidden] }, after: [other], }, (row, { resources, commands }) => { const value: number = row[slot]; @@ -57,16 +55,12 @@ Finalizer.create({ commands.despawn(target); // @ts-expect-error a finalizer's row is read-only row[slot] = 1; - // @ts-expect-error only declared reads are in the row + // @ts-expect-error only what with requires is in the row void row[source]; - // @ts-expect-error read resources are read-only - resources[time] = 0; void value; void now; }); Finalizer.create({ components: {}, - // @ts-expect-error write already makes a resource readable - resources: { read: [time], write: [time] }, }, () => {}); // Changes finalizers see only the essential components they watch. @@ -88,16 +82,18 @@ Finalizer.changes({ watch: [source, parent] }, (changes, { commands }) => { Finalizer.changes({ watch: [slot] }, () => {}); // References change only through commands, so systems can't write them. -System.create({ components: { read: [parent], write: [source] } }, row => { row[source] = row[parent]; }); -// @ts-expect-error a reference changes only through commands -System.create({ components: { write: [parent] } }, () => {}); +System.create({ components: { with: [parent, source] } }, row => { row[source] = row[parent]; }); +System.create({ components: { with: [parent] } }, row => { + // @ts-expect-error a reference changes only through commands + row[parent] = row.entity; +}); check, typeof source>>(); // A rule and a finalizer are the values their constructors return, labeled by their names, which needn't be unique. check>>(); check>(); check>(); -const sameName: Finalizer = Finalizer.create({ components: { read: [source] } }, () => {}); +const sameName: Finalizer = Finalizer.create({ components: { with: [source] } }, () => {}); void sameName; // Rules and finalizers apply in every world. @@ -105,7 +101,7 @@ declare const create: Create; export const created: World = create(); // Names are optional for rules and finalizers too. -const unnamedRule = Rule.create({ components: { has: [source] }, derive: [slot] }); -const unnamedFinalizer: Finalizer = Finalizer.create({ components: { read: [source] } }, row => { void row[source]; }); +const unnamedRule = Rule.create({ components: { with: [source] }, derive: [slot] }); +const unnamedFinalizer: Finalizer = Finalizer.create({ components: { with: [source] } }, row => { void row[source]; }); const unnamedChanges: Finalizer = Finalizer.changes({ watch: [source] }, changes => { void changes.groups; }); void [unnamedRule, unnamedFinalizer, unnamedChanges]; diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts index a305f081..45529720 100644 --- a/packages/ecs/src/interface/system/access.ts +++ b/packages/ecs/src/interface/system/access.ts @@ -1,21 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; import type { Resource } from '../resource/resource.js'; -import type { Writable } from '../component/value.js'; import type { Condition } from '../resource/condition.js'; -import type { Reduced } from '../resource/reduction.js'; -import type { WorldWritten } from '../resource/written.js'; import type { Index } from '../indexes/index-type.js'; /** - * Matches every component in `read`, `write`, `has`, `added` and `changed`, and excludes `not`. A component in `write` - * is also readable, so it's never listed in `read` as well. + * The entities a system runs over: those with every component in `with`, `added` and `changed`, and none in `not`. + * What its body reads and writes of them the build infers from the body. */ export type ComponentAccess = { - readonly read?: readonly Component.Key[]; - /** A component holding a `reference` changes only through commands, so it can't be written here. */ - readonly write?: readonly Writable[]; - readonly has?: readonly Component.Key[]; + readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; /** Passes rows where any of these were added since the system last ran. */ readonly added?: readonly Component.Key[]; @@ -24,15 +18,14 @@ export type ComponentAccess = { }; /** - * Read-only access to other entities: by entity, matching `read`, `has` and `not`, or through an index by key. A lookup - * by entity's `changed` names components it reads or has, whose changes its row's `changed` reports. An index lookup's - * `read` names components every entity the index holds has, its key parts or its `has`, and its `changed` the ones - * whose changes its lists' `changed` reports. Both report as a system's `changed` filter does; its system can't write a - * key part or what it reads. + * Read-only access to other entities: by entity, matching `with` and `not`, or through an index by key, whose rows + * hold what every entity the index holds has. What the body reads of them the build infers. A lookup by entity's + * `changed` names components in `with`, whose changes its row's `changed` reports, and an index lookup's those of its + * rows. Both report as a system's `changed` filter does; its system can't write a key part or what it reads. */ export type Lookup = - | Pick & { readonly changed?: readonly Component.Key[] } - | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed?: readonly Component.Key[] }; + | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } + | { readonly index: Index; readonly changed?: readonly Component.Key[]; readonly with?: never; readonly not?: never }; /** * A system's lookup. An index lookup's `changed: true` makes the index a change filter, as a resource in `changed` is: @@ -41,26 +34,17 @@ export type Lookup = * when a condition fails, keeping its `lastRun`. Its lists' `changed` then reports those changes alone. Only on the * world's thread. */ -export type SystemLookup = - | Lookup - | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed: true }; +export type SystemLookup = Lookup | { readonly index: Index; readonly changed: true }; /** * The parent each row's values flow down from: the entity its `through` reference names, read before the row. Rows - * run parent first, so a parent's value is the one written earlier in the same run, and `read` may name components the - * system writes. + * run parent first, so a parent's value is the one written earlier in the same run. The context's `up` holds the + * parent's values the body reads, or null for a row without a parent or whose parent lacks one of them. */ -export type Up = { readonly through: Component.With<"reference">; readonly read?: readonly Component.Key[] }; +export type Up = { readonly through: Component.With<"reference"> }; -/** - * A resource in `write` is also readable, so it's never listed in `read` as well. Reduced resources are never written, - * nor are those only the world writes. - */ +/** The resources a system filters and is conditioned on; what its body reads, writes and reduces the build infers. */ export type ResourceAccess = { - readonly read?: readonly Resource.Key[]; - readonly write?: readonly Exclude[]; - /** The resources its body contributes to, each folded with the operator its `reduce` trait declares. */ - readonly reduce?: readonly Reduced[]; /** Passes every row when any of these changed since the system last ran. */ readonly changed?: readonly Resource.Key[]; /** Runs only while each of these is neither `null` nor `false`, and reads it as such. Otherwise it's skipped, successfully. */ @@ -68,3 +52,38 @@ export type ResourceAccess = { /** Runs only while each of these is `null` or `false`. Otherwise it's skipped, successfully. */ readonly not?: readonly Condition[]; }; + +/** A key, as the world takes declarations: their lists come from the build, untyped. */ +type Key = symbol; + +/** + * What a system reads and writes, as the world takes it: the build's inferred access joined with its declaration. + * Matches every component in `read`, `write`, `has`, `added` and `changed`, and excludes `not`. + */ +export type ComponentLists = { + readonly read?: readonly Key[]; + readonly write?: readonly Key[]; + readonly has?: readonly Key[]; + readonly not?: readonly Key[]; + readonly added?: readonly Key[]; + readonly changed?: readonly Key[]; +}; + +/** A lookup as the world takes it, with what its body reads of its rows in `read`. */ +export type LookupLists = + | { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly not?: readonly Key[]; readonly changed?: readonly Key[] } + | { readonly index: Index; readonly read?: readonly Key[]; readonly changed?: readonly Key[] }; + +export type SystemLookupLists = LookupLists | { readonly index: Index; readonly read?: readonly Key[]; readonly changed: true }; + +export type UpLists = { readonly through: Key; readonly read?: readonly Key[] }; + +/** A system's resources as the world takes them. */ +export type ResourceLists = { + readonly read?: readonly Key[]; + readonly write?: readonly Key[]; + readonly reduce?: readonly Key[]; + readonly changed?: readonly Key[]; + readonly is?: readonly Key[]; + readonly not?: readonly Key[]; +}; diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts index 26108854..f119999d 100644 --- a/packages/ecs/src/interface/system/body.ts +++ b/packages/ecs/src/interface/system/body.ts @@ -1,43 +1,59 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; +import type { Writable } from '../component/value.js'; import type { Entity } from '../entity/entity.js'; import type { Resource } from '../resource/resource.js'; import type { Holding } from '../resource/condition.js'; +import type { Reduced } from '../resource/reduction.js'; +import type { WorldWritten } from '../resource/written.js'; import type { Commands } from './commands.js'; import type { Declaration } from './declaration.js'; import type { Listed, Property } from './property.js'; import type { RenderContext } from './render.js'; import type { ComponentValues, LookupFunction, ResourceValues, Values } from './values.js'; -/** `read` values are read-only, `write` values readable and writable. */ +/** The components every entity a declaration matches has: those in `with`, `added` and `changed`. */ +export type Members = + | Listed, "with"> | Listed, "added"> | Listed, "changed">; + +/** Whether a declaration only reads: an observer or a render system. */ +type Reading = D extends { readonly observer: true } | { readonly shader: object } ? true : false; + +/** Its members' values, writable but for references, and in a system that only reads. */ export type Row = { readonly entity: Entity } - & Values, "read">, Listed, "write">, Component.Key, ComponentValues>; + & Values, Reading extends true ? never : Members & Writable, Component.Key, ComponentValues>; + +/** Resources systems write: every one but those reduced, which only their contributors' fold sets, and those only the world writes. */ +type Writes = Exclude; + +/** Every resource, read-only. */ +export type ReadResources = Values; + +/** Every resource, writable as `Writes` says. */ +export type WriteResources = Values; + +/** Each reduced resource, as a function contributing a value to the run's fold. */ +type Reductions = { readonly [K in Reduced]: (value: Resource.Value) => void }; -type Resources = Listed, "read"> | Listed, "write"> | Is; type Is = Listed, "is">; -type Reduced = Listed, "reduce"> & Resource.Key; /** - * What a body uses besides its row, by kind, under the declaration's own keys: each is present only when the - * declaration declares something of its kind. `commands` is there for every system but a GPU kernel, a render system - * and an observer system. + * What a body uses besides its row: every resource, its lookups by name, the resources it may reduce into, its + * parent's values with `up`, and its commands, there for every system but a GPU kernel, a render system and an + * observer system. The build infers which of them the body reads and writes. */ export type Context = - ([Resources] extends [never] ? {} : { - readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues> - & { readonly [K in Is & Resource.Key]: Holding }; - }) + { + readonly resources: (Reading extends true ? ReadResources : WriteResources) & { readonly [K in Is & Resource.Key]: Holding }; + } & ([keyof Property] extends [never] ? {} : { readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; }) - & ([Reduced] extends [never] ? {} : { - /** Each resource it reduces into, as a function contributing a value to the run's fold. */ - readonly reduce: { readonly [K in Reduced]: (value: Resource.Value) => void }; - }) + & (Reading extends true ? {} : { readonly reduce: Reductions }) & (Property extends object ? { - /** The parent's `up.read` values, or null for a row without a parent. */ - readonly up: { readonly [K in Listed, "read"> & Component.Key]: Component.Value } | null; + /** The parent's values the body reads, or null for a row without a parent or whose parent lacks one of them. */ + readonly up: ComponentValues | null; } : {}) & (D extends { readonly kernel: "gpu" } | { readonly shader: object } | { readonly observer: true } ? {} : { readonly commands: Commands }); diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts index b8e19029..081da402 100644 --- a/packages/ecs/src/interface/system/conditions.type-test.ts +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -37,11 +37,9 @@ System.create({ resources: { is: [paused] } }, ({ resources }) => { check>(); }); -// `not`: runs while each is null or false; its value is known, so it isn't readable. -System.create({ components: { write: [position] }, resources: { not: [paused] } }, (row, context) => { +// `not`: runs while each is null or false. +System.create({ components: { with: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; - // @ts-expect-error a `not` condition isn't readable - void context.resources; }); // Only a resource whose type allows null or false is a condition. @@ -50,14 +48,10 @@ System.create({ resources: { is: [count] } }, () => {}); // @ts-expect-error a value that isn't data and doesn't allow null is never null System.create({ resources: { not: [handle] } }, () => {}); -// A condition is listed once: `is` already makes it readable, and a written value would have two types. +// A condition is listed once. // @ts-expect-error both is and not System.create({ resources: { is: [paused], not: [paused] } }, () => {}); -// @ts-expect-error is already makes it readable -System.create({ resources: { is: [level], read: [level] } }, () => {}); -// @ts-expect-error a condition isn't written -System.create({ resources: { is: [level], write: [level] } }, () => {}); // Kernels and render systems take conditions too. -System.create({ kernel: true, components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); +System.create({ kernel: true, components: { with: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); System.render({ resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); diff --git a/packages/ecs/src/interface/system/create-resolved.ts b/packages/ecs/src/interface/system/create-resolved.ts new file mode 100644 index 00000000..e1e07e4f --- /dev/null +++ b/packages/ecs/src/interface/system/create-resolved.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { nameOf } from '../registry/items.js'; +import { claimSystem } from '../registry/program.js'; +import { checkLeaf, inputSchemaOf } from './check.js'; +import type { Resolved } from './declaration.js'; +import type { Leaf } from './leaf.js'; +import type { System } from './system.js'; + +/** + * Makes a system of a declaration as the world takes it, with what its body reads and writes already listed: for the + * implementation's own systems, such as those standing for a joined thread's, whose bodies no build reads. Checks it + * as `System.create` does. Not part of the package's interface. + */ +export const createResolved = (declaration: Resolved, body: Leaf["body"], input?: unknown): System => { + const name = nameOf(declaration, undefined, "system"); + const schema = inputSchemaOf(name, body, declaration.components === undefined ? 1 : 2, input); + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body }; + checkLeaf(system); + claimSystem(system, undefined); + return system; +}; diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index bafd6052..0324b73c 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; import { definitionOf, firstItemOf, idOrNull, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; -import { identify } from "../registry/ids.test-helper.js"; +import { giveAccess, identify } from "../registry/ids.test-helper.js"; import { clock, gpuDevice } from "../resources/index.js"; import { System } from "./system.js"; +import { createResolved } from "./create-resolved.js"; import type { F32 } from "../math/index.js"; const position = Symbol("typesSystemTest/position"); @@ -38,37 +39,38 @@ declare module "@adobe/ecs" { } -/** Creates as an untyped caller would, bypassing the types' checks. */ -const create = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); +/** Creates a system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ +const create = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); /** As the plugin calls it for a body taking input: with the schema of its input after the body. */ -const createTaking = (declaration: object, input: unknown): unknown => Reflect.apply(System.create, undefined, [declaration, (_context: unknown, _input: unknown) => {}, input]); +const createTaking = (declaration: object, input: unknown): unknown => Reflect.apply(createResolved, undefined, [declaration, (_context: unknown, _input: unknown) => {}, input]); describe("System.create", () => { it("records the system, its value holding its declaration and body", () => { const body = (): void => {}; - const move = System.create({ components: { write: [position] }, resources: { read: [time] } }, body); + // A body the build doesn't see is given what it uses. + const move = System.create(giveAccess({ components: { with: [position] } }, { write: [position], resources: { read: [time] } }), body); expect(definitionOf(move)).toEqual({ kind: "system", name: "move", declaration: { components: { write: [position] }, resources: { read: [time] } }, body }); expect(move.name).toBe("move"); expect(definitionOf({ name: "move" })).toBe(undefined); }); it("labels each system by the binding or function it's made in, however many share the label", () => { - const twice = (): System => System.create({ components: { read: [position] } }, row => { void row[position]; }); + const twice = (): System => System.create({ components: { with: [position] } }, row => { void row[position]; }); const [first, again] = [twice(), twice()]; expect(sameItem(first, again)).toBe(false); expect([first.name, again.name, idOrNull(first)]).toEqual(["twice", "twice", null]); expect(scheduleOf(requireDefinition(System.create({ systems: [first, again] }))).tiers.flat()).toEqual([first, again]); }); it("labels a system by the binding of its id, which reports give beside it, and takes a declaration of a registered id, the same, as the same system", () => { - const [first, elsewhere, again] = ["a.ts#linked", "b.ts#linked", "a.ts#linked"].map(id => System.create(identify({ components: { read: [position] } }, id), () => {})); + const [first, elsewhere, again] = ["a.ts#linked", "b.ts#linked", "a.ts#linked"].map(id => System.create(identify({ components: { with: [position] } }, id), () => {})); expect([first?.name, elsewhere?.name, first === undefined ? null : idOrNull(first)]).toEqual(["linked", "linked", "a.ts#linked"]); expect(again).not.toBe(first); expect(first !== undefined && again !== undefined && elsewhere !== undefined ? [sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)] : null).toEqual([true, false, first]); - expect(() => ["a.ts#linked"].map(id => System.create(identify({ components: { write: [position] } }, id), () => {}))).toThrow('Cannot declare "linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + expect(() => ["a.ts#linked"].map(id => System.create(identify({ components: { with: [position], changed: [position] } }, id), () => {}))).toThrow('Cannot declare "linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); expect(() => ["a.ts#linked"].map(id => first === undefined ? null : System.create(identify({ systems: [first] }, id)))).toThrow("its id is a system's"); }); it("labels a system made with neither an id nor a binding by its kind numbered", () => { const body = ({ commands }: { readonly commands: System.Commands }): void => { commands.spawn([position])({ [position]: 0 }); }; - const [alone] = [System.create(identify(body, "setup.ts#spawnOnce"))]; + const [alone] = [System.create(giveAccess(identify(body, "setup.ts#spawnOnce")))]; expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce", declaration: {}, body }); const [first, second] = [System.create(() => {}), System.create({ systems: [alone] })].map(system => system.name); const number = Number(first?.slice("system #".length)); @@ -119,22 +121,22 @@ describe("System.create", () => { }); }); it("derives its thread from its object components and resources, and rejects two", () => { - const show = System.create({ components: { read: [position, node] }, resources: { read: [time, document] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); - const count = System.create({ components: { read: [tags], has: [node] } }, row => { void row[tags]; }); - const tick = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); + const show = System.create({ components: { with: [position, node] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); + const count = System.create({ components: { with: [tags, node] } }, row => { void row[tags]; }); + const tick = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); const leaves = [show, count, tick].map(system => definitionOf(system)); expect(leaves.map(leaf => leaf === undefined || leaf.kind === "composite" ? undefined : threadOf(leaf.declaration, "System", leaf.name))).toEqual(["main", "world", null]); expect(() => create({ components: { read: [tags], write: [node] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); expect(() => create({ components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); // A string lives in shared memory, so it goes with any thread's objects. - const caption = System.create({ components: { read: [label, node] } }, row => { row[node].textContent = row[label]; }); + const caption = System.create({ components: { with: [label, node] } }, row => { row[node].textContent = row[label]; }); const captionLeaf = definitionOf(caption); expect(captionLeaf === undefined || captionLeaf.kind === "composite" ? undefined : threadOf(captionLeaf.declaration, "System", captionLeaf.name)).toBe("main"); expect(() => create({ components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); expect(() => create({ components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); }); it("says why a key isn't registered: undeclared, or its module not built with the plugin", () => { - const create = (declaration: unknown) => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const create = (declaration: unknown) => Reflect.apply(createResolved, undefined, [declaration, () => {}]); expect(() => create({ components: { read: [unregistered] } })).toThrow("declare it beside its key, in interface Components or Resources"); // Made in a function, so the build gave it no id, as one made in a module the plugin didn't build has none. const unbuilt = Symbol("unbuilt"); @@ -144,8 +146,8 @@ describe("System.create", () => { describe("System.create of a composite", () => { it("records a composite whose members may live on different threads, from its list or its declaration", () => { - const show = System.create({ components: { read: [node] } }, row => { row[node].hidden = true; }); - const name = System.create({ components: { write: [label] } }, row => { row[label] = ""; }); + const show = System.create({ components: { with: [node] } }, row => { row[node].hidden = true; }); + const name = System.create({ components: { with: [label] } }, row => { row[label] = ""; }); const frame = System.create({ systems: [show, name] }); expect(definitionOf(frame)?.kind).toBe("composite"); expect(definitionOf(System.create({ systems: [show] }))).not.toBe(definitionOf(frame)); @@ -157,23 +159,23 @@ describe("System.create of a composite", () => { it("tells a composite's declaration from a system's by its body", () => { const untyped: (...parameters: never[]) => unknown = System.create; expect(() => Reflect.apply(untyped, undefined, [{ name: "bodiless" }])).toThrow("A system needs a body, and a composite its systems."); - const leaf = Reflect.apply(untyped, undefined, [{ name: "leafWithSystems", systems: [] }, () => {}]); + const leaf = Reflect.apply(untyped, undefined, [giveAccess({ systems: [] }), () => {}]); expect(definitionOf(leaf)?.kind).toBe("system"); }); it("rejects members ordered in a cycle, and conflicting members with no order between them", () => { - const first = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); - const second = System.create({ components: { read: [position] }, after: [first] }, row => { void row[position]; }); - const third = System.create({ components: { read: [position] } }, row => { void row[position]; }); + const first = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); + const second = System.create({ components: { with: [position] }, after: [first] }, row => { void row[position]; }); + const third = System.create({ components: { with: [position] } }, row => { void row[position]; }); expect(scheduleOf(requireDefinition(System.create({ systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["first"], ["second"]]); expect(() => System.create({ systems: [first, third] })).toThrow('Systems "first" and "third" both access "typesSystemTest/position" and one writes it'); const looped = System.create({ before: [first], after: [second] }, () => {}); expect(() => System.create({ systems: [first, second, looped] })).toThrow("Ordering cycle"); }); it("runs observer members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { - const write = System.create({ components: { write: [position] } }, row => { row[position] += 1; }); - const watch = System.create({ observer: true, components: { read: [position] } }, row => { void row[position]; }); - const later = System.create({ components: { read: [position] }, after: [watch, write] }, row => { void row[position]; }); - const free = System.create({ resources: { read: [time] }, after: [watch] }, ({ resources }) => { void resources[time]; }); + const write = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); + const watch = System.create({ observer: true, components: { with: [position] } }, row => { void row[position]; }); + const later = System.create({ components: { with: [position] }, after: [watch, write] }, row => { void row[position]; }); + const free = System.create({ after: [watch] }, ({ resources }) => { void resources[time]; }); const schedule = scheduleOf(requireDefinition(System.create([write, watch, later, free]))); expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["write", "free"], ["later"]]); expect(schedule.observer.map(leaf => leaf.name)).toEqual(["watch"]); @@ -196,13 +198,13 @@ describe("System.render", () => { const draw = System.render({ shader: { accessors: { place: height, again: height } } }, () => {}); const definition = definitionOf(draw); expect(definition?.kind === "render" ? [definition.declaration.components, definition.declaration.resources] : null).toEqual([{ read: [height] }, { is: [gpuDevice] }]); - const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); - expect(() => render({ resources: { read: [document] }, shader: { accessors: {} } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); + const render = (declaration: object, uses: Parameters[1] = {}): unknown => Reflect.apply(System.render, undefined, [giveAccess(declaration, uses), () => {}]); + expect(() => render({ shader: { accessors: {} } }, { resources: { read: [document] } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); }); it("rejects at creation accessors its shader can't read, and reductions", () => { - const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); + const render = (declaration: object, uses: Parameters[1] = {}): unknown => Reflect.apply(System.render, undefined, [giveAccess(declaration, uses), () => {}]); expect(() => System.render({ shader: { accessors: { loop: height } } }, () => {})).toThrow(`can't render: "loop" isn't usable as a WGSL function name.`); - expect(() => render({ shader: { accessors: {} }, resources: { reduce: [total] } })).toThrow("can't reduce: a render system records GPU work rather than running rows"); + expect(() => render({ shader: { accessors: {} } }, { resources: { reduce: [total] } })).toThrow("writes or reduces into resources; a render system only reads"); expect(() => render({ shader: { accessors: {} }, observer: true })).toThrow("is an observer, so it records no GPU work"); }); }); diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 4af69e51..b291e18a 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -1,93 +1,59 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Index } from '../indexes/index-type.js'; +import type { Condition } from '../resource/condition.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; -import type { Resource } from '../resource/resource.js'; -import type { Body } from './body.js'; -import type { Commands } from './commands.js'; +import type { Body, Context } from './body.js'; import { checkComposite, checkLeaf, inputSchemaOf } from './check.js'; import type { Composite } from './composite.js'; import type { CompositeDeclaration } from './composite-declaration.js'; -import type { Declaration } from './declaration.js'; +import type { Declaration, Resolved } from './declaration.js'; import type { CheckMembers, CompositeInputOf, InputOf } from './input.js'; import type { Leaf } from './leaf.js'; import type { Listed, Property } from './property.js'; -import type { StructComponent, StructResource } from './struct.js'; import type { System } from './system.js'; import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; import type { HeldBy } from './values.js'; +import { accessOf, compact, componentLists, lookupsLists, resourceUses, upReads } from './resolve.js'; -type Reduced = Listed, "reduce"> & Resource.Key; -type Written = Listed, "write">; -type Is = Listed, "is">; type Lookups = Property; -/** - * An index lookup reads and watches only what every entity its index holds has, and its system doesn't write what it - * finds rows by or reads through it. A lookup by entity watches only what it reads or has. - */ -type IndexLookups = { +/** A lookup's `changed` names what it requires: a lookup by entity's `with`, an index lookup's what its index holds. */ +type LookupChanges = { readonly lookup?: { readonly [N in keyof Lookups]?: Lookups[N] extends { readonly index: Index } - ? { - readonly index?: Index<{ readonly key: readonly Exclude>[] }>; - readonly read?: readonly Exclude, Written>[]; - readonly changed?: readonly HeldBy[] | true; - } - : { readonly changed?: readonly (Listed[N], "read"> | Listed[N], "has">)[] } + ? { readonly changed?: readonly HeldBy[] | true } + : { readonly changed?: readonly Listed[N], "with">[] } }; }; -/** A system's rows are ordered by an index whose key parts it doesn't write. */ -type Order = { readonly order?: Index<{ readonly key: readonly Exclude>[] }> }; - -/** - * A key in `write` is readable too, so it isn't also in `read`. A system doesn't read or filter on what it reduces - * into, since the fold is only finished after every contributor runs. A condition in `is` is readable too, and isn't - * written or also in `not`. - */ -type Lists = { - readonly components?: { readonly read?: readonly Exclude>[] }; - readonly resources?: { - readonly read?: readonly Exclude | Reduced | Is>[]; - readonly write?: readonly Exclude>[]; - readonly changed?: readonly Exclude>[]; - readonly not?: readonly Exclude>[]; - readonly reduce?: readonly Exclude | Is>[]; - }; +/** What a body reads, writes and reduces into is inferred, never declared. */ +type Inferred = { + readonly components?: { readonly read?: never; readonly write?: never; readonly has?: never }; + readonly resources?: { readonly read?: never; readonly write?: never; readonly reduce?: never }; + readonly up?: { readonly read?: never }; + readonly reduce?: never; }; -type Kernel = { - readonly components?: { readonly read?: readonly StructComponent[]; readonly write?: readonly StructComponent[] }; - readonly lookup?: { - readonly [name: string]: - | { readonly read?: readonly StructComponent[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } - | { readonly read?: readonly StructComponent[]; readonly changed?: readonly Component.Key[] | true; readonly index: Index<{ readonly key: readonly StructComponent[] }> }; - }; - readonly up?: { readonly read?: readonly StructComponent[] }; - readonly order?: Index<{ readonly key: readonly StructComponent[] }>; - readonly resources?: { readonly read?: readonly StructResource[]; readonly write?: never }; -}; +/** A condition is in `is` or `not`, not both. */ +type Conditions = { readonly resources?: { readonly not?: readonly Exclude, "is">>[] } }; /** What a GPU kernel can't use yet, beyond any kernel's limits. */ type GpuKernel = { readonly lookup?: { readonly [name: string]: never }; readonly components?: { readonly added?: never; readonly changed?: never }; - readonly resources?: { readonly changed?: never; readonly reduce?: never }; + readonly resources?: { readonly changed?: never }; readonly order?: { readonly "error: GPU kernels have no order yet": true }; }; /** - * An observer system only reads, after a sync point: it writes and reduces into nothing, never compiles to a kernel, has no `up`, - * and is ordered with nothing. That it takes no input is checked at its body. + * An observer system only reads, after a sync point: its row and resources are read-only, and it never compiles to a + * kernel, has no `up`, and is ordered with nothing. That it takes no input is checked at its body. */ type Observer = { readonly kernel?: false; - readonly components?: { readonly write?: never }; - readonly resources?: { readonly write?: never; readonly reduce?: never }; readonly up?: never; readonly before?: never; readonly after?: never; @@ -99,14 +65,12 @@ type Targeted = { readonly kernel?: false; readonly up?: never; readonly order?: never; - readonly resources?: { readonly reduce?: never }; }; type Validate = - Lists - & IndexLookups - & Order - & (D extends { readonly kernel: true | "gpu" } ? Kernel : {}) + LookupChanges + & Conditions + & Inferred & (D extends { readonly kernel: "gpu" } ? GpuKernel : {}) // `up` and `order` order rows, so a system without them has neither, and one has at most one of them. & (Property extends undefined ? { @@ -139,8 +103,8 @@ type Checks = [I] extends [never] ? unknown : : (D extends { readonly components: { readonly added: object } | { readonly changed: object } } ? { readonly "error: a system with input can't filter on added or changed, as its runs pass different input; pass it through a resource": true } : unknown) & (D extends { readonly observer: true } ? { readonly "error: an observer system takes no input": true } : unknown)); -/** The body of a system that declares nothing, which runs once per run with its commands. */ -type Alone = (context: { readonly commands: Commands }) => void; +/** The body of a system that declares nothing, which runs once per run with its resources and commands. */ +type Alone = (context: Context<{}>) => void; /** A composite: its list of members, or its declaration. */ type Composition = readonly System[] | CompositeDeclaration; @@ -153,19 +117,20 @@ type Checked = C extends Alone ? unknown : C extends CompositeDeclaration ? { /** * Declares a system and returns it; nothing runs. The arguments tell the forms apart: - * - `create(body)`: a system that declares nothing, whose body runs once per run with its commands. + * - `create(body)`: a system that declares nothing, whose body runs once per run with its resources and commands. * - `create(declaration, body)`: a system. * - `create([move, collide])`: a composite of the systems listed, which may live on different threads. * - `create({ systems: [move, collide], after: [input] })`: a composite with constraints. * - * Each run passes a composite's members one input, with every field of theirs. Throws if a system names a key that - * isn't registered as its kind, lists a key in both `read` and `write`, writes a reference, a lookup reads a component - * it writes or goes through an index keyed by one, or reads through an index what not every entity it holds has, its + * What a body reads, writes and reduces into the build infers from it. Each run passes a composite's members one + * input, with every field of theirs. Throws if a system names a key that isn't registered as its kind, its body writes + * a reference, a lookup's rows are read where it writes them or it goes through an index keyed by what it writes, its * rows are ordered by an index it isn't sorted or keyed by what it writes, its object components and resources live on * different threads, or its reductions, conditions, `up`, `order` or placement can't hold, or it's an observer and - * writes, reduces, takes input, is a kernel, has `up` or is ordered; or if a composite's member isn't a system a - * constructor returned, or two members declare an input field with different schemas. A composite's observer members - * run after its sync point, ordered with nothing. + * writes, reduces, takes input, is a kernel, has `up` or is ordered; if the build couldn't follow how its body uses its + * resources, lookups or parent; or if a composite's member isn't a system a constructor returned, or two members + * declare an input field with different schemas. A composite's observer members run after its sync point, ordered + * with nothing. * * One argument, a body or a composite, is one overload, so a mistake in it is reported where it is rather than as no * overload matching. @@ -179,10 +144,11 @@ export function create(first: Declaration | Leaf["body"] | readonly System = { kind: "system", name, declaration, input: schema, body }; checkLeaf(system); @@ -190,6 +156,21 @@ export function create(first: Declaration | Leaf["body"] | readonly System): Resolved => { + const reading = declared.observer === true; + const { components, lookup, up, resources, ...rest } = declared; + const uses = resourceUses(what, access, resources?.is); + const resolved: Resolved = { + ...rest, + ...components === undefined ? {} : { components: componentLists(components, access.components, reading) }, + ...lookup === undefined ? {} : { lookup: lookupsLists(lookup, access) }, + ...up === undefined ? {} : { up: { through: up.through, read: upReads(what, access) } }, + }; + const lists = compact({ read: uses.read, write: uses.write, reduce: uses.reduce, changed: resources?.changed, is: resources?.is, not: resources?.not }); + return Object.keys(lists).length === 0 ? resolved : { ...resolved, resources: lists }; +}; + const isList = (value: unknown): value is readonly System[] => Array.isArray(value); /** A composite of `declaration`, labeled by `first`, the argument the plugin labeled. */ diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 79bb41f8..f9ca0dac 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -1,14 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Index } from '../indexes/index-type.js'; import type { Resource } from '../resource/resource.js'; -import type { ComponentAccess, ResourceAccess, SystemLookup, Up } from './access.js'; +import type { ComponentAccess, ComponentLists, ResourceAccess, ResourceLists, SystemLookup, SystemLookupLists, Up, UpLists } from './access.js'; import type { Shader } from './render.js'; import type { System } from './system.js'; /** * A system never declares its thread: it runs on the one its object components and resources live on, those it - * reads, writes or filters on by change, through `components`, `lookup`, `up` and `resources` but `has` and `not`. - * Without them, a kernel runs on the kernel pool and any other system on `"world"`. + * reads, writes or filters on by change, through its row, lookups, `up` and resources. Without them, a kernel runs on + * the kernel pool and any other system on `"world"`. A declaration says only what its body can't show: which entities + * it runs over, its filters and conditions, and its order; the build infers what the body reads, writes and reduces. */ export type Declaration = { /** @@ -25,14 +26,14 @@ export type Declaration = { * matched row counts as changed in what it writes. */ readonly kernel?: boolean | "gpu"; - /** The entities it runs over, and what it reads and writes of theirs. Omitted for a system that runs once per run. */ + /** The entities it runs over. Omitted for a system that runs once per run. */ readonly components?: ComponentAccess; readonly lookup?: { readonly [name: string]: SystemLookup }; /** - * Runs rows in depth order through `through`, so each reads its parent's `read` values after the parent ran, as - * the context's `up`, or null for a row without a parent. With change filters, a row also passes when its parent, - * or what it reads of its parent, changed since the system last ran, or it moved to another archetype, as removing - * its parent moves it. Rows on or under a parent cycle don't run. + * Runs rows in depth order through `through`, so each reads its parent's values after the parent ran, as the + * context's `up`, or null for a row without a parent. With change filters, a row also passes when its parent, or + * what it reads of its parent, changed since the system last ran, or it moved to another archetype, as removing its + * parent moves it. Rows on or under a parent cycle don't run. */ readonly up?: Up; /** @@ -45,7 +46,7 @@ export type Declaration = { /** * The field of its body's input naming the one entity it runs on: its body runs once, on that entity's row, when * the entity is alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, - * `up`, `order` or `reduce`. + * `up` or `order`, nor with a body that reduces. */ readonly target?: string; /** Makes this a render system (see `render`): its body records GPU work over the GPU copy of the components it names. */ @@ -62,3 +63,11 @@ export type Declaration = { */ readonly observer?: true; }; + +/** A declaration as the world takes it: the build's inferred reads, writes and reductions joined with what it declares. */ +export type Resolved = Omit & { + readonly components?: ComponentLists; + readonly lookup?: { readonly [name: string]: SystemLookupLists }; + readonly up?: UpLists; + readonly resources?: ResourceLists; +}; diff --git a/packages/ecs/src/interface/system/leaf.ts b/packages/ecs/src/interface/system/leaf.ts index 627fc38a..daf745e8 100644 --- a/packages/ecs/src/interface/system/leaf.ts +++ b/packages/ecs/src/interface/system/leaf.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Schema } from '../schema/schema.js'; -import type { Declaration } from './declaration.js'; +import type { Resolved } from './declaration.js'; import type { Name } from './name.js'; /** The schema of a body's input: an object of named fields. */ @@ -14,7 +14,7 @@ export type InputSchema = Extract; export type Leaf = { readonly kind: "system" | "render"; readonly name: Name; - readonly declaration: Declaration; + readonly declaration: Resolved; readonly input: InputSchema | undefined; readonly body: (...parameters: never[]) => void; }; diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index 00ce30be..04d37422 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -5,12 +5,12 @@ import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; import type { Condition } from '../resource/condition.js'; -import type { Resource } from '../resource/resource.js'; import type { ThreadOfResource } from '../resource/value.js'; import { gpuDevice } from '../resources/gpu-device.js'; import type { Body } from './body.js'; import { accessorsOf } from '../gpu/accessors.js'; import { checkLeaf } from './check.js'; +import { accessOf, compact, resourceUses } from './resolve.js'; import type { Leaf } from './leaf.js'; import type { StructComponent } from './struct.js'; import type { System } from './system.js'; @@ -42,10 +42,10 @@ export type RenderContext = { }; type RenderDeclaration = { - /** It reads the components its accessors name. */ - readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; - /** It runs only while `gpuDevice` is set, as if `is` listed it. */ - readonly resources?: { readonly read?: readonly Resource.Key[]; readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + /** It matches the components its accessors name, and those in `with`, and none in `not`. */ + readonly components?: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + /** It runs only while `gpuDevice` is set, as if `is` listed it. What its body reads of resources the build infers. */ + readonly resources?: { readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; /** * Draws every matching row as one table, in this sorted index's order: key order, then entity order. Its shaders' * accessors then take a row's position in that order. @@ -57,8 +57,11 @@ type RenderDeclaration = { readonly during?: readonly System[]; }; -/** A render declaration with the components its accessors name as `components.read`. */ -type Rendered = D & { readonly components: { readonly read: readonly StructComponent[] } }; +/** What a render body reads of resources is inferred, and what it reads of components its accessors name. */ +type Inferred = { + readonly components?: { readonly read?: never; readonly has?: never }; + readonly resources?: { readonly read?: never }; +}; /** * Declares a render system: it runs once per run on `gpuDevice`'s thread, in schedule order, and records GPU work over @@ -71,22 +74,28 @@ type Rendered = D & { readonly components: { readon * 2^31 with up to two matching archetypes, 2^30 with up to four, and so on. */ export const render = ( - declaration: D & OneThread | ThreadOfResource, ThreadedBy>, - body: Body>, + declaration: D & Inferred & OneThread | ThreadOfResource, ThreadedBy>, + body: Body, ): System => { const id = takeIdOfDeclaration(declaration); const name = nameOf(declaration, id, "system"); + const what = `System "${name}"`; + const read = [...new Set(Object.values(declaration.shader.accessors))]; + const is = [...new Set([...declaration.resources?.is ?? [], gpuDevice])]; + const uses = resourceUses(what, accessOf(what, declaration), is); + const { components, resources, ...rest } = declaration; const system: Leaf & System = { kind: "render", name, declaration: { - ...declaration, - components: { ...declaration.components, read: [...new Set(Object.values(declaration.shader.accessors))] }, - resources: { ...declaration.resources, is: [...new Set([...declaration.resources?.is ?? [], gpuDevice])] }, + ...rest, + components: compact({ read, has: (components?.with ?? []).filter(key => !read.includes(key)), not: components?.not }), + resources: compact({ read: uses.read, is, not: resources?.not }), }, input: undefined, body, }; + if (uses.write.length > 0 || uses.reduce.length > 0) throw new Error(`${what} writes or reduces into resources; a render system only reads.`); checkLeaf(system); const accessors = accessorsOf(declaration.shader); if (typeof accessors === "string") throw new Error(`System "${name}" can't render: ${accessors}.`); diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts new file mode 100644 index 00000000..fc9e712d --- /dev/null +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { lookupsLists, type Access } from "./resolve.js"; + +const mass = Symbol("resolveTest/mass"); +const position = Symbol("resolveTest/position"); +const access = (lookup: Access["lookup"]): Access => ({ components: null, resources: null, lookup, up: null, at: "a test" }); + +describe("lookupsLists", () => { + it("takes a lookup whose rows the build didn't follow as reading everything it requires", () => { + expect(lookupsLists({ others: { with: [mass, position] } }, access({ others: null }))).toEqual({ others: { read: [mass, position] } }); + }); + it("takes a lookup the body never used as reading nothing, and one it followed as reading what it read", () => { + expect(lookupsLists({ others: { with: [mass, position] } }, access({}))).toEqual({ others: { has: [mass, position] } }); + expect(lookupsLists({ others: { with: [mass, position] } }, access({ others: [position] }))).toEqual({ others: { read: [position], has: [mass] } }); + }); +}); diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts new file mode 100644 index 00000000..a5cbbe70 --- /dev/null +++ b/packages/ecs/src/interface/system/resolve.ts @@ -0,0 +1,123 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { takeAccessOf } from '../registry/ids.js'; +import { indexDefinitionOf } from '../registry/indexes.js'; +import { declarationOf } from '../registry/names.js'; +import type { ComponentAccess, ComponentLists, Lookup, SystemLookup, SystemLookupLists } from './access.js'; + +type Key = symbol; + +/** What a body reads and writes of a group of keys. */ +type Used = { readonly read: readonly Key[]; readonly write: readonly Key[] }; + +/** + * What the build infers a body uses, which the plugin gives the declaration or body a constructor is called with: what + * it reads and writes of its row, and of resources with what it reduces into; what it reads of each lookup's rows, + * and of its parent's values with `up`. Each is null where the build couldn't follow the body's uses, and `at` names + * where the body is, for errors. + */ +export type Access = { + readonly components: Used | null; + readonly resources: (Used & { readonly reduce: readonly Key[] }) | null; + readonly lookup: { readonly [name: string]: readonly Key[] | null } | null; + readonly up: readonly Key[] | null; + readonly at: string; +}; + +const isKeys = (value: unknown): value is readonly Key[] => Array.isArray(value) && value.every(key => typeof key === "symbol"); + +const isUsed = (value: unknown): value is Used => typeof value === "object" && value !== null + && isKeys(Reflect.get(value, "read")) && isKeys(Reflect.get(value, "write")); + +const isAccess = (value: unknown): value is Access => { + if (typeof value !== "object" || value === null) return false; + const components: unknown = Reflect.get(value, "components"); + const resources: unknown = Reflect.get(value, "resources"); + const lookup: unknown = Reflect.get(value, "lookup"); + const up: unknown = Reflect.get(value, "up"); + return (components === null || isUsed(components)) + && (resources === null || (isUsed(resources) && isKeys(Reflect.get(resources, "reduce")))) + && (lookup === null || (typeof lookup === "object" && Object.values(lookup).every(reads => reads === null || isKeys(reads)))) + && (up === null || isKeys(up)) && typeof Reflect.get(value, "at") === "string"; +}; + +/** + * What the build inferred the body a constructor is called with uses: given to its declaration, or for a system + * declaring nothing to its body. Throws when there's none, as when a module isn't built with the plugin. + */ +export const accessOf = (what: string, made: object): Access => { + const access = takeAccessOf(made); + if (!isAccess(access)) throw new Error(`${what} has no access the build inferred from its body: the build reads what a body uses where the constructor is called with its declaration written in place, as an object literal, and its body as a function, in a module built with Compiler.plugin().`); + return access; +}; + +/** Throws for a group the build couldn't follow, naming what the body did instead. */ +const followed = (what: string, used: T | null, group: string, access: Access): T => { + if (used !== null) return used; + throw new Error(`${what}'s body (${access.at}) uses ${group} in a way the build can't follow, so it can't tell what it reads and writes of them: index them by key directly, or destructure them by key, rather than passing them on, aliasing or spreading them.`); +}; + +const without = (keys: readonly Key[], ...others: (readonly Key[] | undefined)[]): Key[] => + keys.filter(key => others.every(other => !other?.includes(key))); + +/** `lists` without its empty lists. */ +export const compact = (lists: T): T => { + const result: { [name: string]: unknown } = {}; + for (const [name, value] of Object.entries(lists)) if (value !== undefined && !(Array.isArray(value) && value.length === 0)) result[name] = value; + // Every field is one of `lists`', with its own value. + return result as T; +}; + +/** + * A row's lists: what the body reads and writes of its members, the others only required. Without the body's uses, + * conservatively every member is written but references, which change only through commands, or read where it only + * reads. + */ +export const componentLists = (components: ComponentAccess, used: Used | null, reading: boolean): ComponentLists => { + const members = components.with ?? []; + const write = used === null + ? reading ? [] : members.filter(key => declarationOf(key)?.reference === undefined) + : used.write; + const read = used === null ? without(members, write) : without(used.read, write); + const has = without(members, read, write); + return compact({ read, write, has, not: components.not, added: components.added, changed: components.changed }); +}; + +/** + * A lookup's lists: what the body reads of its rows, the rest of its `with` only required. Without the body's uses, + * conservatively everything its rows hold is read: its `with`, or every component its index holds. + */ +export const lookupLists = (lookup: SystemLookup | Lookup, reads: readonly Key[] | null): SystemLookupLists => { + if (lookup.index !== undefined) { + const index = indexDefinitionOf(lookup.index)?.declaration; + const read = reads ?? [...index?.key ?? [], ...index?.components?.has ?? []]; + const { changed } = lookup; + return changed === true ? { index: lookup.index, read, changed } : compact({ index: lookup.index, read, changed }); + } + const read = reads ?? lookup.with ?? []; + return compact({ read, has: without(lookup.with ?? [], read), not: lookup.not, changed: lookup.changed }); +}; + +/** Each lookup's lists, by name. */ +export const lookupsLists = (lookups: { readonly [name: string]: L } | undefined, access: Access): { readonly [name: string]: SystemLookupLists } | undefined => { + if (lookups === undefined) return undefined; + const lists: { [name: string]: SystemLookupLists } = {}; + for (const name in lookups) { + const lookup = lookups[name]; + // A lookup whose rows the build didn't follow is null; one it saw unused has no entry. + const reads = access.lookup === null ? null : name in access.lookup ? access.lookup[name] ?? null : []; + if (lookup !== undefined) lists[name] = lookupLists(lookup, reads); + } + return lists; +}; + +/** + * What a body reads, writes and reduces into of resources, conditions in `is` read already. A read of what it reduces + * into stays, for registration to reject. + */ +export const resourceUses = (what: string, access: Access, is: readonly Key[] | undefined): { readonly read: Key[]; readonly write: readonly Key[]; readonly reduce: readonly Key[] } => { + const used = followed(what, access.resources, "its resources", access); + return { read: without(used.read, used.write, is), write: used.write, reduce: used.reduce }; +}; + +/** What a body reads of its parent's values. */ +export const upReads = (what: string, access: Access): readonly Key[] => followed(what, access.up, "its parent's values", access); diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index 2b5ad03c..b3615efd 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -12,6 +12,7 @@ export const position = Symbol("systemTest/position"); const velocity = Symbol("systemTest/velocity"); const target = Symbol("systemTest/target"); const player = Symbol('systemTest/player'); +const owner = Symbol("systemTest/owner"); export const time = Symbol("systemTest/time"); const score = Symbol("systemTest/score"); @@ -21,6 +22,7 @@ declare module '@adobe/ecs' { [velocity]: { type: number }; [target]: { type: Entity }; [player]: { type: true }; + [owner]: { type: Entity; reference: "owner" }; } } const flagged = Symbol("systemTest/flagged"); @@ -36,31 +38,37 @@ declare module '@adobe/ecs' { } } -// Row: read values are read-only, write values writable, others absent. +// Row: the values of what `with` requires, writable; what the body reads and writes the build infers. export const integrate = System.create({ - components: { read: [velocity], write: [position], has: [player] }, - resources: { read: [time] }, + components: { with: [velocity, position, player] }, }, (row, { resources: { [time]: delta } }) => { row[position] += row[velocity] * delta; const entity: Entity = row.entity; - // @ts-expect-error read values are read-only - row[velocity] = 0; // @ts-expect-error entity is read-only row.entity = entity; - // @ts-expect-error components outside read and write are not in the row void row[player]; }); -// Write grants read: a component in write is readable, and listing it in read as well is rejected. +System.create({ components: { with: [position] } }, row => { + // @ts-expect-error components outside with are not in the row + void row[velocity]; +}); + System.create({ - // @ts-expect-error write already makes it readable - components: { read: [position], write: [position] }, + // @ts-expect-error what the body reads and writes isn't declared + components: { read: [position] }, }, () => {}); +// A reference changes only through commands, so the row holds it read-only. +System.create({ components: { with: [owner] } }, row => { + // @ts-expect-error a reference is read-only in a row + row[owner] = Entity.none; +}); + // Lookups: read-only values by entity, or null. export const follow = System.create({ - components: { read: [target, position], write: [velocity] }, - lookup: { targets: { read: [position] } }, + components: { with: [target, position, velocity] }, + lookup: { targets: { with: [position] } }, after: [integrate], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -78,8 +86,8 @@ export const follow = System.create({ // A lookup by entity with `changed`: its row also says whether what `changed` names may have changed since the system // last ran. System.create({ - components: { read: [target], write: [velocity] }, - lookup: { targets: { read: [position], changed: [position] }, players: { has: [player], changed: [player] } }, + components: { with: [target, velocity] }, + lookup: { targets: { with: [position], changed: [position] }, players: { with: [player], changed: [player] } }, }, (row, { lookup: { targets, players } }) => { const other = targets(row[target]); if (other !== null) { @@ -89,55 +97,50 @@ System.create({ other.changed = false; } const found = players(row[target]); - if (found !== null) check>(); + if (found !== null) check>(); }); System.create({ - components: { read: [target], write: [velocity] }, - // @ts-expect-error a lookup by entity's changed names what it reads or has - lookup: { targets: { read: [position], changed: [velocity] } }, + components: { with: [target, velocity] }, + // @ts-expect-error a lookup by entity's changed names what its with requires + lookup: { targets: { with: [position], changed: [velocity] } }, }, () => {}); // Input: the type of the body's parameter after its context. export const move = System.create({ - components: { has: [player], write: [position] }, + components: { with: [position, player] }, }, (row, _context, input: { readonly distance: number }) => { row[position] += input.distance; // @ts-expect-error input has the type the body gives it void input.missing; }); -// The row and the context are still typed by the declaration. -System.create({ components: { read: [velocity], write: [position] }, resources: { read: [time] } }, (row, { resources }, input: { readonly by: number }) => { +// The row is still typed by the declaration, and the context holds every resource. +System.create({ components: { with: [velocity, position] } }, (row, { resources }, input: { readonly by: number }) => { row[position] += row[velocity] * resources[time] * input.by; - // @ts-expect-error a read component is read-only - row[velocity] = input.by; - // @ts-expect-error score isn't a declared resource + // @ts-expect-error components outside with are not in the row + void row[player]; void resources[score]; }); // @ts-expect-error input is the body's parameter, not part of its declaration System.create({ input: { type: "object", properties: {} } }, () => {}); -// A system without components takes only its context; resource writes are writable, and written ones readable. -export const tally = System.create({ - resources: { read: [time], write: [score] }, -}, ({ resources, commands }) => { +// A system without components takes only its context, which holds every resource, writable but those reduced. +export const tally = System.create({}, ({ resources, commands }) => { resources[score] += resources[time]; - // @ts-expect-error read resources are read-only - resources[time] = 0; + // @ts-expect-error a reduced resource is never written + resources[total] = 0; const spawned: Entity = commands.spawn([position, player])({ [position]: 0, [player]: true }); // @ts-expect-error spawn values must match the component type commands.spawn([position])({ [position]: "zero" }); commands.remove(spawned, velocity); }); -// The context holds only what the declaration declares. +// The context holds every resource and reduction, and the lookups the declaration declares. System.create({}, context => { - // @ts-expect-error no resources declared void context.resources; + void context.reduce; // @ts-expect-error no lookups declared void context.lookup; - // @ts-expect-error no reductions declared - void context.reduce; // @ts-expect-error input isn't in the context void context.input; void context.commands; @@ -147,11 +150,6 @@ System.create({}, context => { check>>(); const alone = System.create({}, (_context, input: { readonly count: number }) => { void input.count; }); -System.create({ - // @ts-expect-error write already makes a resource readable - resources: { read: [score], write: [score] }, -}, () => {}); - System.create({ // @ts-expect-error ordering takes systems after: ["test/unregistered"], @@ -187,45 +185,21 @@ declare module '@adobe/ecs' { } } -// Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. -System.create({ - kernel: true, - components: { read: [velocity, vector, player, target], write: [position], has: [label] }, - resources: { read: [time] }, -}, () => {}); - -System.create({ - kernel: true, - // @ts-expect-error kernel values must be linear - components: { read: [label] }, -}, () => {}); - +// Kernels: a kernel's `with` may require any component; what its body reads must be linear, which the build checks. System.create({ kernel: true, - // @ts-expect-error kernel values can't be variable-length arrays - components: { write: [path] }, -}, () => {}); - -System.create({ - kernel: true, - // @ts-expect-error kernel values can't be sets or maps - components: { read: [marks] }, -}, () => {}); - -System.create({ - kernel: true, - components: { read: [velocity] }, - // @ts-expect-error kernel lookups must read linear values - lookup: { labels: { read: [label] } }, -}, () => {}); + components: { with: [velocity, vector, player, target, position, label, path, marks] }, +}, row => { + row[position] = row[velocity]; +}); declare const byTarget: Index<{ readonly key: readonly [typeof target] }>; // A kernel's lookup by entity matches has and not, as any system's does. System.create({ kernel: true, - components: { read: [target], write: [position] }, - lookup: { players: { read: [position], has: [player], not: [velocity] } }, + components: { with: [target, position] }, + lookup: { players: { with: [position, player], not: [velocity] } }, }, (row, { lookup }) => { const found = lookup.players(row[target]); if (found !== null) row[position] = found[position]; @@ -233,49 +207,40 @@ System.create({ System.create({ kernel: true, - components: { read: [velocity] }, - // @ts-expect-error an index lookup matches what its index holds, so it takes no has - lookup: { byTarget: { index: byTarget, read: [position], has: [player] } }, + components: { with: [velocity] }, + // @ts-expect-error an index lookup matches what its index holds, so it takes no with + lookup: { byTarget: { index: byTarget, with: [player] } }, }, () => {}); System.create({ kernel: true, - components: { read: [velocity] }, - // @ts-expect-error kernels can't write resources - resources: { write: [score] }, + components: { with: [velocity] }, }, () => {}); // GPU kernels: everything kernels can't do, and no lookups, change filters or commands. System.create({ kernel: "gpu", - components: { read: [velocity], write: [position] }, - resources: { read: [time] }, + components: { with: [velocity, position] }, }, row => { row[position] = row[velocity]; }); System.create({ kernel: "gpu", - // @ts-expect-error GPU kernel values must be linear too - components: { read: [label] }, -}, () => {}); - -System.create({ - kernel: "gpu", - components: { read: [target], write: [position] }, + components: { with: [target, position] }, // @ts-expect-error GPU kernels can't use lookups - lookup: { targets: { read: [position] } }, + lookup: { targets: { with: [position] } }, }, () => {}); System.create({ kernel: "gpu", // @ts-expect-error GPU kernels can't use change filters - components: { write: [position], changed: [velocity] }, + components: { with: [position], changed: [velocity] }, }, () => {}); System.create({ kernel: "gpu", - components: { read: [velocity], write: [position] }, + components: { with: [velocity, position] }, }, (row, context) => { // @ts-expect-error GPU kernels can't issue commands context.commands.despawn(row.entity); @@ -287,24 +252,21 @@ System.create({ }, () => {}); // Without the flag a body compiles when it can; false runs it as written, whatever it uses. -System.create({ components: { read: [label] } }, () => {}); -System.create({ kernel: false, components: { read: [label], write: [path] }, resources: { write: [score] } }, () => {}); +System.create({ components: { with: [label] } }, () => {}); +System.create({ kernel: false, components: { with: [label, path] } }, () => {}); System.create({ // @ts-expect-error a system without components runs once, so there's nothing to compile kernel: true, - resources: { read: [time] }, }, () => {}); System.create({ // @ts-expect-error a system without components runs once, so there's nothing to compile, for the GPU either kernel: "gpu", - resources: { read: [time] }, }, () => {}); -// Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. +// Reductions: each reduced resource is a function contributing a value of its type, folded with its trait's operator. System.create({ - components: { read: [velocity, vector], changed: [velocity] }, - resources: { reduce: [total, bounds, flagged] }, + components: { with: [velocity, vector], changed: [velocity] }, }, (row, { reduce }) => { reduce[total](row[velocity]); reduce[bounds](row[vector]); @@ -315,31 +277,19 @@ System.create({ void (reduce[total] + 1); }); -System.create({ +System.create({}, ({ reduce }) => { // @ts-expect-error only resources with a reduce trait can be reduced - resources: { reduce: [score] }, -}, () => {}); - -System.create({ - // @ts-expect-error a reduced resource is never written - resources: { write: [total] }, -}, () => {}); - -System.create({ - // @ts-expect-error a contributor doesn't read what it reduces into - resources: { read: [total], reduce: [total] }, -}, () => {}); + reduce[time](1); +}); System.create({ kernel: "gpu", - components: { read: [velocity] }, - // @ts-expect-error GPU kernels can't reduce yet - resources: { reduce: [total] }, + components: { with: [velocity] }, }, () => {}); // Render systems: the accessors name the components they read. -System.render({ components: { has: [player] }, resources: { read: [time] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { +System.render({ components: { with: [player] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { void [gpu.encoder, resources[time]]; }); System.render({ shader: { accessors: { place: position } } }, (_, context) => { @@ -351,6 +301,10 @@ System.render({ components: { read: [position] }, shader: { accessors: { place: position } }, }, () => {}); +System.render({ shader: { accessors: { place: position } } }, (_, { resources }) => { + // @ts-expect-error a render system only reads + resources[score] = 0; +}); // A pass helper several render systems share declares its context and tables by name. const drawAll = (gpu: System.RenderContext, pass: GPURenderPassEncoder): void => { for (const table of gpu.tables) drawTable(table, pass); @@ -368,7 +322,7 @@ System.render({ shader: { accessors: { place: position } } }, gpu => { declare const byPosition: Index<{ readonly key: readonly [typeof position]; readonly sorted: true }>; declare const hashedByPosition: Index<{ readonly key: readonly [typeof position] }>; declare const someIndex: Index; -System.render({ components: { has: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { +System.render({ components: { with: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); drawAll(gpu, pass); pass.end(); @@ -390,7 +344,7 @@ System.render({ }, () => {}); // Names are optional: a declaration may leave it out, and a system that declares nothing is just its body. -export const unnamed = System.create({ components: { write: [position] }, resources: { read: [time] } }, (row, { resources }) => { +export const unnamed = System.create({ components: { with: [position] } }, (row, { resources }) => { row[position] += resources[time]; }); check>(); @@ -403,8 +357,8 @@ export const bodyOnly = System.create(({ commands }) => { commands.spawn([position])({ [position]: 0 }); }); check>(); -// @ts-expect-error a body alone declares no resources -System.create(({ resources }) => { void resources; }); +// A body alone reads and writes resources as any system does. +System.create(({ resources }) => { resources[score] += 1; }); // @ts-expect-error a body alone runs once per run, so it has no row System.create((row: { readonly entity: Entity }) => { void row; }); export const unnamedAll = System.create([unnamed, bodyOnly]); @@ -414,9 +368,8 @@ System.render({ shader: { accessors: { place: position } } }, gpu => { void gpu. // Observer systems read what a sync point left, after it: rows, resources, lookups and change filters, and no commands. export const watchPositions = System.create({ observer: true, - components: { read: [position], changed: [position] }, - resources: { read: [time] }, - lookup: { targets: { read: [velocity] } }, + components: { with: [position], changed: [position] }, + lookup: { targets: { with: [velocity] } }, }, (row, context) => { const read: number = row[position] + context.resources[time]; void [read, context.lookup.targets(row.entity)]; @@ -426,41 +379,45 @@ export const watchPositions = System.create({ void context.commands; }); check>(); -export const watchTime = System.create({ observer: true, resources: { read: [time], changed: [time] } }, ({ resources }) => { void resources[time]; }); +export const watchTime = System.create({ observer: true, resources: { changed: [time] } }, ({ resources }) => { void resources[time]; }); // A composite may hold observer members, which run after its sync point. export const withWatch = System.create([integrate, watchTime]); check>(); // @ts-expect-error observer is true or left out -System.create({ observer: false, resources: { read: [time] } }, () => {}); -// @ts-expect-error an observer system writes no components -System.create({ observer: true, components: { write: [position] } }, () => {}); -// @ts-expect-error an observer system writes no resources -System.create({ observer: true, resources: { write: [score] } }, () => {}); -// @ts-expect-error an observer system reduces into nothing -System.create({ observer: true, components: { read: [velocity] }, resources: { reduce: [total] } }, () => {}); +System.create({ observer: false }, () => {}); +System.create({ observer: true, components: { with: [position] } }, (row, { resources }) => { + // @ts-expect-error an observer system writes no components + row[position] = 0; + // @ts-expect-error an observer system writes no resources + resources[score] = 0; +}); +System.create({ observer: true }, context => { + // @ts-expect-error an observer system reduces into nothing + void context.reduce; +}); // @ts-expect-error an observer system takes no input -System.create({ observer: true, resources: { read: [time] } }, (_context, input: { readonly count: number }) => { void input; }); +System.create({ observer: true }, (_context, input: { readonly count: number }) => { void input; }); // @ts-expect-error an observer system isn't a kernel -System.create({ observer: true, kernel: true, components: { read: [position] } }, () => {}); -System.create({ observer: true, kernel: false, components: { read: [position] } }, () => {}); +System.create({ observer: true, kernel: true, components: { with: [position] } }, () => {}); +System.create({ observer: true, kernel: false, components: { with: [position] } }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, after: [integrate] }, () => {}); +System.create({ observer: true, after: [integrate] }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, before: [integrate] }, () => {}); +System.create({ observer: true, before: [integrate] }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, during: [integrate] }, () => {}); +System.create({ observer: true, during: [integrate] }, () => {}); // A system on one entity: its input names the entity, and its body runs on that entity's row. type OnOne = { readonly entity: Entity; readonly by: number }; -export const nudge = System.create({ target: "entity", components: { write: [position] } }, (row, _context, input: OnOne) => { +export const nudge = System.create({ target: "entity", components: { with: [position] } }, (row, _context, input: OnOne) => { row[position] += input.by; }); check>>(); // @ts-expect-error its target is an entity field of its input -System.create({ target: "by", components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); +System.create({ target: "by", components: { with: [position] } }, (_row, _context, input: OnOne) => { void input; }); // @ts-expect-error it takes its target from its input, so it takes input -System.create({ target: "entity", components: { write: [position] } }, row => { void row; }); +System.create({ target: "entity", components: { with: [position] } }, row => { void row; }); // @ts-expect-error it runs on a row, so it needs components System.create({ target: "entity" }, (_context, input: OnOne) => { void input; }); // @ts-expect-error not a kernel -System.create({ target: "entity", kernel: true, components: { write: [position] } }, (_row, _context, input: OnOne) => { void input; }); +System.create({ target: "entity", kernel: true, components: { with: [position] } }, (_row, _context, input: OnOne) => { void input; }); diff --git a/packages/ecs/src/interface/system/threads.ts b/packages/ecs/src/interface/system/threads.ts index 7e49f0d7..ae0d7d92 100644 --- a/packages/ecs/src/interface/system/threads.ts +++ b/packages/ecs/src/interface/system/threads.ts @@ -8,20 +8,20 @@ import type { Listed, Property } from './property.js'; import type { KeyOf } from './values.js'; type Lookups = Property; -type LookupReads = { [L in keyof Lookups]: Listed[L], "read"> }[keyof Lookups]; type LookupKeys = { [L in keyof Lookups]: Lookups[L] extends { readonly index: Index } ? KeyOf[number] : never }[keyof Lookups]; type OrderKeys = Property extends Index ? KeyOf[number] : never; type Components = Property; type Resources = Property; -/** The components that place a declaration: those it reads, writes or filters on by change, and the keys it looks up and orders by. */ -type Placing = - | Listed, "read"> | Listed, "write"> | Listed, "added"> | Listed, "changed"> - | LookupReads | LookupKeys | Listed, "read"> | OrderKeys; +/** + * The components that place a declaration, as far as its type shows: those it filters on by change, and the keys it + * looks up and orders by. A member of `with` places it only where its body uses one, which the build infers and + * registration checks, so types can't tell it from one only required. + */ +type Placing = Listed, "added"> | Listed, "changed"> | LookupKeys | OrderKeys; -/** The resources that place a declaration: those it reads, writes, filters on by change, or reads as a condition. */ -type PlacingResources = - Listed, "read"> | Listed, "write"> | Listed, "changed"> | Listed, "is">; +/** The resources that place a declaration, as far as its type shows: those it filters on by change or reads as a condition. */ +type PlacingResources = Listed, "changed"> | Listed, "is">; /** The threads of the object components and resources a declaration uses. */ export type ThreadsOf = ThreadOfComponent & Component.Key> | ThreadOfResource & Resource.Key>; diff --git a/packages/ecs/src/interface/system/threads.type-test.ts b/packages/ecs/src/interface/system/threads.type-test.ts index 0462fc06..d3e3bffc 100644 --- a/packages/ecs/src/interface/system/threads.type-test.ts +++ b/packages/ecs/src/interface/system/threads.type-test.ts @@ -35,73 +35,49 @@ declare module '@adobe/ecs' { } } -// A system runs on the thread of the object components and resources it uses; strings and structs go with any. -System.create({ components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); -System.create({ components: { read: [audio, position] } }, () => {}); -System.create({ components: { write: [label] }, resources: { is: [settings] } }, () => {}); -// Presence-only lists don't place a system. -System.create({ components: { read: [label], has: [node], not: [audio] }, resources: { read: [time] } }, () => {}); -System.create({ components: { read: [node] }, resources: { not: [settings] } }, () => {}); -System.create({ components: { read: [label], write: [node] } }, () => {}); -System.create({ components: { read: [node], changed: [label] }, lookup: { labels: { read: [label] } } }, () => {}); +// A system runs on the thread of the object components and resources it uses; strings and structs go with any. Its +// type places it by what it filters on, conditions and lookup keys; what its body uses places it at registration. +System.create({ components: { with: [position, node] } }, () => {}); +System.create({ components: { with: [audio, position] } }, () => {}); +System.create({ components: { with: [label] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { with: [label, node], not: [audio] } }, () => {}); +System.create({ components: { with: [node] }, resources: { not: [settings] } }, () => {}); +System.create({ components: { with: [node], changed: [label] }, lookup: { labels: { with: [label] } } }, () => {}); +System.create({ components: { with: [tags, node] } }, () => {}); -// @ts-expect-error a system's object components live on one thread -System.create({ components: { read: [node, tags] } }, () => {}); -// @ts-expect-error writes and change filters place it too -System.create({ components: { write: [tags], changed: [audio] } }, () => {}); -// @ts-expect-error a lookup's reads place it too -System.create({ components: { read: [tags] }, lookup: { nodes: { read: [node] } } }, () => {}); +// @ts-expect-error change filters and conditions place it +System.create({ components: { changed: [tags, audio] } }, () => {}); // @ts-expect-error resources and components live on one thread -System.create({ components: { read: [audio] }, resources: { read: [document] } }, () => {}); -// @ts-expect-error a condition it reads places it -System.create({ components: { read: [node] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { changed: [audio] }, resources: { is: [document] } }, () => {}); // Render systems run on gpuDevice's thread, "world". -System.render({ resources: { read: [settings] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ shader: { accessors: { place: position } } }, () => {}); // @ts-expect-error a render system's resources live with gpuDevice -System.render({ resources: { read: [document] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ resources: { is: [document] }, shader: { accessors: { place: position } } }, () => {}); // A composite's members may live on different threads. -const render = System.create({ components: { write: [node] } }, () => {}); -const play = System.create({ components: { write: [audio] } }, () => {}); +const render = System.create({ components: { with: [node] } }, () => {}); +const play = System.create({ components: { with: [audio] } }, () => {}); export const frame = System.create({ systems: [render, play] }); -// Finalizers too run on one thread. -Finalizer.create({ components: { read: [node] }, resources: { read: [document] } }, () => {}); -// @ts-expect-error a finalizer's object components and resources live on one thread -Finalizer.create({ components: { read: [node, audio] } }, () => {}); -Finalizer.create({ components: { read: [node, label] } }, () => {}); - -// A finalizer on another thread than the world's, which a joined thread may fill, runs there after the sync point: it -// issues no commands, writes no resources and looks up nothing. -Finalizer.create({ components: { read: [node, position] }, resources: { read: [document, time] } }, (row, context) => { +// Finalizers too run on one thread, which registration finds from what their bodies use. One on another thread than +// the world's, which a joined thread may fill, runs there after the sync point: it issues no commands, writes no +// resources and looks up nothing. +Finalizer.create({ components: { with: [node] } }, () => {}); +Finalizer.create({ components: { with: [node, label] } }, () => {}); +Finalizer.create({ components: { with: [node, position] } }, (row, context) => { row[node].remove(); void [row[position], context.resources[document], context.resources[time]]; - // @ts-expect-error a finalizer on another thread issues no commands - void context.commands; -}); -Finalizer.create({ components: { read: [audio] } }, (row, context) => { - void row[audio]; - // @ts-expect-error a finalizer on another thread issues no commands - void context.commands; }); -// @ts-expect-error a finalizer on another thread writes no resources -Finalizer.create({ components: { read: [node] }, resources: { write: [time] } }, () => {}); -// @ts-expect-error a finalizer writing another thread's resource is on that thread -Finalizer.create({ components: { read: [position] }, resources: { write: [document] } }, () => {}); -// @ts-expect-error a finalizer on another thread looks up nothing -Finalizer.create({ components: { read: [node] }, lookup: { places: { read: [position] } } }, () => {}); // On the world's thread, structs and its objects alike, it does. -Finalizer.create({ components: { read: [label, position] }, resources: { write: [time] }, lookup: { labels: { read: [label] } } }, (row, { commands, resources, lookup }) => { +Finalizer.create({ components: { with: [label, position] }, lookup: { labels: { with: [label] } } }, (row, { commands, resources, lookup }) => { resources[time] = lookup.labels(row.entity)?.[label].length ?? 0; commands.despawn(row.entity); }); -// A changes finalizer runs on the world's thread: watching another thread's objects, or reading its resources, doesn't compile. -Finalizer.changes({ watch: [label, position], resources: { read: [time] } }, (_, { commands }) => { void commands; }); +// A changes finalizer runs on the world's thread: watching another thread's objects doesn't compile. +Finalizer.changes({ watch: [label, position] }, (_, { commands }) => { void commands; }); // @ts-expect-error a changes finalizer watches no other thread's objects Finalizer.changes({ watch: [node] }, () => {}); -// @ts-expect-error a changes finalizer reads no other thread's resources -Finalizer.changes({ resources: { read: [document] } }, () => {}); // The error names each object component and resource with its thread; data places nothing. -check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); +check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts index 46d0aaf1..d3b811ec 100644 --- a/packages/ecs/src/interface/system/up.type-test.ts +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -20,53 +20,48 @@ declare module '@adobe/ecs' { } } -// `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the -// system writes. +// `up` holds the values of the entity `through` names, or null for a row without one; it may read what the system +// writes. What the body reads of it the build infers. System.create({ kernel: true, - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { - check>(); + if (up !== null) check>(); row[world] = up === null ? row[local] : up[world] + row[local]; // @ts-expect-error up's values are read-only if (up !== null) up[world] = 0; }); // Without `up`, the context has none. -System.create({ components: { read: [local] } }, (row, context) => { +System.create({ components: { with: [local] } }, (row, context) => { void row[local]; // @ts-expect-error no up declared void context.up; }); System.create({ - components: { write: [world] }, + components: { with: [world] }, // @ts-expect-error through is a reference component - up: { through: local, read: [world] }, + up: { through: local }, }, () => {}); -System.create({ - kernel: true, - components: { write: [world] }, - // @ts-expect-error a kernel's up reads linear values - up: { through: parent, read: [name] }, -}, () => {}); + // A GPU kernel reads up too. System.create({ kernel: "gpu", - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = up === null ? row[local] : up[world] + row[local]; }); System.create({ // @ts-expect-error up orders rows, so it needs components - up: { through: parent, read: [world] }, + up: { through: parent }, }, () => {}); // An observer system reads what a sync point left, in no order. // @ts-expect-error an observer system has no up -System.create({ observer: true, components: { read: [local] }, up: { through: parent, read: [local] } }, () => {}); +System.create({ observer: true, components: { with: [local] }, up: { through: parent } }, () => {}); diff --git a/packages/ecs/src/interface/system/values.ts b/packages/ecs/src/interface/system/values.ts index 66f96242..4d63f2b4 100644 --- a/packages/ecs/src/interface/system/values.ts +++ b/packages/ecs/src/interface/system/values.ts @@ -17,8 +17,8 @@ export type Values = /** An index declaration's key. */ export type KeyOf = D extends { readonly key: infer K extends readonly Component.Key[] } ? K : readonly Component.Key[]; -/** The components every entity an index holds has: its key parts and its `has`. */ -export type HeldBy = KeyOf[number] | Listed, "has">; +/** The components every entity an index holds has: its key parts and its `with`. */ +export type HeldBy = KeyOf[number] | Listed, "with">; /** The key's values, in key order. */ type KeyValues = { readonly [I in keyof K]: Component.Value }; @@ -28,8 +28,8 @@ type Prefix = V extends readonly [infer First, ... ? readonly [] | readonly [First | Comparison] | readonly [First, ...Prefix] : readonly []; -/** A row an index lookup gives: its entity and the lookup's `read` values, read-only. */ -type IndexRow = { readonly entity: Entity } & { readonly [K in Listed & Component.Key]: Component.Value }; +/** A row an index lookup gives: its entity and the values of what every entity the index holds has, read-only. */ +type IndexRow = { readonly entity: Entity } & { readonly [K in HeldBy & Component.Key]: Component.Value }; /** * The rows a lookup found, in the index's order: by entity for a hash index, by key and then entity for a sorted one. @@ -48,24 +48,24 @@ type Rows = { }; /** A lookup through an index, by its declaration `D`: by key, a prefix of it for a sorted index. */ -type IndexLookup = +type IndexLookup = D extends { readonly unique: true } ? D extends { readonly sorted: true } - ? ((...key: KeyValues>) => IndexRow | null) & ((...key: Prefix>>) => Rows>) - : (...key: KeyValues>) => IndexRow | null + ? ((...key: KeyValues>) => IndexRow | null) & ((...key: Prefix>>) => Rows>) + : (...key: KeyValues>) => IndexRow | null : D extends { readonly sorted: true } - ? (...key: Prefix>>) => Rows> - : (...key: KeyValues>) => Rows>; + ? (...key: Prefix>>) => Rows> + : (...key: KeyValues>) => Rows>; /** - * The row a lookup by entity gives: the lookup's `read` values, read-only, and with `changed` declared, whether a + * The row a lookup by entity gives: the values of its `with`, read-only, and with `changed` declared, whether a * component it names may have changed in the entity since its system last ran: conservative, as `changed` filters are, * and true on a system's first run and in finalizers. With `changed`, valid until the lookup is called again. */ type EntityRow = { - readonly [K in (Listed & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: K extends Component.Key ? Component.Value : boolean + readonly [K in (Listed & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: K extends Component.Key ? Component.Value : boolean }; export type LookupFunction = L extends { readonly index: Index } - ? IndexLookup + ? IndexLookup : (entity: Entity) => EntityRow | null; diff --git a/packages/ecs/src/interface/world/loop.type-test.ts b/packages/ecs/src/interface/world/loop.type-test.ts index aa9cdaa7..cc75fd05 100644 --- a/packages/ecs/src/interface/world/loop.type-test.ts +++ b/packages/ecs/src/interface/world/loop.type-test.ts @@ -43,23 +43,27 @@ type Removed = "component" | "persistent" | "shared" | "threadPort" | "threadSta check, never>>(); // Systems read the loop's state, ask for a wake time and change its cadence. -System.create({ resources: { read: [clock] } }, ({ resources }) => { +System.create({}, ({ resources }) => { const now: number = resources[clock].now; void now; }); -System.create({ resources: { write: [cadence, always] } }, ({ resources }) => { +System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1000 / 60, maxSteps: 2 }; resources[always] = true; }); -System.create({ resources: { reduce: [wake] } }, ({ reduce }) => { reduce[wake](1000); }); -// @ts-expect-error clock is written only by the world -System.create({ resources: { write: [clock] } }, () => {}); -// @ts-expect-error wake is reduced, never written -System.create({ resources: { write: [wake] } }, () => {}); -// @ts-expect-error a finalizer doesn't write clock either -Finalizer.create({ components: {}, resources: { write: [clock] } }, () => {}); +System.create({}, ({ reduce }) => { reduce[wake](1000); }); +System.create({}, ({ resources }) => { + // @ts-expect-error clock is written only by the world + resources[clock] = resources[clock]; + // @ts-expect-error wake is reduced, never written + resources[wake] = 0; +}); +Finalizer.create({ components: {} }, (_row, { resources }) => { + // @ts-expect-error a finalizer doesn't write clock either + resources[clock] = resources[clock]; +}); // @ts-expect-error a cadence is a frame or a fixed step -System.create({ resources: { write: [cadence] } }, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); +System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); // Worlds start the loop's settings at other values; clock is the world's own. const paced: World = create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true, [workers]: 2 }); diff --git a/packages/ecs/src/observe/entity.ts b/packages/ecs/src/observe/entity.ts index adac7186..952435b3 100644 --- a/packages/ecs/src/observe/entity.ts +++ b/packages/ecs/src/observe/entity.ts @@ -27,12 +27,12 @@ type EntityShape = Shape & { const shapes = new WeakMap>(); /** Indexes of the entities with every component of a list, by its id: one per list in the program. */ -const memberIndexes = new Map>(); +const memberIndexes = new Map>(); const membersOf = (required: readonly Component.Key[]) => { const id = keysId(required); let index = memberIndexes.get(id); - if (index === undefined) memberIndexes.set(id, index = Index.create(labelled({ key: [], components: { has: [...required] } }, `Observe.entity(${labelOf(required)})`))); + if (index === undefined) memberIndexes.set(id, index = Index.create(labelled({ key: [], components: { with: [...required] } }, `Observe.entity(${labelOf(required)})`))); return index; }; @@ -129,14 +129,14 @@ const createShape = (state: WorldState, byShape: Map, id: s let finders: Finders | null = null; let source: object | null = null; const system = optional.length === 0 - ? System.create(labelled({ observer: true, lookup: { presence: { has: required, changed: required }, values: { read: required }, members: { index: membersOf(required), changed: required } } }, name), ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { presence: { with: required, changed: required }, values: { with: required }, members: { index: membersOf(required), changed: required } } }, name, { lookup: { values: required } }), ({ lookup }) => { if (source !== lookup || finders === null) { source = lookup; finders = { presence: lookup.presence, values: lookup.values, optional: noOptional }; } pass(lookup.members().changed, finders); }) - : System.create(labelled({ observer: true, lookup: optionalLookups(required, optional) }, name), ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: optionalLookups(required, optional) }, name, { lookup: optionalReads(required, optional) }), ({ lookup }) => { if (source !== lookup || finders === null) { source = lookup; finders = { presence: lookup.presence, values: lookup.values, optional: (index, entity) => lookup[index](entity) }; @@ -173,22 +173,29 @@ const createShape = (state: WorldState, byShape: Map, id: s const noOptional = (): null => null; -type Watched = { readonly read: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; +type Watched = { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; /** Lookups of an entity's required components, whether and what, and one of each optional one, by its position. */ const optionalLookups = (required: readonly Component.Key[], optional: readonly Component.Key[]): { - readonly presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; - readonly values: { readonly read: readonly Component.Key[] }; + readonly presence: { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; + readonly values: { readonly with: readonly Component.Key[] }; readonly [index: number]: Watched; } => { - const lookups: { presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; values: { readonly read: readonly Component.Key[] }; [index: number]: Watched } = { - presence: { has: required, changed: required }, - values: { read: required }, + const lookups: { presence: { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; values: { readonly with: readonly Component.Key[] }; [index: number]: Watched } = { + presence: { with: required, changed: required }, + values: { with: required }, }; - for (let i = 0; i < optional.length; i++) lookups[i] = { read: [optional[i]], changed: [optional[i]] }; + for (let i = 0; i < optional.length; i++) lookups[i] = { with: [optional[i]], changed: [optional[i]] }; return lookups; }; +/** What the optional shape's body reads of its lookups' rows: the required values, and each optional one. */ +const optionalReads = (required: readonly Component.Key[], optional: readonly Component.Key[]): { readonly [name: string]: readonly Component.Key[] } => { + const reads: { [name: string]: readonly Component.Key[] } = { values: required }; + for (let i = 0; i < optional.length; i++) reads[i] = [optional[i]]; + return reads; +}; + export const observeEntity = (world: World, entity: Entity, components: { readonly required?: readonly Component.Key[]; readonly optional?: readonly Component.Key[] }): Observe => { const required = [...components.required ?? []]; const optional = [...components.optional ?? []]; diff --git a/packages/ecs/src/observe/resource.ts b/packages/ecs/src/observe/resource.ts index 22a1a474..0448ba9c 100644 --- a/packages/ecs/src/observe/resource.ts +++ b/packages/ecs/src/observe/resource.ts @@ -31,14 +31,14 @@ const createShape = (state: WorldState, byKey: Map, const label = ownName(`Observe.resource(${key.description ?? "?"})`); // On a joined thread, first values come once a run of this system resolved, never in its pass: none to skip. const { joined } = state; - const notifier = System.create(labelled({ observer: true, resources: { read: [key], changed: [key] } }, label), ({ resources }) => { + const notifier = System.create(labelled({ observer: true, resources: { changed: [key] } }, label, { resources: [key] }), ({ resources }) => { passed(state); entry.value = resources[key]; entry.valid = true; if (entry.stopped > 0) sweepSubscribers(entry); throwFailed(give(entry, 0, true, state.pass, joined ? -1 : state.pass, none)); }); - const reader = System.create(labelled({ observer: true, resources: { read: [key] } }, label), ({ resources }) => { + const reader = System.create(labelled({ observer: true }, label, { resources: [key] }), ({ resources }) => { const through = shape.called.shift() ?? 0; entry.value = resources[key]; entry.valid = true; diff --git a/packages/ecs/src/observe/select.ts b/packages/ecs/src/observe/select.ts index 1af6a702..1cd1fb00 100644 --- a/packages/ecs/src/observe/select.ts +++ b/packages/ecs/src/observe/select.ts @@ -72,8 +72,8 @@ const shapes = new WeakMap>(); type Rows = { readonly length: number; readonly changed: boolean; at(index: number): { readonly entity: Entity } & Values }; /** Indexes the trackers look up through, by declaration: one each in the program. */ -const keyless = new Map>(); -const sortedIndexes = new Map | null>(); +const keyless = new Map>(); +const sortedIndexes = new Map | null>(); /** * Whether `not` excludes none of `held`, an index's key parts and `has`: what an index declaration's type checks of @@ -93,7 +93,7 @@ const excluding = (not: readonly Component.Key[], held: readonly Component.Key[] const keylessOf = (has: readonly Component.Key[], not: readonly Component.Key[]) => { const id = `${keysId(has)}|${keysId(not)}`; let index = keyless.get(id); - if (index === undefined) keyless.set(id, index = Index.create(labelled({ key: [], components: { has: [...has], not: excluding(not, has) } }, `Observe.query(${labelOf(has)})`))); + if (index === undefined) keyless.set(id, index = Index.create(labelled({ key: [], components: { with: [...has], not: excluding(not, has) } }, `Observe.query(${labelOf(has)})`))); return index; }; @@ -103,7 +103,7 @@ const sortedOf = (key: readonly Component.Key[], has: readonly Component.Key[], let index = sortedIndexes.get(id); if (index === undefined) { try { - index = Index.create(labelled({ key: [...key], sorted: true, components: { has: [...has], not: excluding(not, [...key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf(key)}`)); + index = Index.create(labelled({ key: [...key], sorted: true, components: { with: [...has], not: excluding(not, [...key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf(key)}`)); } catch { index = null; @@ -179,7 +179,7 @@ const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readon let index = rangedIndexes.get(id); if (index === undefined) { try { - index = Index.create(labelled({ key: [key], sorted: true, components: { has: [...has], not: excluding(not, [key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf([key])}`)); + index = Index.create(labelled({ key: [key], sorted: true, components: { with: [...has], not: excluding(not, [key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf([key])}`)); } catch { index = null; @@ -188,7 +188,7 @@ const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readon } return index; }; -const rangedIndexes = new Map | null>(); +const rangedIndexes = new Map | null>(); const createShape = (state: WorldState, byShape: Map, id: string, counting: boolean, impossible: boolean, paged: boolean, required: readonly Component.Key[], not: readonly Component.Key[], where: readonly Component.Key[], terms: readonly Term[]): QueryShape => { const reads = unique([...where, ...terms.map(term => term.key)]); @@ -403,16 +403,16 @@ const createShape = (state: WorldState, byShape: Map, id: st }; const tracker = ordered !== null - ? System.create(labelled({ observer: true, lookup: { list: { index: ordered, read: reads, changed: reads } } }, name), ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: ordered, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { track(lookup.list(), null, fromSorted); }) : byRange !== null - ? System.create(labelled({ observer: true, lookup: { list: { index: byRange, read: reads, changed: reads } } }, name), ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: byRange, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { // A range is of a `where` condition on the index's key, which the query's type checked against its // values. track(null, entry => ownRange(entry, range => lookup.list(range as Parameters[0]), () => lookup.list()), fromSorted); }) - : System.create(labelled({ observer: true, lookup: { list: { index: keylessOf(has, not), read: reads, changed: reads } } }, name), ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: { list: { index: keylessOf(has, not), changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { track(lookup.list(), null, fromKeyless); }); @@ -466,7 +466,7 @@ const createShape = (state: WorldState, byShape: Map, id: st for (const entry of scan.entries) entry.gather.start(); accept = acceptOf(scan.entries); }; - const scanner = System.create(labelled({ observer: true, components: { read: reads, has: required.filter(key => !reads.includes(key)), not } }, name), row => { + const scanner = System.create(labelled({ observer: true, components: { with: [...reads, ...required.filter(key => !reads.includes(key))], not } }, name, { components: reads }), row => { if (accept === null) start(); if (direct === 0) { accept?.(row); diff --git a/packages/ecs/src/observe/world-state.ts b/packages/ecs/src/observe/world-state.ts index 57722ec3..1baea298 100644 --- a/packages/ecs/src/observe/world-state.ts +++ b/packages/ecs/src/observe/world-state.ts @@ -94,11 +94,33 @@ const ownNames = new Set(); /** The registry's map of labels (`@adobe/ecs/labels@1`), which the plugin fills for declarations made in functions. */ const labels = Symbol.for("@adobe/ecs/labels@1"); -/** Labels a declaration of a system or index the factories make, and returns it. */ -export const labelled = (declaration: D, label: string): D => { +/** The registry's map of what bodies use (`@adobe/ecs/access@1`), which the plugin fills from bodies it can follow. */ +const access = Symbol.for("@adobe/ecs/access@1"); + +/** What a factory's body reads: of its row, of resources, and of each lookup's rows. Factories' systems only read. */ +type Reads = { + readonly components?: readonly symbol[]; + readonly resources?: readonly symbol[]; + readonly lookup?: { readonly [name: string]: readonly symbol[] }; +}; + +/** + * Labels a declaration of a system or index the factories make, gives it what its body reads, since the keys it reads + * are values the build can't follow, and returns it. + */ +export const labelled = (declaration: D, label: string, reads: Reads = {}): D => { let map: unknown = Reflect.get(globalThis, labels); if (!(map instanceof WeakMap || map instanceof Map)) Reflect.set(globalThis, labels, map = new WeakMap()); if (map instanceof WeakMap || map instanceof Map) map.set(declaration, label); + let uses: unknown = Reflect.get(globalThis, access); + if (!(uses instanceof WeakMap)) Reflect.set(globalThis, access, uses = new WeakMap()); + if (uses instanceof WeakMap) uses.set(declaration, { + components: { read: reads.components ?? [], write: [] }, + resources: { read: reads.resources ?? [], write: [], reduce: [] }, + lookup: reads.lookup ?? {}, + up: [], + at: label, + }); return declaration; }; @@ -109,7 +131,7 @@ export const ownName = (name: string): string => { }; /** Labels a declaration of a system the factories make, whose failures are theirs to rethrow. */ -export const own = (declaration: D, label: string): D => labelled(declaration, ownName(label)); +export const own = (declaration: D, label: string, reads: Reads = {}): D => labelled(declaration, ownName(label), reads); const ignore = (): void => { // A disposed world runs nothing more: its observers' first values never come. @@ -236,7 +258,7 @@ export const stateOf = (world: World): WorldState => { rejected: rejectedOf(world), // Run with the factories' own systems, never added: what one of them throws is in a later report, whose errors // are written once, which the next of the factories' runs sees. - rethrow: System.create(own({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, "Observe/rethrow"), row => { + rethrow: System.create(own({ observer: true, components: { changed: [Report.errors] } }, "Observe/rethrow", { components: [Report.errors] }), row => { for (const failure of row[Report.errors].failures) if (ownNames.has(failure.name)) rethrowLater(new Error(`${failure.name}: ${failure.message}`)); }), composites: new WeakMap(), diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index db76d24e..dd9b1dbe 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -504,7 +504,7 @@ export const benchmarks: readonly Benchmark[] = [ run: async () => { const { world, roots } = await filteredForest(); const found: { child: Entity | null } = { child: null }; - await world.run(System.create({ observer: true, components: { read: [hierarchyParent] } }, row => { + await world.run(System.create({ observer: true, components: { with: [hierarchyParent] } }, row => { if (found.child === null && row[hierarchyParent] === roots[0]) found.child = row.entity; })); const { child } = found; diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts index 7e12130a..4580d0ec 100644 --- a/packages/ecs/src/perftest/editor.ts +++ b/packages/ecs/src/perftest/editor.ts @@ -72,8 +72,8 @@ const load = System.create({}, ({ commands }) => { /** Each node's world position is its parent's plus its own. */ const place = System.create({ kernel: false, - components: { read: [localX, localY], write: [worldX, worldY], changed: [localX, localY] }, - up: { through: parent, read: [worldX, worldY] }, + components: { with: [localX, localY, worldX, worldY], changed: [localX, localY] }, + up: { through: parent }, }, (row, { up }) => { row[worldX] = (up === null ? 0 : up[worldX]) + row[localX]; row[worldY] = (up === null ? 0 : up[worldY]) + row[localY]; @@ -81,18 +81,18 @@ const place = System.create({ /** Each node's world opacity is its parent's times its own. */ const fade = System.create({ kernel: false, - components: { read: [opacity], write: [worldOpacity], changed: [opacity] }, - up: { through: parent, read: [worldOpacity] }, + components: { with: [opacity, worldOpacity], changed: [opacity] }, + up: { through: parent }, }, (row, { up }) => { row[worldOpacity] = (up === null ? 1 : up[worldOpacity]) * row[opacity]; }); let checksum = 0; /** Each group counts its children, as a layout does. */ -const layout = System.create({ components: { has: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { +const layout = System.create({ components: { with: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { checksum += lookup.children(row.entity).length; }); /** Visits every node in paint order, as drawing does. */ -const paint = System.create({ components: { read: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { +const paint = System.create({ components: { with: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { checksum = (checksum * 31 + row[worldX] * row[worldOpacity]) % 1000003; }); const frame = System.create({ systems: [place, fade, layout, paint] }); diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts index 4a3f0952..6629304a 100644 --- a/packages/ecs/src/perftest/filters.ts +++ b/packages/ecs/src/perftest/filters.ts @@ -41,7 +41,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) /** Writes the value of the rows whose `at` is `first` plus a multiple of `stride`. */ const write = System.create({ kernel: true, - components: { read: [at], write: [value] }, + components: { with: [at, value] }, }, (row, _context, input: { readonly first: I32; readonly stride: I32 }) => { const offset = row[at] - input.first; if (offset >= 0 && offset % input.stride === 0) row[value] += 1; @@ -50,23 +50,23 @@ const write = System.create({ let seen = 0; /** Main-thread systems each filtering on `value`'s changes. */ const readers = System.create({ - systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { read: [value], changed: [value] } }, row => { + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { with: [value], changed: [value] } }, row => { seen += row[value]; })), }); /** The same filtering on `value`'s or `other`'s changes, of which only `value` is written. */ const readersOfTwo = System.create({ - systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { read: [value], changed: [value, other] } }, row => { + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { with: [value], changed: [value, other] } }, row => { seen += row[value]; })), }); /** Kernels each filtering on `value`'s changes, writing an output of its own. */ const kernels = System.create({ systems: [ - System.create({ kernel: true, components: { read: [value], write: [out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), - System.create({ kernel: true, components: { read: [value], write: [out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), - System.create({ kernel: true, components: { read: [value], write: [out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), - System.create({ kernel: true, components: { read: [value], write: [out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), + System.create({ kernel: true, components: { with: [value, out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), + System.create({ kernel: true, components: { with: [value, out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), + System.create({ kernel: true, components: { with: [value, out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), + System.create({ kernel: true, components: { with: [value, out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), ], }); diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index eddc9707..da9e7225 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -43,19 +43,19 @@ export const despawnedObjects = 10_000; /** On the joined thread: what the finalizer released. */ export const releases = { count: 0 }; -export const release = Finalizer.create({ components: { read: [handle, spot] } }, row => { +export const release = Finalizer.create({ components: { with: [handle, spot] } }, row => { row[handle]?.release(); if (row[spot][0] >= 0) releases.count++; }); /** On the joined thread: the same, reading the world's string resource too. */ -export const releaseNoted = Finalizer.create({ components: { read: [noted, spot] }, resources: { read: [note] } }, (row, { resources }) => { +export const releaseNoted = Finalizer.create({ components: { with: [noted, spot] } }, (row, { resources }) => { row[noted]?.release(); if (row[spot][0] >= 0 && resources[note].length > 0) releases.count++; }); /** On the joined thread: the same, reading two linear resources of the world's too. */ -export const releaseMeasured = Finalizer.create({ components: { read: [measured, spot] }, resources: { read: [scale, offset] } }, (row, { resources }) => { +export const releaseMeasured = Finalizer.create({ components: { with: [measured, spot] } }, (row, { resources }) => { row[measured]?.release(); if (row[spot][0] * resources[scale] + resources[offset][0] >= 0) releases.count++; }); @@ -63,7 +63,7 @@ export const releaseMeasured = Finalizer.create({ components: { read: [measured, const spawn = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); -const despawn = System.create({ kernel: false, components: { has: [handle, spot] } }, (row, { commands }) => { +const despawn = System.create({ kernel: false, components: { with: [handle, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); const spawnNoted = System.create({}, ({ commands }) => { @@ -72,10 +72,10 @@ const spawnNoted = System.create({}, ({ commands }) => { const spawnMeasured = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, measured])({ [spot]: [k, 0, 0], [measured]: null }); }); -const despawnMeasured = System.create({ kernel: false, components: { has: [measured, spot] } }, (row, { commands }) => { +const despawnMeasured = System.create({ kernel: false, components: { with: [measured, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const despawnNoted = System.create({ kernel: false, components: { has: [noted, spot] } }, (row, { commands }) => { +const despawnNoted = System.create({ kernel: false, components: { with: [noted, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 65b816a2..454d9e91 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -43,6 +43,6 @@ export const spawnEach = System.create({}, ({ commands }) => { }); /** A kernel over every row of the hundred archetypes. */ -export const bumpEach = System.create({ kernel: true, components: { write: [value] } }, row => { +export const bumpEach = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts index 4c841d5d..ecb20df6 100644 --- a/packages/ecs/src/perftest/handoff.ts +++ b/packages/ecs/src/perftest/handoff.ts @@ -24,28 +24,28 @@ declare module "@adobe/ecs" { /** An object of the joined thread, which places the systems reading it there. */ const here0 = System.create({}, () => {}); -const there0 = System.create({ resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const there0 = System.create({ after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); const here1 = System.create({ after: [there0] }, () => {}); -const there1 = System.create({ resources: { read: [token] }, after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const there1 = System.create({ after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); const here2 = System.create({ after: [there1] }, () => {}); -const there2 = System.create({ resources: { read: [token] }, after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const there2 = System.create({ after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); const here3 = System.create({ after: [there2] }, () => {}); -const there3 = System.create({ resources: { read: [token] }, after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const there3 = System.create({ after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); const here4 = System.create({ after: [there3] }, () => {}); -const there4 = System.create({ resources: { read: [token] }, after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const there4 = System.create({ after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten tiers alternating the world's thread and the joined one, empty systems. */ export const handoff = System.create({ systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); -const fused0 = System.create({ resources: { read: [token] } }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused1 = System.create({ resources: { read: [token] }, after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused2 = System.create({ resources: { read: [token] }, after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused3 = System.create({ resources: { read: [token] }, after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused4 = System.create({ resources: { read: [token] }, after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused5 = System.create({ resources: { read: [token] }, after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused6 = System.create({ resources: { read: [token] }, after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused7 = System.create({ resources: { read: [token] }, after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused8 = System.create({ resources: { read: [token] }, after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused9 = System.create({ resources: { read: [token] }, after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused0 = System.create({ }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused1 = System.create({ after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused2 = System.create({ after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused3 = System.create({ after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused4 = System.create({ after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused5 = System.create({ after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused6 = System.create({ after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused7 = System.create({ after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused8 = System.create({ after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused9 = System.create({ after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten consecutive tiers on the joined thread, empty systems. */ export const fused = System.create({ systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 18be3b5d..611ce5eb 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -26,8 +26,8 @@ const taggedChild = Archetype.create([local, world, parent, leaf]); /** Each row's world value is its parent's plus its own. */ export const propagate = System.create({ kernel: true, - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); @@ -35,8 +35,8 @@ export const propagate = System.create({ /** The same, only for rows whose own or parent's values changed since it last ran. */ export const propagateChanged = System.create({ kernel: true, - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); @@ -53,7 +53,7 @@ export const forest = System.create({ const target = { type: "object", properties: { entity: Entity.schema } } as const; /** Changes one row's own value. */ -export const nudge = System.create({ kernel: false, components: { write: [local] } }, (row, _context, input: { readonly entity: Entity }) => { +export const nudge = System.create({ kernel: false, components: { with: [local] } }, (row, _context, input: { readonly entity: Entity }) => { if (row.entity === input.entity) row[local] += 1; }); @@ -100,8 +100,8 @@ const gpuChild = Archetype.create([offset, placed, parent]); /** `propagate` in 4-byte values, on the GPU. */ export const place = System.create({ kernel: "gpu", - components: { read: [offset], write: [placed] }, - up: { through: parent, read: [placed] }, + components: { with: [offset, placed] }, + up: { through: parent }, }, (row, { up }) => { row[placed] = (up === null ? 0 : up[placed]) + row[offset]; }); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index aacc1caa..b8c05e1a 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -45,9 +45,9 @@ const seedKeys = System.create({}, ({ commands }, input: { readonly count: numbe /** Moves every marked entity to another bucket, and back again the next run. */ let flip = 1; -const rekeyMarked = System.create({ components: { write: [key], has: [marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyMarked = System.create({ components: { with: [key, marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); /** Moves every entity to another bucket. */ -const rekeyAll = System.create({ components: { write: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyAll = System.create({ components: { with: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); let found = 0; /** Uses `byKey`, so a run of it refreshes the index. */ @@ -75,7 +75,7 @@ const threads = navigator.hardwareConcurrency - 1; const pooled = (): World => createWorld({ [workers]: threads }); /** A kernel touching nothing, so a world's pool starts, as it does with its first kernel. */ -const startPool = System.create({ kernel: true, components: { has: [marked] } }, () => {}); +const startPool = System.create({ kernel: true, components: { with: [marked] } }, () => {}); /** Indexes `index idle` keeps, each over its own thousand entities. */ const idleIndexes = 1000; @@ -87,12 +87,12 @@ declare module "@adobe/ecs" { } /** Unique serials among `unique lookup 1M`'s entities. */ -const bySerial = Index.create({ key: [serial], unique: true, components: { has: [amount] } }); +const bySerial = Index.create({ key: [serial], unique: true, components: { with: [amount] } }); const seedSerials = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) commands.spawn([serial, amount])({ [serial]: i, [amount]: 1 }); }); let summed = 0; -const lookUnique = System.create({ lookup: { bySerial: { index: bySerial, read: [amount] } } }, ({ lookup }) => { +const lookUnique = System.create({ lookup: { bySerial: { index: bySerial } } }, ({ lookup }) => { let sum = 0; for (let i = 0; i < million; i++) sum += lookup.bySerial(i)?.[amount] ?? 0; summed = sum; @@ -100,21 +100,21 @@ const lookUnique = System.create({ lookup: { bySerial: { index: bySerial, read: /** Parents in `children lookup`, with ten children each. */ const parents = 10_000; -const childrenOf = Index.create({ key: [parent], components: { has: [amount] } }); +const childrenOf = Index.create({ key: [parent], components: { with: [amount] } }); const seedFamilies = System.create({}, ({ commands }) => { for (let p = 0; p < parents; p++) { const entity = commands.spawn([marked])({ [marked]: true }); for (let c = 0; c < 10; c++) commands.spawn([parent, amount])({ [parent]: entity, [amount]: 1 }); } }); -const sumChildren = System.create({ components: { has: [marked] }, lookup: { children: { index: childrenOf, read: [amount] } } }, (row, { lookup }) => { +const sumChildren = System.create({ components: { with: [marked] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { const children = lookup.children(row.entity); for (let i = 0; i < children.length; i++) summed += children.at(i)[amount]; }); /** Particles in `grid neighbors 100K`, in a 100 by 100 by 10 grid of unit cells, ten a cell. */ const particles = 100_000; -const grid = Index.create({ key: [cell], components: { has: [place] } }); +const grid = Index.create({ key: [cell], components: { with: [place] } }); const seedParticles = System.create({}, ({ commands }) => { for (let i = 0; i < particles; i++) { const x = ((i * 7919) % 1000) / 10; @@ -124,7 +124,7 @@ const seedParticles = System.create({}, ({ commands }) => { } }); /** The same on a kernel: on the pool, each row's 27 probes in straight-line code. */ -const neighborsKernel = System.create({ kernel: true, components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighborsKernel = System.create({ kernel: true, components: { with: [place, cell, amount] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -152,12 +152,12 @@ const seedKernelParticles = System.create({}, ({ commands }) => { /** Particles of `rekey 1M`, drifting through cells of 4 units. */ const drifting = million; -const drift = System.create({ kernel: true, components: { write: [place] } }, row => { +const drift = System.create({ kernel: true, components: { with: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 0.25, y, z]; }); /** The cell a particle is in, written only when it crosses into another, so the index's work is the crossings. */ -const rekeyCells = System.create({ kernel: true, components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { +const rekeyCells = System.create({ kernel: true, components: { with: [place, cell], changed: [place] }, after: [drift] }, row => { const [x, y, z] = row[place]; const [cx, cy, cz] = row[cell]; const nx = Math.floor(x / 4); @@ -165,7 +165,7 @@ const rekeyCells = System.create({ kernel: true, components: { read: [place], wr const nz = Math.floor(z / 4); if (nx !== cx || ny !== cy || nz !== cz) row[cell] = [nx, ny, nz]; }); -const coarse = Index.create({ key: [cell], components: { has: [place] } }); +const coarse = Index.create({ key: [cell], components: { with: [place] } }); /** Uses `coarse` once, so a run refreshes it. */ const lookCoarse = System.create({ lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); const frame = System.create({ systems: [drift, rekeyCells, lookCoarse] }); @@ -181,7 +181,7 @@ const frameUnindexed = System.create({ systems: [drift, rekeyCells] }); /** Rows of `order run 100K`, ranked in a scattered order. */ const orderedRows = 100_000; -const byRank = Index.create({ key: [serial], sorted: true, components: { has: [amount] } }); +const byRank = Index.create({ key: [serial], sorted: true, components: { with: [amount] } }); const seedRanked = System.create({}, ({ commands }) => { for (let i = 0; i < orderedRows; i++) { if (i < orderedRows / 100) commands.spawn([serial, amount, marked])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); @@ -190,19 +190,19 @@ const seedRanked = System.create({}, ({ commands }) => { }); let accumulated = 0; /** Sums its rows' amounts, weighting each by the sum so far, so the order rows run in is part of the result. */ -const inRankOrder = System.create({ components: { read: [amount] }, order: byRank }, row => { +const inRankOrder = System.create({ components: { with: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const inStorageOrder = System.create({ components: { read: [amount] } }, row => { +const inStorageOrder = System.create({ components: { with: [amount] } }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const changedInRankOrder = System.create({ components: { read: [amount], changed: [amount] }, order: byRank }, row => { +const changedInRankOrder = System.create({ components: { with: [amount], changed: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const bumpMarked = System.create({ kernel: false, components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); +const bumpMarked = System.create({ kernel: false, components: { with: [amount, marked] } }, row => { row[amount] += 1; }); let pushed = 0; -const neighbors = System.create({ components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighbors = System.create({ components: { with: [place, cell] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -214,9 +214,9 @@ const neighbors = System.create({ components: { read: [place, cell] }, lookup: { }); /** Ranks of `sorted range 1M`, one an entity, and the ranges it looks up, ten ranks each. */ -const ranked = Index.create({ key: [serial], sorted: true, components: { has: [amount] } }); +const ranked = Index.create({ key: [serial], sorted: true, components: { with: [amount] } }); const ranges = 10_000; -const lookRanges = System.create({ lookup: { ranked: { index: ranked, read: [amount] } } }, ({ lookup }) => { +const lookRanges = System.create({ lookup: { ranked: { index: ranked } } }, ({ lookup }) => { let sum = 0; for (let r = 0; r < ranges; r++) { const start = (r * 7919) % (million - 10); @@ -228,15 +228,15 @@ const lookRanges = System.create({ lookup: { ranked: { index: ranked, read: [amo /** Entities `sorted page 100K` pages through, by a value one of them changes each run, the page the 20 after the first 100 with a small rank. */ const pageRows = 100_000; -const byAmount = Index.create({ key: [amount], sorted: true, components: { has: [key] } }); +const byAmount = Index.create({ key: [amount], sorted: true, components: { with: [key] } }); const seedPage = System.create({}, ({ commands }) => { commands.spawn([amount, key, marked])({ [amount]: 0, [key]: 0, [marked]: true }); for (let i = 1; i < pageRows; i++) commands.spawn([amount, key])({ [amount]: (i * 7919) % pageRows, [key]: i }); }); -const nudgeAmount = System.create({ kernel: false, components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); +const nudgeAmount = System.create({ kernel: false, components: { with: [amount, marked] } }, row => { row[amount] += 1; }); let page: number[] = []; /** The page, recomputed only when what it reads changed: the 20 rows after the first 100 with a key below half, highest amounts first. */ -const readPage = System.create({ lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { +const readPage = System.create({ lookup: { byAmount: { index: byAmount, changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { const all = lookup.byAmount(); if (!all.changed) return; const rows: number[] = []; @@ -258,7 +258,7 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -const untagged = Index.create({ key: [], components: { has: [amount], not: [tagged] } }); +const untagged = Index.create({ key: [], components: { with: [amount], not: [tagged] } }); const seedAmounts = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) { if (i % 100 === 0) commands.spawn([amount, marked])({ [amount]: i, [marked]: true }); @@ -267,7 +267,7 @@ const seedAmounts = System.create({}, ({ commands }) => { }); /** Tags every marked entity, which leaves the index, and untags them the next run, which enter it again. */ let tagging = true; -const toggleMarked = System.create({ components: { has: [marked] } }, (row, { commands }) => { +const toggleMarked = System.create({ components: { with: [marked] } }, (row, { commands }) => { if (tagging) commands.add(row.entity, tagged); else commands.remove(row.entity, tagged); }); @@ -281,7 +281,7 @@ const spawnAndDespawn = System.create({}, ({ commands }) => { spawnedLast = spawned; }); /** Writes every entity's amount, which the index holds by but doesn't watch. */ -const writeAmounts = System.create({ kernel: true, components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); +const writeAmounts = System.create({ kernel: true, components: { with: [amount] } }, row => { row[amount] = row[amount] + 1; }); /** Reads the index's length and whether it changed, as a count kept by a system would. */ const lengthReads = 100_000; const readLengths = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { @@ -339,7 +339,7 @@ const seedFlagged = System.create({}, ({ commands }, input: { readonly count: nu flaggedEntities = []; for (let i = 0; i < input.count; i++) flaggedEntities.push(commands.spawn([flag])({ [flag]: i % 2 === 0 })); }); -const toggleFlag = System.create({ target: "entity", components: { write: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { +const toggleFlag = System.create({ target: "entity", components: { with: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { row[flag] = !row[flag]; }); /** Both buckets' lengths, as a count of done and not done todos. */ @@ -455,7 +455,7 @@ export const indexBenchmarks: readonly Benchmark[] = [ best: { bytes: 8, basis: "Each index reads its journal position, 8 bytes." }, run: async () => { const world = inline(); - const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [], components: { has: [idleKey] } })); + const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [], components: { with: [idleKey] } })); await world.run(System.create(({ commands }) => { for (let i = 0; i < 1000; i++) commands.spawn([idleKey])({ [idleKey]: i }); })); const lookup = Object.fromEntries(indexes.map((index, k) => [`i${k}`, { index }])); const user = System.create({ lookup }, () => {}); diff --git a/packages/ecs/src/perftest/loop.ts b/packages/ecs/src/perftest/loop.ts index 18658a4b..38a6c450 100644 --- a/packages/ecs/src/perftest/loop.ts +++ b/packages/ecs/src/perftest/loop.ts @@ -12,7 +12,7 @@ export const loopTicks = 10_000; export const observerIdles = 100; /** Observer systems no entity matches, each run after every tick. */ -const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ observer: true, components: { has: [unused] } }, () => {})); +const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ observer: true, components: { with: [unused] } }, () => {})); /** * The fastest of `stretches` stretches of `loopTicks` ticks with nothing changed, after one to warm up: a world with diff --git a/packages/ecs/src/perftest/observed-gpu.ts b/packages/ecs/src/perftest/observed-gpu.ts index 57df0640..e479581c 100644 --- a/packages/ecs/src/perftest/observed-gpu.ts +++ b/packages/ecs/src/perftest/observed-gpu.ts @@ -25,7 +25,7 @@ const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < heated; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: 0, [rate]: 1 })); }); -const warm = System.create({ kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate]; }); diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 77f98931..3390349c 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -34,13 +34,13 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Writes the marked entity's value, so one entity observer is notified and a selection or count is a candidate. */ -const nudge = System.create({ kernel: false, components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { with: [value, marked] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank. */ -const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { with: [rank] } }, row => { row[rank] += 1; }); /** A system no entity matches: a run that changes nothing. */ -const idle = System.create({ kernel: false, components: { has: [unmatched] } }, () => {}); +const idle = System.create({ kernel: false, components: { with: [unmatched] } }, () => {}); const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); diff --git a/packages/ecs/src/perftest/observer.ts b/packages/ecs/src/perftest/observer.ts index fa5ba5da..55f5657b 100644 --- a/packages/ecs/src/perftest/observer.ts +++ b/packages/ecs/src/perftest/observer.ts @@ -21,7 +21,7 @@ export const eachTick = (world: World, notify: () => void): (() => void) => { /** The clock as an observer run last read it. */ const read = { tick: 0, now: 0 }; -const readClock = System.create({ observer: true, resources: { read: [clock] } }, ({ resources }) => { +const readClock = System.create({ observer: true }, ({ resources }) => { read.tick = resources[clock].tick; read.now = resources[clock].now; }); @@ -36,7 +36,7 @@ export const ticksAfter = async (world: World, count: number): Promise => let { tick, now } = read; let seen = 0; await new Promise(resolve => { - const counter = System.create({ observer: true, resources: { read: [clock] } }, ({ resources }) => { + const counter = System.create({ observer: true }, ({ resources }) => { const current = resources[clock]; if (current.tick === tick && current.now === now) return; tick = current.tick; @@ -52,7 +52,7 @@ export const ticksAfter = async (world: World, count: number): Promise => /** The entities with every component of `has` and none of `not`, in storage order, read by an observer run. */ export const entitiesWith = async (world: World, has: readonly Component.Key[], not: readonly Component.Key[] = []): Promise => { const found: Entity[] = []; - await world.run(System.create({ observer: true, components: { has, not } }, row => { found.push(row.entity); })); + await world.run(System.create({ observer: true, components: { with: [...has], not } }, row => { found.push(row.entity); })); return found; }; diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 412f7afc..996e66c9 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -47,7 +47,7 @@ export const spawn = System.create({}, ({ commands }, input: { readonly count: n export const move = System.create({ kernel: true, - components: { read: [velocity], write: [position], has: [visible, enabled] }, + components: { with: [velocity, position, visible, enabled] }, }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index dc1f969d..bc331d06 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -32,17 +32,17 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Writes the marked entity's value. */ -const nudge = System.create({ kernel: false, components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { with: [value, marked] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ -const advance = System.create({ kernel: true, components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { with: [rank] } }, row => { row[rank] += 1; }); /** The entities `look` and `lookChanged` look up, each run. */ let looked: readonly Entity[] = []; let found = 0; /** Looks up every entity of `looked` by entity, reading its value, after `nudge` where both run. */ -const look = System.create({ lookup: { values: { read: [value] } }, after: [nudge] }, ({ lookup }) => { +const look = System.create({ lookup: { values: { with: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0) found++; @@ -50,7 +50,7 @@ const look = System.create({ lookup: { values: { read: [value] } }, after: [nudg }); /** The same, with `changed`: counts the entities whose value may have changed since it last ran. */ -const lookChanged = System.create({ lookup: { values: { read: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { +const lookChanged = System.create({ lookup: { values: { with: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0 && row.changed) found++; @@ -78,7 +78,7 @@ const lookups = async (system: System): Promise<{ readonly best: number; readonl /** Every entity a seeded world holds. */ const entitiesOf = async (world: World): Promise => { const found: Entity[] = []; - await world.run(System.create({ observer: true, components: { has: [value] } }, row => { found.push(row.entity); })); + await world.run(System.create({ observer: true, components: { with: [value] } }, row => { found.push(row.entity); })); return found; }; @@ -108,7 +108,7 @@ export const readBenchmarks: readonly Benchmark[] = [ run: async () => { const world = await seeded(10_000); copied = 0; - const best = await fastest(() => world.run(System.create({ resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({ }, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run: wrong value"); await world.dispose(); return best; @@ -124,7 +124,7 @@ export const readBenchmarks: readonly Benchmark[] = [ run: async () => { const world = await seeded(10_000); copied = 0; - const best = await fastest(() => world.run(System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({ observer: true }, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run observer: wrong value"); await world.dispose(); return best; @@ -143,7 +143,7 @@ export const readBenchmarks: readonly Benchmark[] = [ await world.run(advance); copied = 0; const best = await fastest(async () => { - await world.run(System.create({ components: { read: [rank], changed: [rank] } }, row => { copied += row[rank]; })); + await world.run(System.create({ components: { with: [rank], changed: [rank] } }, row => { copied += row[rank]; })); }, 300); check(copied > 0, "read run changed: nothing read"); await world.dispose(); diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index 1001eaa5..c3546da7 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -34,12 +34,12 @@ declare module "@adobe/ecs" { /** An empty transaction. */ export const empty = System.create({}, () => {}); /** Changes the observed resource every tick. */ -export const count = System.create({ resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); +export const count = System.create({ }, ({ resources }) => { resources[counter] += 1; }); export const seedRanks = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); -const flip = System.create({ resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); -const reverse = System.create({ kernel: true, components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { +const flip = System.create({ }, ({ resources }) => { resources[sign] = -resources[sign]; }); +const reverse = System.create({ kernel: true, components: { with: [rank, order] }, after: [flip] }, (row, { resources }) => { row[order] = row[rank] * resources[sign]; }); /** Reverses the entities' order every tick. */ @@ -49,19 +49,19 @@ export const reorder = System.create([flip, reverse]); export const observerSeen = { last: 0, notified: 0 }; /** On a joined thread, after each sync point that changed it: the observed resource. */ -export const observerCounter = System.create({ observer: true, resources: { read: [counter], changed: [counter] } }, ({ resources }) => { +export const observerCounter = System.create({ observer: true, resources: { changed: [counter] } }, ({ resources }) => { observerSeen.last = resources[counter]; observerSeen.notified++; }); /** Every entity with a rank, in entity order. */ -const ranked = Index.create({ key: [], components: { has: [rank, order] } }); +const ranked = Index.create({ key: [], components: { with: [rank, order] } }); const sortedKeys = { keys: new Float64Array(0), entities: new Float64Array(0), positions: new Uint32Array(0) }; /** * On a joined thread, after each sync point: the entities in `order`, sorted from a scan of the index of every ranked * entity into reused arrays. */ -export const observerSelect = System.create({ observer: true, lookup: { all: { index: ranked, read: [order] } } }, ({ lookup }) => { +export const observerSelect = System.create({ observer: true, lookup: { all: { index: ranked } } }, ({ lookup }) => { const list = lookup.all(); const count = list.length; if (sortedKeys.keys.length < count) { @@ -87,10 +87,10 @@ export const observerSelect = System.create({ observer: true, lookup: { all: { i export const seedCaptions = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, caption, amount])({ [rank]: i, [caption]: `Buy milk and bread #${i}`, [amount]: i }); }); -const recaption = System.create({ components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const recaption = System.create({ components: { with: [rank, caption] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[caption] = `Buy milk and bread #${row[rank]}.${resources[counter]}`; }); -const reamount = System.create({ kernel: false, components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const reamount = System.create({ kernel: false, components: { with: [rank, amount] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[amount] = row[rank] + resources[counter]; }); /** Rewrites 1% of the captions a tick. */ @@ -99,12 +99,12 @@ export const retext = System.create([count, recaption]); export const reamounted = System.create([count, reamount]); /** On a joined thread, after each sync point: every row's caption, out of its record. */ -export const observerCaptions = System.create({ observer: true, components: { read: [caption] } }, row => { +export const observerCaptions = System.create({ observer: true, components: { with: [caption] } }, row => { observerSeen.last += row[caption].length; observerSeen.notified++; }); /** On a joined thread, after each sync point: every row's amount. */ -export const observerAmounts = System.create({ observer: true, components: { read: [amount] } }, row => { +export const observerAmounts = System.create({ observer: true, components: { with: [amount] } }, row => { observerSeen.last += row[amount]; observerSeen.notified++; }); @@ -129,7 +129,7 @@ const joinedFile = "./joined.js"; /** The observed resource, read by an observer run. */ const counterOf = async (world: World): Promise => { let value = 0; - await world.run(System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { value = resources[counter]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { value = resources[counter]; })); return value; }; diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts index c6f14f2c..ed303419 100644 --- a/packages/ecs/src/perftest/render-order.ts +++ b/packages/ecs/src/perftest/render-order.ts @@ -34,7 +34,7 @@ const spawn = System.create({}, ({ commands }) => { }); /** Moves one item to a new depth, as reordering it does. */ const restack = System.create({ - components: { write: [paintDepth] }, + components: { with: [paintDepth] }, target: "item", }, (row, _context, input: { readonly item: Entity; readonly depth: number }) => { row[paintDepth] = input.depth; diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts index f3b80b90..4ae47b69 100644 --- a/packages/ecs/src/perftest/strings.ts +++ b/packages/ecs/src/perftest/strings.ts @@ -30,10 +30,10 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) let total = 0; let round = 0; -const readText = System.create({ components: { read: [text] } }, row => { total += row[text].length; }); -const readBoxed = System.create({ components: { read: [boxed] } }, row => { total += row[boxed].text.length; }); -const writeText = System.create({ components: { write: [text] } }, row => { row[text] = labelOf(row.entity, round); }); -const writeBoxed = System.create({ components: { write: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); +const readText = System.create({ components: { with: [text] } }, row => { total += row[text].length; }); +const readBoxed = System.create({ components: { with: [boxed] } }, row => { total += row[boxed].text.length; }); +const writeText = System.create({ components: { with: [text] } }, row => { row[text] = labelOf(row.entity, round); }); +const writeBoxed = System.create({ components: { with: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); /** A seeded world, with a joined thread on this one when `shared`, which keeps records of its strings. */ const seeded = async (shared = false): Promise<{ readonly world: World; readonly dispose: () => Promise }> => { @@ -76,7 +76,7 @@ const read = (system: System, name: string) => measure(system, async world => { /** Each row's text, as the last write left it. */ const written = (name: string) => async (world: World): Promise => { let wrong = 0; - await world.run(System.create({ observer: true, components: { read: [text, boxed] } }, row => { + await world.run(System.create({ observer: true, components: { with: [text, boxed] } }, row => { if (row[text] !== row[boxed].text && row[text] !== labelOf(row.entity, round) && row[boxed].text !== labelOf(row.entity, round)) wrong++; })); check(wrong === 0, `${name}: wrong text`); diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index 704127ba..271fb30f 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -81,22 +81,22 @@ export const spawnSixteen = System.create({}, ({ commands }, input: { readonly c }); /** A spawn per row, from a kernel. */ -export const spawnKernel = System.create({ kernel: true, components: { read: [index], not: [spawned] } }, (row, { commands }) => { +export const spawnKernel = System.create({ kernel: true, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.spawn(made)({ [index]: row[index], [value]: 0 }); }); /** Despawns every entity a spawn benchmark made. */ -export const despawnSpawned = System.create({ kernel: false, components: { has: [spawned] } }, (row, { commands }) => { +export const despawnSpawned = System.create({ kernel: false, components: { with: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Despawns every seeded entity. */ -export const despawnAll = System.create({ kernel: false, components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnAll = System.create({ kernel: false, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** The same, from a kernel. */ -export const despawnKernel = System.create({ kernel: true, components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnKernel = System.create({ kernel: true, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); @@ -113,39 +113,39 @@ export const despawnOne = System.create({}, ({ commands }) => { if (entity !== undefined) commands.despawn(entity); }); -export const addFlag = System.create({ kernel: false, components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlag = System.create({ kernel: false, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlag = System.create({ kernel: false, components: { has: [flag] } }, (row, { commands }) => { +export const removeFlag = System.create({ kernel: false, components: { with: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addFlagKernel = System.create({ kernel: true, components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlagKernel = System.create({ kernel: true, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlagKernel = System.create({ kernel: true, components: { has: [flag] } }, (row, { commands }) => { +export const removeFlagKernel = System.create({ kernel: true, components: { with: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addAmount = System.create({ kernel: false, components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { +export const addAmount = System.create({ kernel: false, components: { with: [index], not: [amount, spawned] } }, (row, { commands }) => { commands.add(row.entity, amount, row[index]); }); -export const removeAmount = System.create({ kernel: false, components: { has: [amount] } }, (row, { commands }) => { +export const removeAmount = System.create({ kernel: false, components: { with: [amount] } }, (row, { commands }) => { commands.remove(row.entity, amount); }); /** Adds a value every row already has: no archetype moves, only values replaced and marked changed. */ -export const replaceAmount = System.create({ kernel: false, components: { read: [amount] } }, (row, { commands }) => { +export const replaceAmount = System.create({ kernel: false, components: { with: [amount] } }, (row, { commands }) => { commands.add(row.entity, amount, row[amount] + 1); }); -export const addSeveral = System.create({ kernel: false, components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addSeveral = System.create({ kernel: false, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, several, { [amount]: row[index], [extra]: 1 }); }); -export const removeSeveral = System.create({ kernel: false, components: { has: [flag, amount, extra] } }, (row, { commands }) => { +export const removeSeveral = System.create({ kernel: false, components: { with: [flag, amount, extra] } }, (row, { commands }) => { commands.remove(row.entity, several); }); diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 47964584..b31bc5e4 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -55,14 +55,14 @@ export const seed = System.create({}, ({ commands }, input: { readonly count: nu }); /** Despawns a tenth of the entities and spawns as many replacements. */ -export const churn = System.create({ components: { read: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { +export const churn = System.create({ components: { with: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { if (row[index] % 10 !== input.group) return; commands.despawn(row.entity); commands.spawn(moving)({ [position]: row[position], [velocity]: [0, 0, 0], [index]: row[index] }); }); /** Adds the tag to one tenth and removes it from another: a fifth of the entities move archetype. */ -export const retag = System.create({ components: { read: [index] } }, (row, { commands }, input: { readonly group: number }) => { +export const retag = System.create({ components: { with: [index] } }, (row, { commands }, input: { readonly group: number }) => { const group = row[index] % 10; if (group === input.group) commands.add(row.entity, tag); else if (group === (input.group + 5) % 10) commands.remove(row.entity, tag); @@ -71,7 +71,7 @@ export const retag = System.create({ components: { read: [index] } }, (row, { co /** Writes 1% of positions. */ export const poke = System.create({ kernel: true, - components: { read: [index], write: [position] }, + components: { with: [index, position] }, }, (row, _context, input: { readonly group: I32 }) => { if (row[index] % 100 === input.group) { const [x, y, z] = row[position]; @@ -82,7 +82,7 @@ export const poke = System.create({ /** Writes 1% of positions, clustered in one contiguous run of rows. */ export const pokeCluster = System.create({ kernel: true, - components: { read: [index], write: [position] }, + components: { with: [index, position] }, }, (row, _context, input: { readonly group: I32 }) => { const group = row[index] - input.group * 10_000; if (group >= 0 && group < 10_000) { @@ -94,7 +94,7 @@ export const pokeCluster = System.create({ /** Sees only what changed since it last ran. */ export const observe = System.create({ kernel: true, - components: { read: [position], write: [velocity], changed: [position] }, + components: { with: [position, velocity], changed: [position] }, }, row => { const [x, y, z] = row[position]; row[velocity] = [x * 0.5, y * 0.5, z * 0.5]; @@ -103,8 +103,8 @@ export const observe = System.create({ /** Steers toward the target: one lookup per row. */ export const follow = System.create({ kernel: true, - components: { read: [position, target], write: [velocity] }, - lookup: { targets: { read: [position] } }, + components: { with: [position, target, velocity] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { @@ -117,7 +117,7 @@ export const follow = System.create({ /** Issues commands from a kernel: a tenth of the rows record a hit. */ export const emit = System.create({ kernel: true, - components: { read: [index, position] }, + components: { with: [index, position] }, }, (row, { commands }) => { if (row[index] % 10 === 0) { const [x] = row[position]; @@ -126,27 +126,27 @@ export const emit = System.create({ }); /** Six dependent kernels: one tier each, so dispatch cost shows. */ -export const chain1 = System.create({ kernel: true, components: { write: [velocity] } }, row => { +export const chain1 = System.create({ kernel: true, components: { with: [velocity] } }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain2 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain1] }, row => { +export const chain2 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain1] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain3 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain2] }, row => { +export const chain3 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain2] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain4 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain3] }, row => { +export const chain4 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain3] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain5 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain4] }, row => { +export const chain5 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain4] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain6 = System.create({ kernel: true, components: { write: [velocity] }, after: [chain5] }, row => { +export const chain6 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain5] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); @@ -155,7 +155,7 @@ export const chain = System.create({ systems: [chain1, chain2, chain3, chain4, c /** Fifty small main-thread systems in one tier, each reading every row. */ let total = 0; export const smalls = System.create({ - systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { read: [position] } }, row => { + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { with: [position] } }, row => { total += row[position][0]; })), }); @@ -187,21 +187,21 @@ export const seeded = async (create: () => World, count: number): Promise }; /** Folds every row's index into a sum and a maximum. */ -export const reduce = System.create({ kernel: true, components: { read: [index] }, resources: { reduce: [indexSum, highest] } }, (row, { reduce }) => { +export const reduce = System.create({ kernel: true, components: { with: [index] } }, (row, { reduce }) => { reduce[indexSum](row[index]); reduce[highest](row[index]); }); /** A system no entity matches: what a run costs with nothing to do. */ -export const idle = System.create({ components: { has: [unused] } }, () => {}); +export const idle = System.create({ components: { with: [unused] } }, () => {}); /** A system that throws, so each run of it has a report to spawn. */ export const failing = System.create({}, () => { throw new Error("suite: failing"); }); /** An observer system no entity matches: a run of it alone has no tiers. */ -export const idleObserver = System.create({ observer: true, components: { has: [unused] } }, () => {}); +export const idleObserver = System.create({ observer: true, components: { with: [unused] } }, () => {}); /** Fifty systems no entity matches, in one tier. */ export const idles = System.create({ - systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { has: [unused] } }, () => {})), + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { with: [unused] } }, () => {})), }); /** Spawns one entity: a run with a sync point that applies one command. */ @@ -210,6 +210,6 @@ export const spawnOne = System.create({}, ({ commands }) => { }); /** One main-thread system reading every row. */ -export const small = System.create({ components: { read: [position] } }, row => { +export const small = System.create({ components: { with: [position] } }, row => { total += row[position][0]; }); diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts index 9418c271..e92d100f 100644 --- a/packages/ecs/src/perftest/systems.ts +++ b/packages/ecs/src/perftest/systems.ts @@ -26,10 +26,10 @@ const seed = System.create({}, ({ commands }) => { let read = 0; /** A new system for each tick: a change filter, which counts as watching `level`, and rows to read. */ -const fresh = (): System => System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); +const fresh = (): System => System.create({ components: { with: [level], changed: [level] } }, row => { read += row[level]; }); /** One module's system, added and removed again and again. */ -const again = System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); +const again = System.create({ components: { with: [level], changed: [level] } }, row => { read += row[level]; }); /** Ticks in each timed stretch. */ export const churnTicks = 10_000; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index 8da40ebb..f045cba7 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -60,24 +60,24 @@ const spawnPoses = System.create({}, ({ commands }) => { const here = System.create({}, () => {}); /** On the joined thread, terminal: reads every row's place, as a DOM-writing system reads positions. */ -const draw = System.create({ components: { read: [place] }, resources: { read: [surface] } }, (row, { resources }) => { +const draw = System.create({ components: { with: [place] } }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0]; }); /** A tick of a world system and a 1,000-row tail that finishes before the next tick needs it. */ export const promptTick = System.create({ systems: [here, draw] }); /** On the joined thread, ahead of the late tail: busy for a while, as a main thread is with layout. */ -const busy = System.create({ resources: { read: [surface] } }, ({ resources }) => { +const busy = System.create({ }, ({ resources }) => { const until = performance.now() + 4; while (performance.now() < until && resources[surface] === surfaceStart); }); /** On the joined thread, terminal, after `busy`: reads every row's place and pose, and the resource `poke` writes. */ -const copied = System.create({ components: { read: [place, pose] }, resources: { read: [surface, poked] }, after: [busy] }, (row, { resources }) => { +const copied = System.create({ components: { with: [place, pose] }, after: [busy] }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0] + row[pose][15] + resources[poked]; }); export const lateTick = System.create({ systems: [busy, copied] }); /** On the world's thread: writes what the late tail reads, so it copies the rest of the tail's reads first. */ -const poke = System.create({ resources: { write: [poked] } }, ({ resources }) => { +const poke = System.create({ }, ({ resources }) => { resources[poked] += 1; }); const nothing = System.create({}, () => {}); @@ -89,23 +89,23 @@ export const overlapWorldMilliseconds = 2; export const overlapTicks = 100; /** On the world's thread: writes every row's place, which the last tick's tail still reads. */ -const overlapWrite = System.create({ kernel: false, components: { write: [place] } }, row => { +const overlapWrite = System.create({ kernel: false, components: { with: [place] } }, row => { row[place] = [row[place][0], 1, 0]; }); /** On the world's thread, after the writer: the rest of the tick's work, as systems reading what it wrote do. */ -const overlapSimulate = System.create({ resources: { read: [worldWork] }, after: [overlapWrite] }, ({ resources }) => { +const overlapSimulate = System.create({ after: [overlapWrite] }, ({ resources }) => { const until = performance.now() + resources[worldWork]; while (performance.now() < until); }); /** On the joined thread, terminal: works on every row's place and pose, as a DOM-writing system on main does. */ -const overlapDraw = System.create({ components: { read: [place, pose] }, resources: { read: [surface, rowWork] }, after: [overlapWrite] }, (row, { resources }) => { +const overlapDraw = System.create({ components: { with: [place, pose] }, after: [overlapWrite] }, (row, { resources }) => { if (resources[surface] !== surfaceStart) return; let sum = row[place][0] + row[pose][15]; for (let k = resources[rowWork]; k > 0; k--) sum = Math.sqrt(sum + k); drawn.sum += sum; }); export const overlapTick = System.create({ systems: [overlapWrite, overlapSimulate, overlapDraw] }); -const setOverlap = System.create({ resources: { write: [rowWork, worldWork] } }, ({ resources }) => { +const setOverlap = System.create({ }, ({ resources }) => { resources[rowWork] = overlapWork; resources[worldWork] = overlapWorldMilliseconds; }); diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts index 33332fda..d69a533c 100644 --- a/packages/ecs/src/perftest/transfers.ts +++ b/packages/ecs/src/perftest/transfers.ts @@ -22,9 +22,7 @@ declare module "@adobe/ecs" { export const taken = { bytes: 0 }; /** On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. */ -export const take = System.create({ - resources: { read: [sink] }, -}, ({ resources }, input: { readonly buffer: ArrayBuffer; readonly bitmap: ImageBitmap }) => { +export const take = System.create({}, ({ resources }, input: { readonly buffer: ArrayBuffer; readonly bitmap: ImageBitmap }) => { if (resources[sink] !== null) return; taken.bytes += input.buffer.byteLength + input.bitmap.width * input.bitmap.height * 4; input.bitmap.close(); diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 9a880cc6..f3b3f26a 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -23,7 +23,7 @@ const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < shapes; i++) spawned.push(commands.spawn([place])({ [place]: [i, 0, 0] })); }); const drag = System.create({ - components: { write: [place] }, + components: { with: [place] }, target: "shape", }, (row, _context, input: { readonly shape: Entity; readonly x: number }) => { row[place] = [input.x, 1, 0]; @@ -31,7 +31,7 @@ const drag = System.create({ /** Draws nothing: its run uploads what it reads. */ const draw = System.render({ shader: { accessors: { place } }, after: [drag] }, () => {}); const frame = System.create({ systems: [drag, draw] }); -const slide = System.create({ kernel: true, components: { write: [place] } }, row => { +const slide = System.create({ kernel: true, components: { with: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 1, y, z]; }); diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 10789aa9..8bb6b7b1 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -37,10 +37,10 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) }); /** Shows each row's pose: a struct read and written whole per row. */ -const showPose = System.create({ kernel: false, components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); +const showPose = System.create({ kernel: false, components: { with: [pose, shown] } }, row => { row[shown] = row[pose]; }); /** Commits a pose and an extent per row as commands, values every row already has. */ -const commitPose = System.create({ kernel: true, components: { read: [order] } }, (row, { commands }) => { +const commitPose = System.create({ kernel: true, components: { with: [order] } }, (row, { commands }) => { commands.add(row.entity, pose, { tx: row[order], ty: 1, rotation: 0, sx: 1, sy: 1 }); commands.add(row.entity, extent, [2, 2]); }); diff --git a/packages/ecs/src/samples/particles/gpu/main.ts b/packages/ecs/src/samples/particles/gpu/main.ts index ecfe091f..b7e0b4cd 100644 --- a/packages/ecs/src/samples/particles/gpu/main.ts +++ b/packages/ecs/src/samples/particles/gpu/main.ts @@ -63,7 +63,7 @@ const start = async (): Promise => { await world.run(spawn, { count, width: canvas.width, height: canvas.height }); /** For automated checks: a few drawn particles' positions, which an observer system reads back from the GPU. */ const probed: (readonly number[])[] = []; - const probe = System.create({ observer: true, components: { read: [position], has: [visible, enabled] } }, row => { + const probe = System.create({ observer: true, components: { with: [position, visible, enabled] } }, row => { if (probed.length < 3) probed.push(row[position]); }); Reflect.set(globalThis, "sampleProbe", async () => { diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index e89f0e0f..5e8e0be5 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -56,7 +56,7 @@ export const spawn = System.create({ export const move = System.create({ kernel: "gpu", - components: { read: [velocity], write: [position], has: [visible, enabled] }, + components: { with: [velocity, position, visible, enabled] }, }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); @@ -64,7 +64,7 @@ export const move = System.create({ /** The same move on the CPU, for comparison. */ export const moveOnCpu = System.create({ kernel: true, - components: { read: [velocity], write: [position], has: [visible, enabled] }, + components: { with: [velocity, position, visible, enabled] }, }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); @@ -87,8 +87,7 @@ struct Point { @builtin(position) position: vec4f, @location(0) color: vec4f } `; const declaration = { - name: "gpuParticles/draw", - components: { has: [visible, enabled] }, + components: { with: [visible, enabled] }, shader: { group: 0, accessors: { particlePosition: position, particleColor: color } }, after: [move, moveOnCpu], } as const; diff --git a/packages/ecs/src/samples/particles/particles.test.ts b/packages/ecs/src/samples/particles/particles.test.ts index e8e18499..5e5eba37 100644 --- a/packages/ecs/src/samples/particles/particles.test.ts +++ b/packages/ecs/src/samples/particles/particles.test.ts @@ -23,7 +23,7 @@ describe("particles", () => { let positions: readonly (readonly number[])[] = []; for (const seconds of frames) positions = await frame(world, seconds); let spawned = 0; - await world.run(System.create({ observer: true, resources: { read: [count] } }, ({ resources }) => { spawned = resources[count]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { spawned = resources[count]; })); expect(spawned).toBe(100); const expected = reference(frames); expect(positions).toHaveLength(expected.length); diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index 337b1cfe..233c4cde 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -19,7 +19,7 @@ declare module '@adobe/ecs' { // Main thread: resource-only, runs when `count` changes, including the first run. export const spawn = System.create({ - resources: { read: [count], changed: [count] }, + resources: { changed: [count] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[count]; i++) { commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [1, i, 0] }); @@ -28,16 +28,14 @@ export const spawn = System.create({ // Kernels: linear components and read-only resources, so the build compiles them to run in chunks across workers. export const gravity = System.create({ - components: { write: [velocity] }, - resources: { read: [delta] }, + components: { with: [velocity] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; row[velocity] = [x, y - 9.8 * resources[delta], z]; }); export const integrate = System.create({ - components: { read: [velocity], write: [position] }, - resources: { read: [delta] }, + components: { with: [velocity, position] }, after: [gravity], }, (row, { resources }) => { const [px, py, pz] = row[position]; @@ -47,9 +45,7 @@ export const integrate = System.create({ }); // Transaction carrying frame time into the ECS. -export const setDelta = System.create({ - resources: { write: [delta] }, -}, ({ resources }, input: { readonly seconds: number }) => { +export const setDelta = System.create({}, ({ resources }, input: { readonly seconds: number }) => { resources[delta] = input.seconds; }); @@ -59,7 +55,7 @@ export const main = System.create({ systems: [spawn, gravity, integrate] }); const positions: Vec3[] = []; const read = System.create({ observer: true, - components: { read: [position] }, + components: { with: [position] }, }, row => { positions.push(row[position]); }); diff --git a/packages/ecs/src/samples/todo/app/app.browser.test.ts b/packages/ecs/src/samples/todo/app/app.browser.test.ts index 3c7fff53..5d58a73d 100644 --- a/packages/ecs/src/samples/todo/app/app.browser.test.ts +++ b/packages/ecs/src/samples/todo/app/app.browser.test.ts @@ -182,10 +182,10 @@ describe("the todo app", () => { }); // It runs in the tick that gives the todo its row, alongside createRows. -const failing = System.create({ components: { has: [todo] } }, () => { +const failing = System.create({ components: { with: [todo] } }, () => { throw new Error("a tick's system failed"); }); -const failingObserver = System.create({ observer: true, components: { has: [row], changed: [row] } }, () => { +const failingObserver = System.create({ observer: true, components: { with: [row], changed: [row] } }, () => { throw new Error("a tick's observer failed"); }); const pass = System.create({ observer: true }, () => {}); diff --git a/packages/ecs/src/samples/todo/app/log-failures.ts b/packages/ecs/src/samples/todo/app/log-failures.ts index ef72c911..8713409a 100644 --- a/packages/ecs/src/samples/todo/app/log-failures.ts +++ b/packages/ecs/src/samples/todo/app/log-failures.ts @@ -2,7 +2,7 @@ import { Report, System } from "../../../index.js"; /** Logs what reports say failed, a run's an event started and a tick's alike: each report's errors are written once. */ -export const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +export const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { const { failures } = row[Report.errors]; if (failures.length > 0) console.error("A run failed", failures); }); diff --git a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts index b5f5c351..b80a8f04 100644 --- a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts +++ b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts @@ -88,7 +88,7 @@ const nothing = System.create({}, () => {}); /** The failures of every report the apps measured spawned, which none should. */ const failed: Report.Failure[] = []; -const collectFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const collectFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { failed.push(...row[Report.errors].failures); }); @@ -104,7 +104,7 @@ const forceLayout = (): void => { }; /** Marks every other todo done, for filters to hide half the rows. */ -const halfDone = System.create({ components: { write: [done] } }, item => { +const halfDone = System.create({ components: { with: [done] } }, item => { item[done] = item.entity % 2 === 0; }); @@ -168,7 +168,7 @@ const benchmarkApp = async (n: number): Promise => { /** The filter not kept: an attribute of the list, which a style sheet selects rows by. */ const markList = System.create({ observer: true, - resources: { read: [filter], changed: [filter], is: [list] }, + resources: { changed: [filter], is: [list] }, }, ({ resources }) => { resources[list].dataset.filter = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/completion/data.ts b/packages/ecs/src/samples/todo/features/completion/data.ts index 782249a7..3e41e3f1 100644 --- a/packages/ecs/src/samples/todo/features/completion/data.ts +++ b/packages/ecs/src/samples/todo/features/completion/data.ts @@ -3,4 +3,4 @@ import { Index } from "../../../../index.js"; import { done, todo } from "../todos/data.js"; /** Todos by whether they're done: its lists' lengths are the counts, kept as todos change. */ -export const byDone = Index.create({ key: [done], components: { has: [todo] } }); +export const byDone = Index.create({ key: [done], components: { with: [todo] } }); diff --git a/packages/ecs/src/samples/todo/features/completion/model.test.ts b/packages/ecs/src/samples/todo/features/completion/model.test.ts index dd9a89cf..23754fcf 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.test.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.test.ts @@ -8,7 +8,7 @@ import { clearCompleted, toggleAll } from "./model.js"; let writes = 0; /** Counts the todos written since it last ran. */ -const countWrites = System.create({ observer: true, components: { has: [done], changed: [done] } }, () => { +const countWrites = System.create({ observer: true, components: { with: [done], changed: [done] } }, () => { writes += 1; }); diff --git a/packages/ecs/src/samples/todo/features/completion/model.ts b/packages/ecs/src/samples/todo/features/completion/model.ts index 21cc3bd0..5b1ce169 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.ts @@ -5,7 +5,7 @@ import { byDone } from "./data.js"; /** Marks every todo done, or every one not done. Only todos that change are written. */ export const toggleAll = System.create({ - components: { write: [done], has: [todo] }, + components: { with: [done, todo] }, }, (row, _context, input: { readonly done: boolean }) => { if (row[done] !== input.done) row[done] = input.done; }); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.ts b/packages/ecs/src/samples/todo/features/filter/dom.ts index f2c2b6aa..345d1b33 100644 --- a/packages/ecs/src/samples/todo/features/filter/dom.ts +++ b/packages/ecs/src/samples/todo/features/filter/dom.ts @@ -39,8 +39,8 @@ const focusNearest = (hiding: Element, chosen: Filter): void => { */ export const filterRows = System.create({ observer: true, - components: { read: [done, row], changed: [done, row] }, - resources: { read: [filter], changed: [filter] }, + components: { with: [done, row], changed: [done, row] }, + resources: { changed: [filter] }, }, (item, { resources }) => { const element = item[row]; const hidden = leavesOut(resources[filter], item[done]); @@ -51,7 +51,7 @@ export const filterRows = System.create({ export const bindFilters = System.create({ observer: true, - resources: { read: [filter], changed: [filter], is: [filters] }, + resources: { changed: [filter], is: [filters] }, }, ({ resources }) => { resources[filters].filter = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.test.ts b/packages/ecs/src/samples/todo/features/filter/model.test.ts index 4c58130b..d1f82a76 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.test.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.test.ts @@ -5,7 +5,7 @@ import { filter, type Filter } from "./data.js"; import { setFilter } from "./model.js"; let seen: Filter | null = null; -const readFilter = System.create({ observer: true, resources: { read: [filter] } }, ({ resources }) => { +const readFilter = System.create({ observer: true }, ({ resources }) => { seen = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.ts b/packages/ecs/src/samples/todo/features/filter/model.ts index c058d5f6..009bf7da 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.ts @@ -2,8 +2,6 @@ import { System } from "../../../../index.js"; import { filter, type Filter } from "./data.js"; -export const setFilter = System.create({ - resources: { write: [filter] }, -}, ({ resources }, input: { readonly filter: Filter }) => { +export const setFilter = System.create({}, ({ resources }, input: { readonly filter: Filter }) => { resources[filter] = input.filter; }); diff --git a/packages/ecs/src/samples/todo/features/generate/model.ts b/packages/ecs/src/samples/todo/features/generate/model.ts index a80ac564..3739eed5 100644 --- a/packages/ecs/src/samples/todo/features/generate/model.ts +++ b/packages/ecs/src/samples/todo/features/generate/model.ts @@ -9,7 +9,7 @@ export const generatedLabel = (n: number): string => `${tasks[n % tasks.length]} /** Adds `count` todos with generated labels at the end of the list. */ export const addGenerated = System.create({ - lookup: { ordered: { index: ordered, read: [order] } }, + lookup: { ordered: { index: ordered } }, }, ({ lookup, commands }, input: { readonly count: number }) => { const all = lookup.ordered(); const first = orderAfter(all); diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts index 93a8bd56..1349d2cf 100644 --- a/packages/ecs/src/samples/todo/features/todos/data.ts +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -20,7 +20,7 @@ declare module "@adobe/ecs" { export const Todo = Archetype.create([todo, label, done, order]); /** Every todo, in list order. */ -export const ordered = Index.create({ key: [order], sorted: true, components: { has: [todo] } }); +export const ordered = Index.create({ key: [order], sorted: true, components: { with: [todo] } }); /** The order just after the last of `ordered`'s todos, as a lookup through it gives them: 0 for none. */ export const orderAfter = (todos: { readonly length: number; readonly at: (index: number) => { readonly [order]: number } }): number => diff --git a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts index df84ce12..00c8432a 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts @@ -29,7 +29,7 @@ const createDriven = async (): Promise<{ readonly world: World; readonly element const rowsOf = (element: TodoList): TodoRow[] => [...element.querySelectorAll("todo-row")]; let rows = 0; -const countRows = System.create({ observer: true, components: { has: [row] } }, () => { +const countRows = System.create({ observer: true, components: { with: [row] } }, () => { rows += 1; }); diff --git a/packages/ecs/src/samples/todo/features/todos/dom.ts b/packages/ecs/src/samples/todo/features/todos/dom.ts index b21f2ff9..bf28bac3 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.ts @@ -20,14 +20,14 @@ declare module "@adobe/ecs" { } /** Todos with rows, in list order. */ -const placed = Index.create({ key: [order], sorted: true, components: { has: [todo, row] } }); +const placed = Index.create({ key: [order], sorted: true, components: { with: [todo, row] } }); /** * Gives each new todo a row, attached to it for events from the row, while the page has a list for it. It isn't placed * until the row is the todo's, so a todo gone first never shows one. */ export const createRows = System.create({ - components: { has: [todo], not: [row] }, + components: { with: [todo], not: [row] }, resources: { is: [list] }, }, (item, { commands }) => { commands.add(item.entity, row, attach(document.createElement("todo-row"), item.entity)); @@ -36,7 +36,7 @@ export const createRows = System.create({ /** Writes what changed of each todo to its row. */ export const bindRows = System.create({ observer: true, - components: { read: [label, done, row], changed: [label, done, row] }, + components: { with: [label, done, row], changed: [label, done, row] }, }, item => { const element = item[row]; element.label = item[label]; @@ -65,10 +65,10 @@ const place = (parent: Element, element: HTMLElement, before: Element | null): v */ export const placeRows = System.create({ observer: true, - components: { read: [row, order], changed: [row, order] }, + components: { with: [row, order], changed: [row, order] }, order: placed, resources: { is: [list] }, - lookup: { placed: { index: placed, read: [row] } }, + lookup: { placed: { index: placed } }, }, (item, { resources, lookup }) => { const element = item[row]; const parent = resources[list]; @@ -77,7 +77,7 @@ export const placeRows = System.create({ if (before !== element) place(parent, element, before); }); -Finalizer.create({ components: { read: [row] } }, item => { +Finalizer.create({ components: { with: [row] } }, item => { item[row].remove(); }); diff --git a/packages/ecs/src/samples/todo/features/todos/model.ts b/packages/ecs/src/samples/todo/features/todos/model.ts index 559a292b..c5d01579 100644 --- a/packages/ecs/src/samples/todo/features/todos/model.ts +++ b/packages/ecs/src/samples/todo/features/todos/model.ts @@ -4,7 +4,7 @@ import { Todo, done, label, order, orderAfter, ordered, todo } from "./data.js"; /** Adds a todo at the end of the list, with its label trimmed; a blank label adds nothing. */ export const addTodo = System.create({ - lookup: { ordered: { index: ordered, read: [order] } }, + lookup: { ordered: { index: ordered } }, }, ({ lookup, commands }, input: { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") return; @@ -16,7 +16,7 @@ type One = { readonly entity: Entity }; export const toggleTodo = System.create({ target: "entity", - components: { write: [done], has: [todo] }, + components: { with: [done, todo] }, }, (row, _context, _input: One) => { row[done] = !row[done]; }); @@ -24,7 +24,7 @@ export const toggleTodo = System.create({ /** Gives a todo a new label, trimmed; a blank one deletes it. */ export const editTodo = System.create({ target: "entity", - components: { write: [label], has: [todo] }, + components: { with: [label, todo] }, }, (row, { commands }, input: One & { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") commands.despawn(row.entity); @@ -33,7 +33,7 @@ export const editTodo = System.create({ export const deleteTodo = System.create({ target: "entity", - components: { has: [todo] }, + components: { with: [todo] }, }, (row, { commands }, _input: One) => { commands.despawn(row.entity); }); @@ -43,7 +43,7 @@ export const deleteTodo = System.create({ * neighbours'. When no number lies between them, every todo is numbered again. */ export const moveTodo = System.create({ - lookup: { todos: { read: [order], has: [todo] }, ordered: { index: ordered, read: [order] } }, + lookup: { todos: { with: [order, todo] }, ordered: { index: ordered } }, }, ({ lookup, commands }, input: { readonly entity: Entity; readonly before: Entity }) => { const { entity, before } = input; if (entity === before || lookup.todos(entity) === null) return; diff --git a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts index 0d5ed7fe..a0125a46 100644 --- a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts +++ b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts @@ -7,7 +7,7 @@ import { addTodo } from "./model.js"; export type Listed = { readonly entity: Entity; readonly label: string; readonly done: boolean; readonly order: number }; const listed: Listed[] = []; -const readTodos = System.create({ observer: true, components: { read: [label, done, order] }, order: ordered }, row => { +const readTodos = System.create({ observer: true, components: { with: [label, done, order] }, order: ordered }, row => { listed.push({ entity: row.entity, label: row[label], done: row[done], order: row[order] }); }); diff --git a/packages/ecs/src/samples/transfers/main.ts b/packages/ecs/src/samples/transfers/main.ts index 722ca888..0c10e5bc 100644 --- a/packages/ecs/src/samples/transfers/main.ts +++ b/packages/ecs/src/samples/transfers/main.ts @@ -15,7 +15,7 @@ const overlay = (text: string): void => { let latest = { sprites: 0, frames: 0 }; // Added on this thread, it runs here after each of the world's frames that changed stats. -const show = System.create({ observer: true, resources: { read: [stats], changed: [stats] } }, ({ resources }) => { +const show = System.create({ observer: true, resources: { changed: [stats] } }, ({ resources }) => { const { sprites, frames } = resources[stats]; latest = { sprites, frames }; overlay(`${sprites} sprites, each a texture the loader decoded and moved to the world's worker · frame ${frames}`); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index 77602588..5282e6c2 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -28,7 +28,7 @@ declare module '@adobe/ecs' { /** The canvas main moved to the world's thread. */ /** From main: the canvas the world draws into. */ -export const attachSurface = System.create({ resources: { write: [surface] } }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { +export const attachSurface = System.create({ }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { resources[surface] = input.canvas; }); @@ -39,9 +39,7 @@ const random = (seed: number): number => { }; /** From the loader: a sprite with the texture it decoded, somewhere on the canvas, moving. */ -export const addSprite = System.create({ - resources: { read: [surface], write: [stats] }, -}, ({ resources, commands }, input: { readonly bitmap: ImageBitmap; readonly seed: number }) => { +export const addSprite = System.create({}, ({ resources, commands }, input: { readonly bitmap: ImageBitmap; readonly seed: number }) => { const canvas = resources[surface]; if (canvas === null) return; const angle = random(input.seed) * Math.PI * 2; @@ -55,7 +53,7 @@ export const addSprite = System.create({ }); /** Clears the canvas, once a frame. */ -const clear = System.create({ resources: { read: [surface], write: [stats] } }, ({ resources }) => { +const clear = System.create({ }, ({ resources }) => { const canvas = resources[surface]; canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); resources[stats] = { sprites: resources[stats].sprites, frames: resources[stats].frames + 1 }; @@ -63,7 +61,7 @@ const clear = System.create({ resources: { read: [surface], write: [stats] } }, /** Moves each sprite, bouncing off the canvas's edges. */ const move = System.create({ - components: { read: [texture], write: [position, velocity] }, resources: { read: [surface, clock] }, + components: { with: [texture, position, velocity] }, }, (row, { resources }) => { const canvas = resources[surface]; if (canvas === null) return; @@ -79,7 +77,7 @@ const move = System.create({ }); /** Draws each sprite's texture where it is. */ -const draw = System.create({ components: { read: [texture, position] }, resources: { read: [surface] }, after: [clear, move] }, (row, { resources }) => { +const draw = System.create({ components: { with: [texture, position] }, after: [clear, move] }, (row, { resources }) => { const [x, y] = row[position]; resources[surface]?.getContext("2d")?.drawImage(row[texture], x, y); }); diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index 4bd45dbe..5fa4b2da 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -14,8 +14,8 @@ declare module "@adobe/ecs" { } const spawn = System.create({}, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); -const raise = System.create({ resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); -const add = System.create({ components: { write: [count] } }, row => { row[count] += 1; }); +const raise = System.create({ }, ({ resources }) => { resources[total] = 7; }); +const add = System.create({ components: { with: [count] } }, row => { row[count] += 1; }); describe("World.create", () => { it("creates a world from the package root, the one import that names everything else", async () => { @@ -23,7 +23,7 @@ describe("World.create", () => { await world.run(spawn); await world.run(add); let seen = 0; - await world.run(System.create({ observer: true, components: { read: [count] } }, row => { seen = row[count]; })); + await world.run(System.create({ observer: true, components: { with: [count] } }, row => { seen = row[count]; })); expect(seen).toBe(2); }); it("rejects a key that isn't a resource, as worlds the implementation creates do", () => { @@ -39,7 +39,7 @@ describe("World.join", () => { await using joined = World.join(channel.port2); await joined.run(raise); let seen = 0; - await world.run(System.create({ observer: true, resources: { read: [total] } }, ({ resources }) => { seen = resources[total]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { seen = resources[total]; })); expect(seen).toBe(7); }); }); From 55d25f644631a94220cdfda7e629f15e507d82ac Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:40:04 -0700 Subject: [PATCH 080/127] ecs: memory records that types and build errors, not editor affordances, guide consumers Co-Authored-By: Claude Opus 5.5 --- packages/ecs/memory.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ecs/memory.md b/packages/ecs/memory.md index 35a2484d..78e6a12a 100644 --- a/packages/ecs/memory.md +++ b/packages/ecs/memory.md @@ -9,6 +9,8 @@ decisions about ecs here rather than in a personal memory, so every machine and and nothing about how it's done, so the implementation stays free to change and optimize. Prefer what the build can derive over what consumers write; never expose a mechanism consumers could call instead of declaring intent, such as registration. +- **Agents write most consumer code; type errors guide them, editor affordances don't.** Spend effort on precise types + and build errors, not on completions, editor plugins or other tooling only an IDE shows. - **Humans own the interface, agents own the implementation** (`src/interface/CLAUDE.md`). Agents add or widen nothing public: no exported types, built-in components or resources, fields, options, union members or observable behaviour. Any change that can't be avoided is proposed for review before it lands. From f844050dd2c3d893dae7a7f0bf20725b8062ace6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:40:09 -0700 Subject: [PATCH 081/127] ecs: docs say declarations hold what bodies can't show, and the build infers the rest Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 29 +++++---- packages/ecs/followups.md | 10 --- packages/ecs/src/guide/03-systems.test.ts | 5 +- packages/ecs/src/guide/04-ticks.test.ts | 4 +- .../ecs/src/guide/07-change-filters.test.ts | 4 +- packages/ecs/src/guide/guide.md | 63 ++++++++++++------- packages/ecs/threads.md | 8 +-- 7 files changed, 66 insertions(+), 57 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index e5b685e4..98528565 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -37,13 +37,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. ## Execution - **Systems are designed to run on workers.** That is a primary constraint, not an add-on. -- **A system's declared read/write sets drive both ordering and parallelism.** +- **A system's read/write sets, which the build infers from its body, drive both ordering and parallelism.** - **Systems that touch main-thread-only components are pinned to the main thread.** - **Structural changes become visible only at sync points,** whether they come from the main thread or from workers. - **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. - **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. + - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written (as read in an observer), and where it can't follow a lookup's rows, all it requires is taken as read, each with a build warning; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. @@ -94,7 +94,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts export const follow = System.create({ - components: { read: [position, target], write: [velocity] }, + components: { with: [position, target, velocity] }, lookup: { targets: { read: [position] } }, after: [integrate], // imported from the physics module }, (row, { lookup }) => { @@ -109,17 +109,16 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. export const main = System.create({ systems: [input, physics, follow, render] }); ``` - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their label, `name`, alone. Nothing runs when they're called. - - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup` appears in the context under the same name, and `resources.reduce` as `reduce`. - - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`. + - **A declaration says only what its body can't show:** which entities it runs over, its filters and conditions, its lookups' membership, its order. `lookup` appears in the context under the same name; every resource is in `resources`, writable but reduced and world-written ones, and every reduced resource in `reduce`. + - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources. - `added`/`changed` pass when **any** of the listed components was added or changed. - - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. + - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module value but primitive constants, functions and the interface's own values (an object or array holding data, however it's made, may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles. - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. - - Type-checked where required: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. + - Checked by the build: what a kernel's body reads and writes of its rows and lookups must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and it writes no resources. What it only requires (`with`, `not`, `added`, `changed`) can name any component. - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. - **The pool starts once rows warrant it:** while no archetype a kernel matches holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. A kernel registering looks at the archetypes it matches; one running here over that many rows starts the pool after its run's sync point, so nothing scans archetypes per run. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. @@ -131,8 +130,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - `(system name, input)` is a replicable transaction. On a GPU, input maps to a uniform buffer. - A `Blob` input can only be stored or forwarded, because its contents can't be read synchronously. - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. - - `row`: `{ readonly entity }`, plus `read` values (read-only) and `write` values (readable and writable). No component may be named `entity`. - - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `up`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. + - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in an observer or render system. No component may be named `entity`. + - `context` holds each kind under its own key: `resources` (every resource, assignable but reduced and world-written ones), `lookup` (functions by name, when declared), `reduce` (contribution functions by reduced resource) and `up` (with `up` declared). It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. @@ -148,7 +147,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`during` places a system alongside another:** after what that system follows and before what follows it, both by its own constraints and by others', outside a composite's own members. No-op systems ordered one after another are phases, and systems during different phases are ordered with each other. Where no ordering path separates them, the two share a tier. - **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them. - **A lookup can't read a component its own system writes;** that throws at registration. `up` can, since it reads a parent only after the parent ran. - - **`up: { through, read }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what `read` lists of the row's parent, or null without one. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. + - **`up: { through }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what the body reads of the row's parent, or null without one or for a parent lacking one of them. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. - `through` doesn't narrow the match: rows without it are roots. References change only through commands, so the tree changes only at sync points. - A row's depth counts its ancestors among the matched rows: a row whose parent doesn't match is a root of this system's order. - With change filters, a row also passes when its parent reference, or what it reads of its parent, changed since the system last ran, decided on ticks, so a parent another system wrote counts too. Those components keep ticks and kernels keep their stamps. A row that moved to another archetype since then passes too, since removing its parent reference moves it, as do the children of a parent that moved to another archetype or started or stopped matching. @@ -156,7 +155,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Order:** rows run depth by depth, and within a depth in groups of one archetype whose parents share one archetype, each in storage order. Groups of a depth never read each other's writes, so values don't depend on the grouping; commands and reductions follow it, deterministically. The order, each row's parent row and the children of each parent change only at sync points, since only those move rows or change references. - **Kernels read `up` straight from the parent's columns:** a call covers one group, so it binds the parent archetype's columns once, and each row reads its parent's leaf at the parent's row. A leaf's stride and offset depend only on its component's layout, so they're the same in every archetype. `up === null` holds for the whole call. - **Filtered runs push changes down:** rows are marked from chunk and block ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. - - **`kernel: "gpu"` systems may declare `up`;** what `read` lists must be made of 4-byte scalars, as the rest of a GPU kernel's values are. + - **`kernel: "gpu"` systems may declare `up`;** what the body reads of the parent must be made of 4-byte scalars, as the rest of a GPU kernel's values are. - **In a main-thread body, `context.up` is one reused object,** valid only while its row runs, as `row` is. - **World** (`src/interface/world/`): - `World.create(resources?)` returns a world synchronously, on the creating thread, with resources starting at the given values in place of their defaults; a key that isn't a resource, or one only the world writes, throws; string keys are ignored, as in commands. Built-in resources are symbols too, so initial values import them: `World.create({ [workers]: 0 })`. @@ -201,7 +200,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Cascades** are component traits, expanded in Plan with the rules. - **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped. - **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null. -- **Finalizers are the only removal mechanism.** `Finalizer.create({ components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. +- **Finalizers are the only removal mechanism.** `Finalizer.create({ components: { with, not }, lookup, before, after }, (row, { resources, lookup, commands }) => …)`, what its body reads and writes inferred as a system's is. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct and string reads and its resources copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. - **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched. - **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know. @@ -226,8 +225,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Contributions have a known shape:** a tuple or struct component contributed whole needs its leaves known, as whole-value writes do. - **Indexes** (`src/interface/indexes/`; its type tests pin the behavior) find rows by the values of stored components, for systems and finalizers. - - **Declared once, program-wide:** `Index.create({ key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. - - **Lookups:** `lookup: { near: { index: grid, read: [position], changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. + - **Declared once, program-wide:** `Index.create({ key: [cell], components: { with, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. + - **Lookups:** `lookup: { near: { index: grid, changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. - **`changed: true`** on a system's index lookup makes the index a change filter, as a resource in `changed` is: every row passes when, since the system last ran, an entity entered or left the index or moved to another key or position in it, as its refreshes count them; a rebuild counts as a change, and a key written the same, a move to another archetype, or a change to what the index doesn't key on doesn't. With several, any one changing passes. A system whose only change filters these are, with no `up`, is skipped before its body when none changed, as when a condition fails, keeping its `lastRun`, at one comparison per such lookup, against a tick each index keeps of its last refresh that changed an entry; with other change filters its rows pass by them otherwise. Conditions still decide first. Its lists' `changed` then reports entries alone. Observer systems and CPU kernels take it, the world deciding before it calls them; finalizers, which run per entity, don't, and a system on a joined thread, or an observer placed on or added on one, is rejected, the decision needing the world's index. Systems without it pay one boolean test per run. - **`order: index`** runs a system's rows that a sorted index holds in its order, with change filters the rows that pass; commands and reductions follow it. Not with `up`, and not on GPU kernels. - **Scheduling and currency:** a system using an index, through a lookup or `order`, reads its key parts, for ordering, conflicts and placement, and can't write them or what it reads through the index. It sees key values written by systems ordered before it in the run, and entities as of the last sync point; a finalizer sees the index as of before its sync point applies, as its lookups see rows. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a5547d8e..92df0324 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -78,16 +78,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept. -## API optimizations - -The plugin is required, so it can derive what consumers now restate. Each is a contract change, reviewed one at a -time; inferred values are emitted as the same literals the runtime takes today, so none costs anything at runtime. - -1. **Access inferred from bodies, saved for discussion with Kris.** Recommended: option B, membership explicit as - `components: { with: [...], not: [...] }` and access modes inferred from the body, so rows stay typed over `with` - and matching never changes behind the author's back; `write` stays as an override for bodies the plugin can't - follow. Option A infers the lists entirely, typing rows over every component. - ## Findings from the todo sample (`src/samples/todo/README.md`) 2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index a0a2bf81..1a306f54 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -25,7 +25,8 @@ const spawn = System.create(({ commands }) => { commands.spawn([position, velocity, frozen])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); }); -// Runs once per entity with every component in read and write, and none in not. +// Runs once per entity with every component in with, and none in not. What its body reads and writes the build +// infers. const move = System.create({ components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { @@ -34,7 +35,7 @@ const move = System.create({ }); // Without components, a system runs once per run. -const count = System.create({ }, ({ resources }) => { +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index a783455c..286b2832 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -13,7 +13,7 @@ declare module "@adobe/ecs" { } // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ }, ({ resources }) => { +const advance = System.create({}, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); @@ -50,7 +50,7 @@ it("runs added systems on every tick", async () => { // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ }, ({ resources, reduce }) => { +const step = System.create({}, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts index e5f951aa..1af9c016 100644 --- a/packages/ecs/src/guide/07-change-filters.test.ts +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -60,8 +60,8 @@ it("runs systems only for what changed since they last ran", async () => { const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => { resources[steps] += resources[map].width; }); -const pause = System.create({ }, ({ resources }) => { resources[paused] = true; }); -const open = System.create({ }, ({ resources }) => { resources[map] = { width: 4 }; }); +const pause = System.create({}, ({ resources }) => { resources[paused] = true; }); +const open = System.create({}, ({ resources }) => { resources[map] = { width: 4 }; }); let stepped = 0; const readSteps = System.create({ observer: true }, ({ resources }) => { diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 0985368a..1d50ae6b 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -35,8 +35,8 @@ How to use the ECS. Each excerpt comes from a test in this folder; the link unde ## Setup ```ts -// vite.config.ts: compiles kernels, checks each system's declaration against its body, reads the schema of each -// body's input from its type, and gives keys and systems ids. +// vite.config.ts: infers what each system's body reads and writes, compiles kernels, reads the schema of each body's +// input from its type, and gives keys and systems ids. import { Compiler } from "@adobe/ecs/compiler"; export default defineConfig({ plugins: [Compiler.plugin()] }); ``` @@ -50,6 +50,10 @@ chunks, so the plugin sets Vite's `worker.format` to `"es"` when the config does bundles, its own included, which then load as module workers (`{ type: "module" }`). The examples create worlds with `World.create()`. +A system declares only what its body can't show: the entities it runs over (`with` and `not`), its change filters +and conditions, and its order. What its body reads, writes and reduces into the plugin infers from the body, so +write the declaration and body where the system is made, and index rows and resources by key. + ## Basics Resources, values a world holds once, and entities, each holding a set of components, are the whole data model. @@ -84,18 +88,29 @@ declare module "@adobe/ecs" { } // Systems read a world: an observer only reads, after the world's changes apply. -let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, floor, names, audio, focused] } }, ({ resources }) => { +let values: readonly [ + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value +] | null = null; +const read = System.create({ observer: true }, ({ resources }) => { values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; }); -await using moon = World.create({ [gravity]: -1.6 }); -// run: runs a system once. -await moon.run(read); -expect(values[0]).toBe(-1.6); +it("holds one value per world", async () => { + // A world starts each resource at its default, or at the value given when it's created. + await using earth = World.create(); + await using moon = World.create({ [gravity]: -1.6 }); + // run: runs a system once. + await earth.run(read); + expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]); + await moon.run(read); + expect(values?.[0]).toBe(-1.6); ``` -[`01-resources.test.ts`](01-resources.test.ts#L6-L45) +[`01-resources.test.ts`](01-resources.test.ts#L7-L52) ### 2. Entities, components and archetypes @@ -151,7 +166,8 @@ it("spawns entities with components", async () => { ### 3. Systems ```ts -// Runs once per entity with every component in read and write, and none in not. +// Runs once per entity with every component in with, and none in not. What its body reads and writes the build +// infers. const move = System.create({ components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { @@ -160,7 +176,7 @@ const move = System.create({ }); // Without components, a system runs once per run. -const count = System.create({ }, ({ resources }) => { +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); @@ -183,7 +199,7 @@ it("runs a system once per entity, or once", async () => { expect(moved).toEqual([[1, 0, 0]]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L28-L57) +[`03-systems.test.ts`](03-systems.test.ts#L28-L58) ```ts const fail = System.create(() => { @@ -206,13 +222,13 @@ it("reports what failed as entities", async () => { expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L61-L78) +[`03-systems.test.ts`](03-systems.test.ts#L62-L79) ### 4. Ticks and cadence ```ts // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ }, ({ resources }) => { +const advance = System.create({}, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); @@ -247,7 +263,7 @@ it("runs added systems on every tick", async () => { ```ts // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ }, ({ resources, reduce }) => { +const step = System.create({}, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); @@ -273,11 +289,11 @@ it("ticks again when a system asks", async () => { ### 5. Scheduling and composites ```ts -const hit = System.create({ components: { read: [damage], write: [health] } }, row => { +const hit = System.create({ components: { with: [damage, health] } }, row => { row[health] -= row[damage]; }); // before, after and during list the systems themselves. -const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { +const heal = System.create({ components: { with: [health] }, after: [hit] }, row => { row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order @@ -395,6 +411,9 @@ it("runs a system with a value", async () => { ```ts declare module "@adobe/ecs" { interface Components { + [mesh]: { type: number }; + [sprite]: { type: number }; + [hidden]: { type: true }; // Only rules add derived components, starting at their default. [drawn]: { type: true; derived: true }; [layer]: { type: number; derived: true; default: 1 }; @@ -402,11 +421,11 @@ declare module "@adobe/ecs" { } // Entities matching a rule have what it derives, only while they match. -Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. -Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] }); // Rules build on what others derive. -Rule.create({ components: { has: [drawn] }, derive: [layer] }); +Rule.create({ components: { with: [drawn] }, derive: [layer] }); ``` [`09-rules.test.ts`](09-rules.test.ts#L11-L27) @@ -571,8 +590,8 @@ const countPlayers = System.create({ observer: true, lookup: { players: { index: [`13-indexes.test.ts`](13-indexes.test.ts#L52-L58) -A list stays valid until its call site runs again. Lists and rows read only what the index holds and its lookup's -`read` names, and `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A +A list stays valid until its call site runs again. Rows hold what every entity in the index has: its key and its +`with`. `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A lookup's `changed: true` runs its system only when that happened anywhere in the index. ### 14. Kernels diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index cb942708..95ee2b91 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -100,10 +100,10 @@ of the browser or node, and a joined thread's `dispose` closes only a `MessagePo All derived from declarations and, for terminal systems, the plugin's analysis of their bodies; none is a flag. -- **A system's thread** is the thread of every object component and object resource it names in `read`, `write`, - `added`, `changed`, `lookup`, `up` and resource `is`, and the key parts of the indexes it looks up through or orders - its rows by. Strings and structs don't place it. Components in `has` and `not` are structural, and read a shared - presence word, so they don't place it. With no objects, a kernel runs on the pool and any other system on `world`. +- **A system's thread** is the thread of every object component and object resource its body reads or writes, as + the build infers, of those it filters on by `added` and `changed`, of resource `is`, and the key parts of the + indexes it looks up through or orders its rows by. Strings and structs don't place it. Components it only requires, + in `with`, and those in `not` are structural, and read a shared presence word, so they don't place it. With no objects, a kernel runs on the pool and any other system on `world`. Render systems and `kernel: "gpu"` systems are on `world`, with `gpuDevice`. - **Errors when declared:** objects of two threads in one system or finalizer, in types and when it's created, which registers it. **Errors when joining:** a thread claimed twice; a claimed thread whose systems or finalizers that From 8c2da795deb76b9c757a43d5f6647fc91ad227be Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:40:09 -0700 Subject: [PATCH 082/127] ecs: an unfollowed lookup reads all it requires; tests give unseen bodies their access, and place finalizers by use Co-Authored-By: Claude Opus 5.5 --- .../implementation/world/conditions.test.ts | 6 +++--- .../src/implementation/world/create.test.ts | 2 +- .../world/entity-lookups.test-helper.ts | 19 +++++++++++++------ .../implementation/world/finalizers.test.ts | 2 +- .../implementation/world/gpu.browser.test.ts | 2 +- .../world/index-changed.test.ts | 2 +- .../src/implementation/world/indexes.test.ts | 2 +- .../ecs/src/implementation/world/join.test.ts | 6 +++--- .../world/joining.test-helper.ts | 4 ++-- .../src/implementation/world/lifetime.test.ts | 2 +- .../src/implementation/world/names.test.ts | 3 ++- .../world/observe-cases.test.ts | 6 +++--- .../world/observe-factories.test.ts | 2 +- .../world/observe-joined-cases.test.ts | 2 +- .../world/observe-ticks-cases.test.ts | 10 +++++----- .../world/observer-contained.test-helper.ts | 4 ++-- .../world/observer-contained.test.ts | 6 +++--- .../world/observer-joined.test-helper.ts | 8 ++++---- .../world/observer-joined.test.ts | 2 +- .../src/implementation/world/observer.test.ts | 2 +- .../implementation/world/pool-size.fixture.ts | 2 +- .../implementation/world/reductions.test.ts | 4 ++-- .../world/remote-finalizers.test-helper.ts | 12 +++++++++--- .../world/remote-finalizers.test.ts | 6 +++--- .../world/remove.test-helper.ts | 8 ++++---- .../src/implementation/world/reports.test.ts | 2 +- .../src/implementation/world/strings.test.ts | 2 +- .../implementation/world/tails.test-helper.ts | 12 ++++++------ .../world/thread-systems.test-helper.ts | 14 +++++++------- .../world/ticks.browser.test.ts | 4 ++-- .../src/implementation/world/ticks.test.ts | 4 ++-- .../ecs/src/interface/system/resolve.test.ts | 6 ++++++ packages/ecs/src/perftest/handoff.ts | 2 +- packages/ecs/src/perftest/reads.ts | 2 +- packages/ecs/src/perftest/remote.ts | 4 ++-- packages/ecs/src/perftest/tails.ts | 6 +++--- packages/ecs/src/samples/transfers/sprites.ts | 4 ++-- packages/ecs/src/world.test.ts | 2 +- 38 files changed, 104 insertions(+), 84 deletions(-) diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index f3000359..d264d764 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -30,7 +30,7 @@ declare module "@adobe/ecs" { } -const pause = System.create({ }, ({ resources }) => { resources[paused] = true; }); +const pause = System.create({}, ({ resources }) => { resources[paused] = true; }); const tick = System.create({ resources: { not: [paused] } }, ({ resources }) => { resources[ticks] += 1; }); const afterTick = System.create({ after: [tick] }, ({ resources }) => { resources[after] += 1; }); const frame = System.create({ systems: [tick, afterTick] }); @@ -56,7 +56,7 @@ describe("resource conditions", () => { const measure = System.create({ resources: { is: [level] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); await world.run(measure); expect(await readResource(world, sizes)).toBe(0); - const load = System.create({ }, ({ resources }) => { resources[level] = { size: 3 }; }); + const load = System.create({}, ({ resources }) => { resources[level] = { size: 3 }; }); await world.run(load); await world.run(measure); expect(await readResource(world, sizes)).toBe(3); @@ -70,7 +70,7 @@ describe("resource conditions", () => { await world.run(spawn); await world.run(watch); expect(seen).toEqual([]); - await world.run(System.create({ }, ({ resources }) => { resources[level] = { size: 1 }; })); + await world.run(System.create({}, ({ resources }) => { resources[level] = { size: 1 }; })); await world.run(watch); expect(seen).toEqual([7]); }); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index ee3a1fc6..0361923a 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -345,7 +345,7 @@ describe("World.create", () => { await using world = createWorld(); expect(await readResource(world, gpuDevice)).toBeNull(); let seen: unknown = undefined; - await world.run(System.create({ }, ({ resources }) => { seen = resources[gpuDevice]; })); + await world.run(System.create({}, ({ resources }) => { seen = resources[gpuDevice]; })); expect(seen).toBeNull(); }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index 969bdc90..840c1c1d 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -64,26 +65,32 @@ const seenOf = (subject: Entity, lookup: { }; /** On the world's thread. */ -export const probe = System.create({ +/** + * What a probe's body reads: its lookups are passed to a helper, which the build can't follow, so they're given: only + * `found`'s rows are read, so the others keep the forms of lookups reading nothing. + */ +const probeReads = (read: readonly symbol[]) => ({ read, lookup: { found: [value], tagged: [], empty: [], has: [] } }); + +export const probe = System.create(giveAccess({ components: { with: [target] }, lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, -}, (row, { lookup }) => { +}, probeReads([target])), (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** The same, registered later, so its first run comes after every change. */ -export const lateProbe = System.create({ +export const lateProbe = System.create(giveAccess({ components: { with: [target] }, lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, -}, (row, { lookup }) => { +}, probeReads([target])), (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** On thread "entityLookupsTest", as it reads the probe's label there. */ -export const threadProbe = System.create({ +export const threadProbe = System.create(giveAccess({ components: { with: [target, label] }, lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, -}, (row, { lookup }) => { +}, probeReads([target, label])), (row, { lookup }) => { if (row[label]?.text === "probe") scene.seen.push(seenOf(row[target], lookup)); }); diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index a0885d8b..f0a5f637 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -134,7 +134,7 @@ describe("finalizers", () => { it("leave their commands for the next run's sync point, even when that run first adds resources", async () => { let other = Entity.none; Finalizer.create({ components: { with: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); - const reads = System.create({ }, ({ resources }) => { void resources[level]; }); + const reads = System.create({}, ({ resources }) => { void resources[level]; }); // Read after every run, as a read through a run of its own would apply the commands left for it. let seen: string | null = null; const watch = System.create({ observer: true, lookup: { labels: { with: [label] } } }, ({ lookup }) => { diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 759da4b6..39638483 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -209,7 +209,7 @@ const positions = async (world: ReturnType): Promise (await readEach(world, await select(world, { required: [position] }), position)).map(value => value ?? [NaN, NaN, NaN]); let nextDevice: GPUDevice | null = null; -const setDevice = System.create({ }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); +const setDevice = System.create({}, ({ resources }) => { resources[gpuDevice] = nextDevice; }); describe("removing GPU systems", () => { it("releases the GPU records of a render system and a GPU kernel once they're removed", async () => { diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index a639b430..40116ec0 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -48,7 +48,7 @@ const despawn = System.create({ target: "entity", components: { with: [group] } const dropWeight = System.create({ target: "entity", components: { with: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.remove(row.entity, weight); }); -const pause = System.create({ }, ({ resources }, input: { readonly paused: boolean }) => { +const pause = System.create({}, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index c73eb84a..bb0c70bb 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -409,7 +409,7 @@ const ranksOf = (list: { readonly length: number; readonly changed: boolean; at( }); describe("sorted indexes on the main thread", () => { - const spawnRanks = System.create({ }, ({ commands }, input: { readonly ranks: readonly number[] }) => { + const spawnRanks = System.create({}, ({ commands }, input: { readonly ranks: readonly number[] }) => { for (const value of input.ranks) commands.spawn([rank])({ [rank]: value }); }); const query = System.create({ diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index dab80092..4b718026 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -31,17 +31,17 @@ declare module "@adobe/ecs" { } /** Adds its struct input to `total`, and its object input's names to `names`. */ -const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const append = System.create({ }, ({ resources }, input: { readonly name: string }) => { +const append = System.create({}, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); /** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ -const tally = System.create({ }, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { +const tally = System.create({}, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { const next = new Map(resources[counts]); for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); resources[counts] = next; diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 5841e128..f1af11bb 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -24,10 +24,10 @@ declare module "@adobe/ecs" { } /** Appends a digit to `total`, so the order of runs shows in it. */ -export const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { +export const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -export const append = System.create({ }, ({ resources }, input: { readonly name: string }) => { +export const append = System.create({}, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); export const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts index 90958da8..39f05939 100644 --- a/packages/ecs/src/implementation/world/lifetime.test.ts +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -36,7 +36,7 @@ let copied = 0; /** Fresh systems of each shape that holds something: a body alone, a resource read, a change filter, and `up`. */ const freshSystems = (k: number): readonly System[] => [ System.create(() => {}), - System.create({ }, ({ resources }) => { copied = resources[score] + k; }), + System.create({}, ({ resources }) => { copied = resources[score] + k; }), System.create({ components: { with: [count], changed: [count] } }, row => { row[count] += 1; }), System.create({ components: { with: [count] }, up: { through: parent } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), ]; diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index 8892d1bf..297e0f86 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -42,7 +42,8 @@ const failOnce = System.create(() => { throw new Error("unnamed"); }); describe("systems, rules and finalizers of one label", () => { it("are distinct in schedules, both run, and are told apart in reports by their ids", async () => { - const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { with: [value] }, after }, body); + // Its body is written in place, calling what it's given with the row, which the build takes as writing every member. + const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { with: [value] }, after }, row => body(row)); const a = linked([], row => { ran.push("a"); row[value] += 1; }); const b = linked([a], row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); const c = linked([b], row => { ran.push(`c${row[value]}`); }); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 6a05186c..8c081fdc 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -31,14 +31,14 @@ declare module "@adobe/ecs" { } -const raise = System.create({ }, ({ resources }, input: { readonly by: number }) => { +const raise = System.create({}, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; resources[level] += input.by; }); -const touchOther = System.create({ }, ({ resources }) => { resources[other] += 1; }); +const touchOther = System.create({}, ({ resources }) => { resources[other] += 1; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); const sum = System.create({ components: { with: [weight] } }, (row, { reduce }) => { reduce[total](row[weight]); }); -const keep = System.create({ }, ({ resources }) => { resources[level] = resources[level] + 0; }); +const keep = System.create({}, ({ resources }) => { resources[level] = resources[level] + 0; }); /** Errors thrown out of band while `catching` runs, as uncaught errors would be, caught instead. */ const catching = async (run: () => Promise): Promise => { diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index b9f105f1..2c5fde6c 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -32,7 +32,7 @@ const seed = System.create({}, ({ commands }, input: { readonly count: number }) for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); }); const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); -const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); const nothing = System.create({}, () => {}); /** Counts passes: the observer passes after each run's sync point. */ diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index ebe64110..9049c5e2 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -44,7 +44,7 @@ const none = { text: "none" }; type Caption = { readonly text: string } | null; const Caption = { schema: { default: none } }; -const add = System.create({ }, ({ resources }, input: { readonly amount: number }) => { +const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index bb560e55..6db773cf 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -25,17 +25,17 @@ declare module "@adobe/ecs" { } } -const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); -const watch = System.create({ }, ({ resources }) => { resources[seen] = resources[clock].tick; }); +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); +const watch = System.create({}, ({ resources }) => { resources[seen] = resources[clock].tick; }); const fail = System.create({}, () => { throw new Error("tick failed"); }); const nothing = System.create({}, () => {}); const idle = System.create({ observer: true }, () => {}); -const fast = System.create({ }, ({ resources }) => { +const fast = System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1 }; resources[always] = true; }); -const calm = System.create({ }, ({ resources }) => { resources[always] = false; }); -const later = System.create({ }, ({ resources, reduce }) => { +const calm = System.create({}, ({ resources }) => { resources[always] = false; }); +const later = System.create({}, ({ resources, reduce }) => { if (resources[clock].tick === 0) reduce[wake](resources[clock].now + 20); }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 57607118..353165ad 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -25,9 +25,9 @@ export const gate: { words: Int32Array | null } = { words: null }; /** What the watcher's runs saw, in order. */ export const tallies: number[] = []; -export const bump = System.create({ }, ({ resources }) => { resources[tally] += 1; }); +export const bump = System.create({}, ({ resources }) => { resources[tally] += 1; }); export const nothing = System.create({}, () => {}); -export const setHolding = System.create({ }, ({ resources }, input: { readonly holding: number }) => { +export const setHolding = System.create({}, ({ resources }, input: { readonly holding: number }) => { resources[holding] = input.holding; }); diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index e7ef18a3..a5cf88a5 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -18,13 +18,13 @@ declare module "@adobe/ecs" { } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ -const widen = System.create({ }, ({ resources }) => { +const widen = System.create({}, ({ resources }) => { const first = resources[wide][0] + 1; const base = first * 100; resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; }); -const step = System.create({ }, ({ resources }) => { resources[steps] += 1; }); -const pause = System.create({ }, ({ resources }, input: { readonly paused: boolean }) => { +const step = System.create({}, ({ resources }) => { resources[steps] += 1; }); +const pause = System.create({}, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 2c2cf844..98ffddcf 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -81,7 +81,7 @@ export const hold = (): void => { export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: the counter moves on once a tick, then every row's stamp, spot and word take it. */ -export const count = System.create({ }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); export const advance = System.create({ components: { with: [stamp, spot, word] }, after: [count] }, (row, { resources }) => { @@ -94,7 +94,7 @@ export const ticked = System.create({ systems: [count, advance] }); export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ }, ({ resources }, input: { readonly pause: number }) => { +export const setStall = System.create({}, ({ resources }, input: { readonly pause: number }) => { resources[stall] = input.pause; }); @@ -138,7 +138,7 @@ export const spawnHeated = System.create({}, ({ commands }) => { export const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -export const nextPhase = System.create({ }, ({ resources }) => { +export const nextPhase = System.create({}, ({ resources }) => { resources[phase] += 1; }); @@ -152,7 +152,7 @@ export const countHot = System.create({ observer: true, lookup: { hot: { index: /** On the world's thread: the motto of a round, and the round, every motto of one size. */ export const mottoOf = (round: number): string => `motto ${round % 10}`; -export const setMotto = System.create({ }, ({ resources }, input: { readonly round: number }) => { +export const setMotto = System.create({}, ({ resources }, input: { readonly round: number }) => { resources[motto] = mottoOf(input.round); resources[mottoRound] = input.round; }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 941262ae..0c9dc869 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -40,7 +40,7 @@ const Label = { schema: { default: blank } }; const byCell = Index.create({ key: [cell], components: { with: [amount] } }); const allTagged = Index.create({ key: [], components: { with: [tagged] } }); -const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); const nothing = System.create({}, () => {}); const spawn = System.create({}, ({ commands }, input: { readonly at: number }) => { commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index 061b9f87..ad312e92 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -31,7 +31,7 @@ const reset = (): void => { for (const list of Object.values(seen)) list.length = 0; }; -const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); const spawn = System.create({}, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); const nothing = System.create({}, () => {}); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index 2b6c6c87..2504531c 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -35,7 +35,7 @@ const few = System.create({}, ({ commands }) => { for (let i = 0; i < 16; i++) commands.spawn([value])({ [value]: i }); }); const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); -const resize = System.create({ }, ({ resources }, input: { readonly size: number | null }) => { +const resize = System.create({}, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index b885d1df..1074cb56 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -91,7 +91,7 @@ const heavyMass = System.create({ kernel: true, components: { with: [mass, heavy const lightMass = System.create({ components: { with: [mass], not: [heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const reader = System.create({ }, ({ resources }) => { +const reader = System.create({}, ({ resources }) => { resources[seen] = resources[total]; }); const frame = System.create({ systems: [reader, heavyMass, lightMass] }); @@ -239,7 +239,7 @@ describe("reductions", () => { // @ts-expect-error only a resource with a reduce trait is reduced expect(() => System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written - expect(() => System.create({ }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); + expect(() => System.create({}, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); // A contributor doesn't read what it reduces into. expect(() => System.create({ components: { with: [mass] } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); // Nor filters on it. diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index e15bbaad..e662f69e 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -88,6 +88,8 @@ export const entitled: { readonly serial: number; readonly title: string }[] = [ /** On the finalizers' thread: what each titled stop's title was before its sync point applied. */ export const entitle = Finalizer.create({ components: { with: [serial, title, widget] } }, row => { + // Reading its widget places it on the widget's thread. + void row[widget]; entitled.push({ serial: row[serial], title: row[title] }); }); @@ -96,6 +98,7 @@ export const headlined: { readonly serial: number; readonly heading: string }[] /** On the finalizers' thread: the heading as it was before each headed stop's sync point applied. */ export const headline = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; headlined.push({ serial: row[serial], heading: resources[heading] }); }); @@ -104,6 +107,7 @@ export const headlinedBoth: { readonly serial: number; readonly heading: string; /** On the finalizers' thread: two string resources as they were before each headed stop's sync point applied. */ export const headlines = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); }); @@ -112,6 +116,7 @@ export const gauged: { readonly serial: number; readonly level: number; readonly /** On the finalizers' thread: a number and a struct resource as they were before each headed stop's sync point applied. */ export const gauge = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); }); @@ -121,6 +126,7 @@ export const surveyed: Surveyed[] = []; /** On the finalizers' thread: resources of mixed widths as they were before each headed stop's sync point applied. */ export const survey = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; const { alpha, color, glossy } = resources[material]; surveyed.push({ serial: row[serial], caption: resources[caption], lit: resources[lit], tint: [...resources[tint]], @@ -136,7 +142,7 @@ export const surveyOf = (at: number): Omit => ({ }); /** From the world's thread: the level, the origin at it, and the resources of mixed widths at it. */ -export const regauge = System.create({ }, ({ resources }, input: { readonly level: number }) => { +export const regauge = System.create({}, ({ resources }, input: { readonly level: number }) => { resources[level] = input.level; resources[origin] = [input.level, input.level + 10]; const values = surveyOf(input.level); @@ -152,7 +158,7 @@ export const spawnHeaded = System.create({}, ({ commands }, input: { readonly fr for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); }); /** From the world's thread: the heading and subheading. */ -export const rehead = System.create({ }, ({ resources }, input: { readonly heading: string }) => { +export const rehead = System.create({}, ({ resources }, input: { readonly heading: string }) => { resources[heading] = input.heading; resources[subheading] = `s${input.heading}`; }); @@ -181,7 +187,7 @@ export const relabel = System.create({ components: { with: [serial, label] } }, if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); }); /** On the thread: its object resource. */ -export const retone = System.create({ }, ({ resources }, input: { readonly tone: string }) => { +export const retone = System.create({}, ({ resources }, input: { readonly tone: string }) => { resources[tone] = { text: input.tone }; }); /** From the world's thread: places move, so a stop's values are those before the sync point applies. */ diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index 13c357b7..31e37166 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -23,13 +23,13 @@ declare module "@adobe/ecs" { [third]: { type: number }; } } -const bump = System.create({ }, ({ resources }) => { +const bump = System.create({}, ({ resources }) => { resources[later] += 1; }); -const bumpSecond = System.create({ }, ({ resources }) => { +const bumpSecond = System.create({}, ({ resources }) => { resources[second] += 1; }); -const bumpThird = System.create({ }, ({ resources }) => { +const bumpThird = System.create({}, ({ resources }) => { resources[third] += 1; }); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index ae66023b..2e4da6c2 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -38,7 +38,7 @@ const spin = (milliseconds: number): void => { }; /** Blocking, since its body reaches `commands`: a tier on the thread, and with `second`, one task. */ -export const first = System.create({ }, ({ resources, commands }) => { +export const first = System.create({}, ({ resources, commands }) => { void commands; calls.first += resources[mark].text.length; spin(resources[pause]); @@ -52,14 +52,14 @@ export const second = System.create({ after: [first] }, ({ resources, commands } }); /** Terminal, so a tail, run with `secondTail` as one task after the tick's sync point. */ -export const firstTail = System.create({ }, ({ resources }) => { +export const firstTail = System.create({}, ({ resources }) => { calls.firstTail += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(secondTail); }); /** Terminal, so a tail: removes `removal.target`, once, when it's set. */ -export const removerTail = System.create({ }, ({ resources }) => { +export const removerTail = System.create({}, ({ resources }) => { void resources[mark]; const { target } = removal; removal.target = null; @@ -77,6 +77,6 @@ export const secondTail = System.create({ after: [firstTail] }, ({ resources }) export const ticking = System.create({ after: [second] }, () => {}); /** Sets how long `first` and `firstTail` spin. */ -export const setPause = System.create({ }, ({ resources }, input: { readonly milliseconds: number }) => { +export const setPause = System.create({}, ({ resources }, input: { readonly milliseconds: number }) => { resources[pause] = input.milliseconds; }); diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index 20bae7b8..5a17fb22 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -18,7 +18,7 @@ declare module "@adobe/ecs" { const nothing = System.create({}, () => {}); const fail = System.create({}, () => { throw new Error("run failed"); }); -const raise = System.create({ }, ({ resources }) => { resources[level] += 1; }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); const idle = System.create({ observer: true }, () => {}); const spawn = System.create({}, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); const despawnAll = System.create({ components: { with: [amount] } }, (row, { commands }) => { diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index 9cddb7b4..d2e8d880 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -69,7 +69,7 @@ const labelAgain = System.create({ components: { with: [order], not: [label] } } commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const edit = System.create({ }, ({ resources }, input: { readonly title: string }) => { +const edit = System.create({}, ({ resources }, input: { readonly title: string }) => { resources[mode] = "edit"; resources[title] = input.title; }); diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index 15128db3..0f3e489d 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -52,7 +52,7 @@ const spin = (milliseconds: number): void => { }; /** On the world's thread: the counter moves on once a tick. */ -export const count = System.create({ }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); /** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ @@ -79,13 +79,13 @@ export const grow = System.create({ after: [count] }, ({ resources, commands }) export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ }, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { +export const setStall = System.create({}, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; }); -export const setSpawning = System.create({ }, ({ resources }, input: { readonly count: number }) => { +export const setSpawning = System.create({}, ({ resources }, input: { readonly count: number }) => { resources[spawning] = input.count; }); -export const setFailAt = System.create({ }, ({ resources }, input: { readonly at: number }) => { +export const setFailAt = System.create({}, ({ resources }, input: { readonly at: number }) => { resources[failAt] = input.at; }); @@ -94,7 +94,7 @@ export type Seen = { counter: number; rows: number; wrong: number; labels: numbe export const seen: Seen[] = []; /** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ -export const lead = System.create({ }, ({ resources }) => { +export const lead = System.create({}, ({ resources }) => { if (resources[stall].mode === stallBefore && resources[canvas].text === "canvas") spin(resources[stall].pause); }); @@ -138,7 +138,7 @@ export const tally = System.create({ components: { with: [label], changed: [labe export const gate: { words: Int32Array | null } = { words: null }; /** On the tail's thread, terminal, reading only that thread's objects: held in its body until the world opens the gate. */ -export const held = System.create({ }, ({ resources }) => { +export const held = System.create({}, ({ resources }) => { const { words } = gate; if (words === null || resources[canvas].text !== "canvas") return; Atomics.store(words, 1, 1); diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 34aa83d4..8948c663 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -53,7 +53,7 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con const makeNote = (text: string): Note => ({ text, read: () => text }); /** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ -export const seed = System.create({ }, ({ resources, commands }, input: { readonly count: number }) => { +export const seed = System.create({}, ({ resources, commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); @@ -136,7 +136,7 @@ export const stamp = System.create({ components: { with: [uid, caption] } }, (ro }); /** Disables `repaint`, from the world's thread. */ -export const disable = System.create({ }, ({ resources }) => { +export const disable = System.create({}, ({ resources }) => { resources[enabled] = false; }); @@ -265,16 +265,16 @@ export const giveKept = System.create({ components: { with: [caption] } }, (row, if (row[caption] !== undefined) commands.add(row.entity, kept, "given there"); }); /** On the thread: spawns giving an object of the world's thread a value, which throws when it's issued. */ -export const spawnKept = System.create({ }, ({ resources, commands }) => { +export const spawnKept = System.create({}, ({ resources, commands }) => { commands.spawn([uid, kept])({ [uid]: resources[paint].text.length, [kept]: "spawned there" }); }); /** On the thread: runs until its thread ends, so its task never completes. */ -export const stall = System.create({ }, ({ resources }) => { +export const stall = System.create({}, ({ resources }) => { while (resources[paint].text !== "never") { /* until the thread ends */ } }); /** On the world's thread: counts its runs, ticking with nothing on the thread. */ -export const count = System.create({ }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[changes] += 1; }); /** @@ -294,12 +294,12 @@ const setQueued = (task: () => void): void => { ranHere.atDue = -1; }; /** On the thread: counts its runs, so the world's ticks post it a task each. */ -export const countThere = System.create({ }, ({ resources }) => { +export const countThere = System.create({}, ({ resources }) => { if (resources[paint].text !== "never") resources[changes] += 1; }); /** On the thread: counts its runs there in `ranHere`; it reaches its commands, so it blocks its tier, a task a tick. */ -export const counted = System.create({ }, ({ resources, commands }) => { +export const counted = System.create({}, ({ resources, commands }) => { void resources[paint]; void commands; ranHere.runs++; diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index 66789691..d5b250a2 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -14,8 +14,8 @@ declare module "@adobe/ecs" { } } -const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); -const start = System.create({ }, ({ resources }) => { resources[always] = true; }); +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); +const start = System.create({}, ({ resources }) => { resources[always] = true; }); describe("a frame cadence", () => { it("ticks on animation frames, with their time", async () => { diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 3111541a..de67bde2 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -12,11 +12,11 @@ declare module "@adobe/ecs" { } } -const count = System.create({ }, ({ resources }) => { resources[frames] += 1; }); +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); describe("ticks", () => { it("reject a system that conflicts with one added with no order between them, and one with input", async () => { - const other = System.create({ }, ({ resources }) => { resources[frames] = 0; }); + const other = System.create({}, ({ resources }) => { resources[frames] = 0; }); const withInput = System.create({}, (_context, input: { readonly by: number }) => {}); await using world = createWorld({ [workers]: 0 }); world.add(count); diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index fc9e712d..d59200f9 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -4,6 +4,12 @@ import { lookupsLists, type Access } from "./resolve.js"; const mass = Symbol("resolveTest/mass"); const position = Symbol("resolveTest/position"); +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: number }; + [position]: { type: number }; + } +} const access = (lookup: Access["lookup"]): Access => ({ components: null, resources: null, lookup, up: null, at: "a test" }); describe("lookupsLists", () => { diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts index ecb20df6..76d3b338 100644 --- a/packages/ecs/src/perftest/handoff.ts +++ b/packages/ecs/src/perftest/handoff.ts @@ -36,7 +36,7 @@ const there4 = System.create({ after: [here4] }, ({ resources, commands }) => { /** Ten tiers alternating the world's thread and the joined one, empty systems. */ export const handoff = System.create({ systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); -const fused0 = System.create({ }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused0 = System.create({}, ({ resources, commands }) => { void resources[token]; void commands; }); const fused1 = System.create({ after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); const fused2 = System.create({ after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); const fused3 = System.create({ after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index bc331d06..60741c83 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -108,7 +108,7 @@ export const readBenchmarks: readonly Benchmark[] = [ run: async () => { const world = await seeded(10_000); copied = 0; - const best = await fastest(() => world.run(System.create({ }, ({ resources }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({}, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run: wrong value"); await world.dispose(); return best; diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index c3546da7..1cd436c3 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -34,11 +34,11 @@ declare module "@adobe/ecs" { /** An empty transaction. */ export const empty = System.create({}, () => {}); /** Changes the observed resource every tick. */ -export const count = System.create({ }, ({ resources }) => { resources[counter] += 1; }); +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); export const seedRanks = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); -const flip = System.create({ }, ({ resources }) => { resources[sign] = -resources[sign]; }); +const flip = System.create({}, ({ resources }) => { resources[sign] = -resources[sign]; }); const reverse = System.create({ kernel: true, components: { with: [rank, order] }, after: [flip] }, (row, { resources }) => { row[order] = row[rank] * resources[sign]; }); diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index f045cba7..0b246823 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -67,7 +67,7 @@ const draw = System.create({ components: { with: [place] } }, (row, { resources export const promptTick = System.create({ systems: [here, draw] }); /** On the joined thread, ahead of the late tail: busy for a while, as a main thread is with layout. */ -const busy = System.create({ }, ({ resources }) => { +const busy = System.create({}, ({ resources }) => { const until = performance.now() + 4; while (performance.now() < until && resources[surface] === surfaceStart); }); @@ -77,7 +77,7 @@ const copied = System.create({ components: { with: [place, pose] }, after: [busy }); export const lateTick = System.create({ systems: [busy, copied] }); /** On the world's thread: writes what the late tail reads, so it copies the rest of the tail's reads first. */ -const poke = System.create({ }, ({ resources }) => { +const poke = System.create({}, ({ resources }) => { resources[poked] += 1; }); const nothing = System.create({}, () => {}); @@ -105,7 +105,7 @@ const overlapDraw = System.create({ components: { with: [place, pose] }, after: drawn.sum += sum; }); export const overlapTick = System.create({ systems: [overlapWrite, overlapSimulate, overlapDraw] }); -const setOverlap = System.create({ }, ({ resources }) => { +const setOverlap = System.create({}, ({ resources }) => { resources[rowWork] = overlapWork; resources[worldWork] = overlapWorldMilliseconds; }); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index 5282e6c2..48b4e4b3 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -28,7 +28,7 @@ declare module '@adobe/ecs' { /** The canvas main moved to the world's thread. */ /** From main: the canvas the world draws into. */ -export const attachSurface = System.create({ }, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { +export const attachSurface = System.create({}, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { resources[surface] = input.canvas; }); @@ -53,7 +53,7 @@ export const addSprite = System.create({}, ({ resources, commands }, input: { re }); /** Clears the canvas, once a frame. */ -const clear = System.create({ }, ({ resources }) => { +const clear = System.create({}, ({ resources }) => { const canvas = resources[surface]; canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); resources[stats] = { sprites: resources[stats].sprites, frames: resources[stats].frames + 1 }; diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index 5fa4b2da..97c0bfe2 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -14,7 +14,7 @@ declare module "@adobe/ecs" { } const spawn = System.create({}, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); -const raise = System.create({ }, ({ resources }) => { resources[total] = 7; }); +const raise = System.create({}, ({ resources }) => { resources[total] = 7; }); const add = System.create({ components: { with: [count] } }, row => { row[count] += 1; }); describe("World.create", () => { From 2707d835102ecc49622f0ca126a47ccc289d792d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 21:58:32 -0700 Subject: [PATCH 083/127] ecs: follow-ups track persistence: a persist trait for stable ids, and changes finalizers watching by trait Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 92df0324..d9fb7d6c 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -100,6 +100,13 @@ Open items awaiting a decision or work. Remove an item once it's decided or done (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. +- **Persistence.** Saved data needs ids stable across renames and versions, which thread ids (`name@version/path#binding`) + deliberately aren't. Planned shape: + - a `persist: string` trait on each persisted component, the name saved data maps through, checked unique at + registration; + - `Finalizer.changes({ watch: "persist" }, …)`: `watch` taking a trait name as well as a list of keys, typed as + `Component.With<"persist">`, computed once per component id as `watches` already is, so it costs nothing per + change. ## Performance From 890da3ea58fac36fd34b4deea246860b9d2fdce2 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:24:40 -0700 Subject: [PATCH 084/127] ecs: a declaration that doesn't hold fails each World.create call once, on the call, naming its key, member and problem Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/src/exports.test.ts | 78 ++++++++++++++----- packages/ecs/src/guide/guide.md | 3 +- packages/ecs/src/interface/world/create.ts | 12 +-- .../ecs/src/interface/world/declarations.ts | 15 +++- 5 files changed, 78 insertions(+), 32 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 98528565..4ed36a45 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -73,7 +73,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. - **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, it returns a type naming each such declaration's key and members rather than a `World`, so using it fails to compile. That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails then is thrown once, from that read, and given as the reason the key's uses fail. A library's shipped build records its keys the same way. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails then is thrown once, from that read, and given as the reason the key's uses fail. A library's shipped build records its keys the same way. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index eb96b004..e4df5ed0 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -75,7 +75,7 @@ const internal = (path: string): boolean => || path.startsWith("interface/gpu/record"); /** Compiles a consumer's `index.ts` against the installed package, emitting its declarations. */ -const compile = (source: string): { readonly messages: string[] } => { +const compile = (source: string): { readonly messages: string[]; readonly lines: number[] } => { writeFileSync(join(temporary, "index.ts"), source); const program = ts.createProgram([join(temporary, "index.ts")], { target: ts.ScriptTarget.ESNext, @@ -89,9 +89,18 @@ const compile = (source: string): { readonly messages: string[] } => { outDir: join(temporary, "out"), }); const result = program.emit(); - return { messages: messages([...ts.getPreEmitDiagnostics(program), ...result.diagnostics]) }; + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics]; + // Each diagnostic's line in the consumer, 1-based; 0 for one outside it. + const lines = diagnostics.map(diagnostic => diagnostic.file === undefined || diagnostic.start === undefined || !diagnostic.file.fileName.endsWith("index.ts") + ? 0 + : diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1); + return { messages: messages(diagnostics), lines }; }; +/** The 1-based lines of `source` holding `text`. */ +const linesWith = (source: string, text: string): number[] => + source.split("\n").flatMap((line, index) => line.includes(text) ? [index + 1] : []); + /** A consumer with a system whose declaration lists a key in both read and write, and composites of conflicting inputs. */ const misdeclared = ` import { System } from "@adobe/ecs"; @@ -107,20 +116,39 @@ export const listed = System.create([near, far]); export const declared = System.create({ systems: [near, far], after: [both] }); `; -/** A consumer whose declarations give traits values their types don't allow, and a default not of its type. */ -const misdeclaredTraits = ` -import { World } from "@adobe/ecs"; -export const count = Symbol(); -export const mark = Symbol(); -export const label = Symbol(); -declare module "@adobe/ecs" { - interface Traits { readonly [mark]: N extends Component.Key ? (Component.Value extends string ? true : never) : never } - interface Components { [count]: { type: number; thread: "main"; [mark]: true } } - interface Resources { [label]: { type: string; default: 1; transfer: true } } -} +/** A consumer creating two worlds and using them, with `declarations` augmenting the package. */ +const creating = (declarations: string): string => ` +import { World, workers, type Component } from "@adobe/ecs"; +${declarations} export const world: World = World.create(); +export const other: World = World.create({ [workers]: 0 }); +export const typed: World = world; +export const disposed = other.dispose(); `; +/** A trait of your own on a key its type doesn't allow. */ +const customTrait = creating(` +export const instanced = Symbol(); +export const foo = Symbol(); +declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never } + interface Components { [foo]: { type: string; [instanced]: true } } +}`); + +/** A built-in trait on a key its type doesn't allow: a thread on a struct. */ +const builtInTrait = creating(` +export const spot = Symbol(); +declare module "@adobe/ecs" { + interface Components { [spot]: { type: number; thread: "main" } } +}`); + +/** A default that isn't of the declared type. */ +const wrongDefault = creating(` +export const label = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [label]: { type: string; default: 1 } } +}`); + describe("package exports", () => { beforeAll(() => { install(); @@ -143,14 +171,22 @@ describe("package exports", () => { }>;`); expect([...emitted.matchAll(/import\("([^"]+)"\)/g)].map(match => match[1]).filter(path => path !== "@adobe/ecs")).toEqual([]); }, 60_000); - it("fail World.create to compile while a declaration gives a trait a value its type doesn't allow, naming it", () => { - const reported = compile(misdeclaredTraits.replace('import { World } from "@adobe/ecs";', 'import { World, type Component } from "@adobe/ecs";')).messages.join("\n"); - expect(reported).toContain("declarations give traits values their types don't allow"); - // Each declaration by its key, with the members that don't hold: a trait of your own's among them. - expect(reported).toContain(`[count]: unique symbol | "thread"`); - expect(reported).toContain("[label]:"); - expect(reported).toContain(`"default"`); - expect(reported).toContain(`"transfer"`); + it("report a declaration that doesn't hold once at each World.create call, naming the key, the member and the problem", () => { + const cases = [ + { source: customTrait, named: `readonly [foo]: { readonly [instanced]: "isn't allowed on this key"; }` }, + { source: builtInTrait, named: `readonly [spot]: { readonly thread: "isn't allowed on this key"; }` }, + { source: wrongDefault, named: `readonly [label]: { readonly default: "isn't of the declared type"; }` }, + ]; + for (const { source, named } of cases) { + const { messages: reported, lines } = compile(source); + // One error per call, on the call; uses of the worlds type-check. + expect(lines).toEqual(linesWith(source, "World.create(")); + for (const message of reported) { + expect(message).toContain("declarations don't hold"); + expect(message).toContain(named); + } + } + expect(compile(creating("")).messages).toEqual([]); }, 60_000); it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => { const reported = compile(misdeclared).messages; diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 1d50ae6b..80dac24c 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -44,7 +44,8 @@ export default defineConfig({ plugins: [Compiler.plugin()] }); Each component and resource is declared once, in the module that makes its key, by augmenting `Components` or `Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its -precision or width. Traits of your own augment `Traits`. A library builds with Vite and +precision or width. Traits of your own augment `Traits`. While a declaration's trait or default doesn't hold, each +`World.create` call fails to compile, naming the key, the member and what's wrong with it. A library builds with Vite and `Compiler.plugin({ library: true })`, so its build ships its registrations. Kernels run on workers as separate chunks, so the plugin sets Vite's `worker.format` to `"es"` when the config doesn't set it, for every worker the app bundles, its own included, which then load as module workers (`{ type: "module" }`). The examples create worlds with diff --git a/packages/ecs/src/interface/world/create.ts b/packages/ecs/src/interface/world/create.ts index 33321a8e..451c303b 100644 --- a/packages/ecs/src/interface/world/create.ts +++ b/packages/ecs/src/interface/world/create.ts @@ -12,9 +12,11 @@ export type InitialResources = { readonly [K in Exclude [InvalidDeclarations] extends [never] - ? World - : { readonly "declarations give traits values their types don't allow": { readonly [D in InvalidDeclarations as D["key"]]: D["members"] } }; +export type Create = ( + // Unknown while every declaration holds; else what a call's `this` can't be, so the call reports it, printed whole. + this: [InvalidDeclarations] extends [never] ? unknown : { readonly "declarations don't hold": { readonly [D in InvalidDeclarations as D["key"]]: D["members"] } }, + resources?: InitialResources, +) => World; diff --git a/packages/ecs/src/interface/world/declarations.ts b/packages/ecs/src/interface/world/declarations.ts index 48118074..a0f5b1d4 100644 --- a/packages/ecs/src/interface/world/declarations.ts +++ b/packages/ecs/src/interface/world/declarations.ts @@ -15,12 +15,19 @@ type Invalid = | { readonly [T in Exclude]-?: T extends TraitKey ? ([Entry[T]] extends [Traits[T]] ? never : T) : T }[Exclude] | (Entry extends { readonly default: infer D } ? ([D] extends [Value] ? never : "default") : never); -// `infer` resolves the members, so errors print them rather than the alias that computes them. -type Reported = [Members] extends [never] ? never : Members extends infer Resolved ? { readonly key: K; readonly members: Resolved } : never; +/** + * Key `K` with what's wrong with each member that doesn't hold, keyed by member so an error prints a trait by its + * key's binding; written in place, as an alias would print by its name. + */ +type Reported = [Members] extends [never] ? never : { + readonly key: K; + readonly members: { readonly [M in Members]: M extends "default" ? "isn't of the declared type" : M extends TraitKey ? "isn't allowed on this key" : "isn't a trait" }; +}; /** - * Every declaration in `Components` and `Resources` with members that don't hold, each as its key and those members; - * `never` when all hold. The build registers declarations without a type checker, so `World.create` reports these. + * Every declaration in `Components` and `Resources` with members that don't hold, each as its key and what's wrong + * with those members; `never` when all hold. The build registers declarations without a type checker, so + * `World.create` reports these. */ export type InvalidDeclarations = | { readonly [K in Component.Key]: Reported>> }[Component.Key] From ecb13a572dea4677d8b49abeed752f7d6926d141 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:32:28 -0700 Subject: [PATCH 085/127] ecs: the guide teaches lookups by entity Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/guide/12-lookups.test.ts | 62 +++++++++++++++++++++++ packages/ecs/src/guide/guide.md | 31 +++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/ecs/src/guide/12-lookups.test.ts diff --git a/packages/ecs/src/guide/12-lookups.test.ts b/packages/ecs/src/guide/12-lookups.test.ts new file mode 100644 index 00000000..9df84bb5 --- /dev/null +++ b/packages/ecs/src/guide/12-lookups.test.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Entity, System, World } from "../index.js"; + +const health = Symbol(); +const target = Symbol(); +const frozen = Symbol(); +const aim = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [health]: { type: number }; + [target]: { type: Entity; reference: "weak" }; + [frozen]: { type: true }; + [aim]: { type: number }; + } +} + +// A lookup reads another entity's components by its id: its row, or null when the entity doesn't have every component +// in with, or has one in not. +const track = System.create({ + components: { with: [target, aim] }, + lookup: { enemy: { with: [health], not: [frozen] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + row[aim] = enemy === null ? 0 : enemy[health]; +}); + +// With changed, its row says whether those components may have changed since the system last ran. +const refreshed: boolean[] = []; +const watch = System.create({ + observer: true, + components: { with: [target] }, + lookup: { enemy: { with: [health], changed: [health] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + if (enemy !== null) refreshed.push(enemy.changed); +}); + +const aims: number[] = []; +const readAims = System.create({ observer: true, components: { with: [aim] } }, row => { + aims.push(row[aim]); +}); + +const spawn = System.create(({ commands }) => { + const ogre = commands.spawn([health])({ [health]: 40 }); + const statue = commands.spawn([health, frozen])({ [health]: 90 }); + commands.spawn([target, aim])({ [target]: ogre, [aim]: -1 }); + commands.spawn([target, aim])({ [target]: statue, [aim]: -1 }); +}); + +it("reads other entities by id", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(track); + await world.run(readAims); + // The frozen statue doesn't match, so its tracker reads null. + expect(aims.sort()).toEqual([0, 40]); + await world.run(watch); + await world.run(watch); + expect(refreshed).toEqual([true, true, false, false]); +}); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 80dac24c..7f15dc2c 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -532,7 +532,36 @@ check, typeof transform | typeof ### 12. Lookups -Reading another entity's components by id, from a system. To be written. +```ts +// A lookup reads another entity's components by its id: its row, or null when the entity doesn't have every component +// in with, or has one in not. +const track = System.create({ + components: { with: [target, aim] }, + lookup: { enemy: { with: [health], not: [frozen] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + row[aim] = enemy === null ? 0 : enemy[health]; +}); +``` + +[`12-lookups.test.ts`](12-lookups.test.ts#L19-L27) + +```ts +// With changed, its row says whether those components may have changed since the system last ran. +const refreshed: boolean[] = []; +const watch = System.create({ + observer: true, + components: { with: [target] }, + lookup: { enemy: { with: [health], changed: [health] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + if (enemy !== null) refreshed.push(enemy.changed); +}); +``` + +[`12-lookups.test.ts`](12-lookups.test.ts#L29-L38) + +To find entities by their values rather than their ids, look up through an index (next section). ### 13. Indexes From 83297205f3146c7e886757f7a12f1042d87eaecc Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:39:07 -0700 Subject: [PATCH 086/127] ecs: the guide teaches kernels, reductions, GPU kernels and render systems, hierarchies, identity and threads Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/guide/14-kernels.test.ts | 54 ++++++ packages/ecs/src/guide/15-reductions.test.ts | 46 +++++ packages/ecs/src/guide/16-gpu.test.ts | 58 ++++++ packages/ecs/src/guide/17-hierarchies.test.ts | 43 +++++ packages/ecs/src/guide/18-identity.test.ts | 24 +++ packages/ecs/src/guide/19-threads.test.ts | 55 ++++++ packages/ecs/src/guide/CLAUDE.md | 6 +- packages/ecs/src/guide/guide.md | 174 +++++++++++++++++- 8 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 packages/ecs/src/guide/14-kernels.test.ts create mode 100644 packages/ecs/src/guide/15-reductions.test.ts create mode 100644 packages/ecs/src/guide/16-gpu.test.ts create mode 100644 packages/ecs/src/guide/17-hierarchies.test.ts create mode 100644 packages/ecs/src/guide/18-identity.test.ts create mode 100644 packages/ecs/src/guide/19-threads.test.ts diff --git a/packages/ecs/src/guide/14-kernels.test.ts b/packages/ecs/src/guide/14-kernels.test.ts new file mode 100644 index 00000000..03e6bcf1 --- /dev/null +++ b/packages/ecs/src/guide/14-kernels.test.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, System, Vec3, World } from "../index.js"; + +const position = Symbol(); +const velocity = Symbol(); +const delta = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + } + interface Resources { + [delta]: { type: F32; default: 0.5 }; + } +} + +// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as +// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles +// it, with results the same as the body as written. +const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => { + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32, +// which is faster on dense rows. +const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +// kernel: false runs the body as written, on its thread: to step through it in a debugger, say. +const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => { + row[velocity] = Vec3.scale(row[velocity], 0.5); +}); + +const positions: Vec3[] = []; +const read = System.create({ observer: true, components: { with: [position] } }, row => { + positions.push(row[position]); +}); + +const spawn = System.create(({ commands }) => { + for (let i = 0; i < 3; i++) commands.spawn([position, velocity])({ [position]: [i, 0, 0], [velocity]: [2, 0, 0] }); +}); + +it("runs bodies as kernels", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(move); + await world.run(drift); + await world.run(slow); + await world.run(read); + expect(positions.map(([x]) => x).sort()).toEqual([3, 4, 5]); +}); diff --git a/packages/ecs/src/guide/15-reductions.test.ts b/packages/ecs/src/guide/15-reductions.test.ts new file mode 100644 index 00000000..68b0e214 --- /dev/null +++ b/packages/ecs/src/guide/15-reductions.test.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, System, World } from "../index.js"; + +const mass = Symbol(); +const total = Symbol(); +const heaviest = Symbol(); +const count = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: F32 }; + } + // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^". + interface Resources { + [total]: { type: number; reduce: "+" }; + [heaviest]: { type: F32; reduce: "max" }; + [count]: { type: number; reduce: "+" }; + } +} + +// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the +// fold of what every row gave, however the rows were split across workers. +const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => { + reduce[total](row[mass]); + reduce[heaviest](row[mass]); + reduce[count](1); +}); + +// Read the result after the run, as any resource. +let summary = ""; +const report = System.create({ observer: true }, ({ resources }) => { + summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`; +}); + +const spawn = System.create(({ commands }) => { + for (const value of [2, 5, 3]) commands.spawn([mass])({ [mass]: value }); +}); + +it("folds rows into a resource", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(weigh); + await world.run(report); + expect(summary).toBe("3 weigh 10, at most 5"); +}); diff --git a/packages/ecs/src/guide/16-gpu.test.ts b/packages/ecs/src/guide/16-gpu.test.ts new file mode 100644 index 00000000..b9128a34 --- /dev/null +++ b/packages/ecs/src/guide/16-gpu.test.ts @@ -0,0 +1,58 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, Gpu, System, Vec2, World } from "../index.js"; + +const position = Symbol(); +const velocity = Symbol(); +const size = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec2 }; + [velocity]: { type: Vec2 }; + [size]: { type: F32 }; + } +} + +// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device +// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers +// and vectors of them; it has no lookups, change filters or commands. +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { + row[position] = Vec2.add(row[position], row[velocity]); +}); + +// A render system records draws over the GPU copy of the components its accessors name, in a world with a device. +// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and +// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw. +let pipeline: GPURenderPipeline | null = null; +let target: GPUTextureView | null = null; +const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => { + if (pipeline === null || target === null) return; + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] }); + pass.setPipeline(pipeline); + for (const table of tables) { + table.bind(pass); + pass.draw(6, table.count); + } + pass.end(); +}); +const shaderCode = Gpu.wgsl(draw); + +const positions: Vec2[] = []; +const read = System.create({ observer: true, components: { with: [position] } }, row => { + positions.push(row[position]); +}); + +const spawn = System.create(({ commands }) => { + commands.spawn([position, velocity, size])({ [position]: [0, 0], [velocity]: [1, 2], [size]: 4 }); +}); + +it("runs GPU kernels on the CPU without a device, and skips render systems", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(move); + await world.run(draw); + await world.run(read); + expect(positions).toEqual([[1, 2]]); + expect(shaderCode).toContain("fn dotPosition(row: u32) -> vec2"); +}); diff --git a/packages/ecs/src/guide/17-hierarchies.test.ts b/packages/ecs/src/guide/17-hierarchies.test.ts new file mode 100644 index 00000000..dfb13498 --- /dev/null +++ b/packages/ecs/src/guide/17-hierarchies.test.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Entity, System, World } from "../index.js"; + +const parent = Symbol(); +const offset = Symbol(); +const placed = Symbol(); +const label = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + // Despawned with its parent. + [parent]: { type: Entity; reference: "owner" }; + [offset]: { type: number }; + [placed]: { type: number }; + [label]: { type: string }; + } +} + +// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run: +// context.up holds what the body reads of the parent, or null for a row without one. +const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => { + row[placed] = (up === null ? 0 : up[placed]) + row[offset]; +}); + +const where: string[] = []; +const read = System.create({ observer: true, components: { with: [label, placed] } }, row => { + where.push(`${row[label]} at ${row[placed]}`); +}); + +const spawn = System.create(({ commands }) => { + const table = commands.spawn([label, offset, placed])({ [label]: "table", [offset]: 10, [placed]: 0 }); + const tray = commands.spawn([label, offset, placed, parent])({ [label]: "tray", [offset]: 2, [placed]: 0, [parent]: table }); + commands.spawn([label, offset, placed, parent])({ [label]: "cup", [offset]: 1, [placed]: 0, [parent]: tray }); +}); + +it("runs rows parent first", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(place); + await world.run(read); + expect(where.sort()).toEqual(["cup at 13", "table at 10", "tray at 12"]); +}); diff --git a/packages/ecs/src/guide/18-identity.test.ts b/packages/ecs/src/guide/18-identity.test.ts new file mode 100644 index 00000000..39b083a5 --- /dev/null +++ b/packages/ecs/src/guide/18-identity.test.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Report, System, World } from "../index.js"; + +// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code +// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared, +// its package's name and version, its module's path and its binding, so other threads and a module evaluated again +// by hot reload name the same one. +const explode = System.create(() => { + throw new Error("boom"); +}); + +// Reports label it by its binding, and give the id beside it to tell two of one label apart. +const failures: string[] = []; +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`); +}); + +it("labels systems by their binding and id", async () => { + await using world = World.create(); + world.add(logFailures); + await world.run(explode); + expect(failures).toEqual([expect.stringMatching(/^explode @adobe\/ecs@[^/]+\/src\/guide\/18-identity\.test\.ts#explode$/)]); +}); diff --git a/packages/ecs/src/guide/19-threads.test.ts b/packages/ecs/src/guide/19-threads.test.ts new file mode 100644 index 00000000..015fed36 --- /dev/null +++ b/packages/ecs/src/guide/19-threads.test.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System, World } from "../index.js"; + +const volume = Symbol(); +const levels = Symbol(); + +// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it. +declare module "@adobe/ecs" { + interface Threads { + audio: true; + } + interface Components { + [volume]: { type: number }; + } + interface Resources { + [levels]: { type: ReadonlyMap; thread: "audio" }; + } +} + +// A system runs on the thread its objects live on: this one, writing levels, runs on audio. +const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => { + resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]); +}); + +// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after +// the world's sync points. +const heard: number[] = []; +const listen = System.create({ observer: true }, ({ resources }) => { + heard.push(resources[levels].size); +}); + +const spawn = System.create(({ commands }) => { + commands.spawn([volume])({ [volume]: 0.8 }); + commands.spawn([volume])({ [volume]: 0.3 }); +}); + +/** Waits a turn of the event loop at a time until `done`. */ +const until = async (done: () => boolean): Promise => { + while (!done()) await new Promise(resolve => setTimeout(resolve, 1)); +}; + +it("shares a world with another thread", async () => { + // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other. + const channel = new MessageChannel(); + await using world = World.create(); + world.share(channel.port1); + await using audio = World.join(channel.port2, { threads: ["audio"] }); + audio.add(listen); + await world.run(spawn); + // A joined world runs systems as the world does. + await audio.run(mix); + await until(() => heard.at(-1) === 2); + expect(heard.at(-1)).toBe(2); +}); diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md index fa240a9d..e45d2e2d 100644 --- a/packages/ecs/src/guide/CLAUDE.md +++ b/packages/ecs/src/guide/CLAUDE.md @@ -13,8 +13,7 @@ code it shows: every excerpt comes from a file here that runs as a test, so the compile-time checks prevent most mistakes. - Never mention `@adobe/data` or any other package that isn't this one. It means nothing to a consumer. - Describe only the current API, with no history. -- Keep it small. Write sections for the basics and intermediate topics; advanced topics are placeholders until the - API settles. +- Keep it small. Advanced sections are as short as the rest: each shows the common case of its topic and stops. ## Structure @@ -24,7 +23,8 @@ code it shows: every excerpt comes from a file here that runs as a test, so the share a section; entities are taught with components, and archetypes, named lists of components, with them. - **Intermediate:** scheduling and composites, sync points, change filters and conditions, input, rules, finalizers and change sets, and traits with within and references. -- **Advanced:** placeholders only. +- **Advanced:** lookups, indexes, kernels, reductions, GPU kernels and render systems, hierarchies, identity and ids, + and threads. ## Code conventions diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 7f15dc2c..861c24d9 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -626,25 +626,185 @@ lookup's `changed: true` runs its system only when that happened anywhere in the ### 14. Kernels -Systems compiled to run across workers: a system with components compiles when its body allows it, `kernel: true` -requires it and `kernel: false` runs it as written. To be written. +```ts +// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as +// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles +// it, with results the same as the body as written. +const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => { + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32, +// which is faster on dense rows. +const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +// kernel: false runs the body as written, on its thread: to step through it in a debugger, say. +const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => { + row[velocity] = Vec3.scale(row[velocity], 0.5); +}); +``` + +[`14-kernels.test.ts`](14-kernels.test.ts#L19-L35) ### 15. Reductions -One value computed from many rows. To be written. +```ts +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: F32 }; + } + // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^". + interface Resources { + [total]: { type: number; reduce: "+" }; + [heaviest]: { type: F32; reduce: "max" }; + [count]: { type: number; reduce: "+" }; + } +} +``` + +[`15-reductions.test.ts`](15-reductions.test.ts#L10-L20) + +```ts +// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the +// fold of what every row gave, however the rows were split across workers. +const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => { + reduce[total](row[mass]); + reduce[heaviest](row[mass]); + reduce[count](1); +}); + +// Read the result after the run, as any resource. +let summary = ""; +const report = System.create({ observer: true }, ({ resources }) => { + summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`; +}); +``` + +[`15-reductions.test.ts`](15-reductions.test.ts#L22-L34) ### 16. GPU kernels and render systems -Systems that run or draw on the GPU. To be written. +```ts +// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device +// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers +// and vectors of them; it has no lookups, change filters or commands. +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { + row[position] = Vec2.add(row[position], row[velocity]); +}); +``` + +[`16-gpu.test.ts`](16-gpu.test.ts#L17-L22) + +```ts +// A render system records draws over the GPU copy of the components its accessors name, in a world with a device. +// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and +// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw. +let pipeline: GPURenderPipeline | null = null; +let target: GPUTextureView | null = null; +const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => { + if (pipeline === null || target === null) return; + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] }); + pass.setPipeline(pipeline); + for (const table of tables) { + table.bind(pass); + pass.draw(6, table.count); + } + pass.end(); +}); +const shaderCode = Gpu.wgsl(draw); +``` + +[`16-gpu.test.ts`](16-gpu.test.ts#L24-L39) ### 17. Hierarchies -Running rows parent first, through a reference. To be written. +```ts +declare module "@adobe/ecs" { + interface Components { + // Despawned with its parent. + [parent]: { type: Entity; reference: "owner" }; + [offset]: { type: number }; + [placed]: { type: number }; + [label]: { type: string }; + } +} + +// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run: +// context.up holds what the body reads of the parent, or null for a row without one. +const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => { + row[placed] = (up === null ? 0 : up[placed]) + row[offset]; +}); +``` + +[`17-hierarchies.test.ts`](17-hierarchies.test.ts#L10-L24) ### 18. Identity and ids -How systems and keys stay the same across reloads. To be written. +```ts +// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code +// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared, +// its package's name and version, its module's path and its binding, so other threads and a module evaluated again +// by hot reload name the same one. +const explode = System.create(() => { + throw new Error("boom"); +}); + +// Reports label it by its binding, and give the id beside it to tell two of one label apart. +const failures: string[] = []; +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`); +}); +``` + +[`18-identity.test.ts`](18-identity.test.ts#L5-L17) ### 19. Threads -Sharing a world with another thread, which joins it to run systems and add observers. To be written. +```ts +// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it. +declare module "@adobe/ecs" { + interface Threads { + audio: true; + } + interface Components { + [volume]: { type: number }; + } + interface Resources { + [levels]: { type: ReadonlyMap; thread: "audio" }; + } +} + +// A system runs on the thread its objects live on: this one, writing levels, runs on audio. +const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => { + resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]); +}); + +// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after +// the world's sync points. +const heard: number[] = []; +const listen = System.create({ observer: true }, ({ resources }) => { + heard.push(resources[levels].size); +}); +``` + +[`19-threads.test.ts`](19-threads.test.ts#L8-L31) + +```ts +it("shares a world with another thread", async () => { + // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other. + const channel = new MessageChannel(); + await using world = World.create(); + world.share(channel.port1); + await using audio = World.join(channel.port2, { threads: ["audio"] }); + audio.add(listen); + await world.run(spawn); + // A joined world runs systems as the world does. + await audio.run(mix); + await until(() => heard.at(-1) === 2); + expect(heard.at(-1)).toBe(2); +}); +``` + +[`19-threads.test.ts`](19-threads.test.ts#L43-L55) From 6cf92b88e4f07bcc7e311cf6b22287b6c5c7a459 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:39:38 -0700 Subject: [PATCH 087/127] ecs: follow-up on telling the world's thread a join finished Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index d9fb7d6c..58646911 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -88,6 +88,10 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Features to discuss +- **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run + before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section + runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), + or `world.share` returning a promise of the claim. Smallest form to be decided. - **Computed index keys:** an index keyed on a value computed from a component, such as a struct field (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: From 8779f2e53c9b5e9577d02d9b1b51a9104f210b01 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:29:23 -0700 Subject: [PATCH 088/127] ecs: Archetype leaves the API; the build indexes every list of components it can see A list declared as const at module scope, or spreading such lists, gets its index before types are stripped; an array literal of keys a main-thread body passes its commands is made once, with an index. A list made at runtime keeps an index in the same property while its keys stay the same. A list naming a key twice fails the build. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/exports.test.ts | 4 +- packages/ecs/src/guide/02-entities.test.ts | 7 +- .../compiler/archetypes.test.ts | 48 +++++++ .../src/implementation/compiler/archetypes.ts | 131 ++++++++++++++++++ .../ecs/src/implementation/compiler/lists.ts | 21 ++- .../ecs/src/implementation/compiler/plugin.ts | 12 +- .../ecs/src/implementation/compiler/spawns.ts | 56 +++++++- .../implementation/compiler/transform.test.ts | 39 ++++-- .../src/implementation/compiler/transform.ts | 29 +++- .../world/change-blocks.test.ts | 7 +- .../src/implementation/world/commands.test.ts | 40 ++++-- .../world/imported-lists.test-helper.ts | 3 +- .../kernel-imports-sparks.test-helper.ts | 4 +- .../world/kernel-spawns.test.ts | 3 +- .../ecs/src/implementation/world/lists.ts | 55 ++++++-- .../implementation/world/prepared-spawn.ts | 21 +-- .../ecs/src/implementation/world/state.ts | 5 +- .../ecs/src/interface/archetype/archetype.ts | 4 - .../archetype/archetype.type-test.ts | 35 ----- .../src/interface/archetype/create.test.ts | 38 ----- .../ecs/src/interface/archetype/create.ts | 13 -- packages/ecs/src/interface/archetype/index.ts | 2 - packages/ecs/src/interface/index.ts | 1 - packages/ecs/src/interface/index.type-test.ts | 2 +- .../src/interface/registry/archetypes.test.ts | 44 ++++++ .../ecs/src/interface/registry/archetypes.ts | 31 ++++- packages/ecs/src/interface/registry/index.ts | 2 +- packages/ecs/src/interface/report/index.ts | 6 +- packages/ecs/src/interface/system/commands.ts | 2 +- .../interface/system/commands.type-test.ts | 3 +- .../src/interface/system/lists.type-test.ts | 53 +++++++ .../{archetype => system}/values-of.ts | 0 packages/ecs/src/perftest/editor.ts | 9 +- packages/ecs/src/perftest/filters.ts | 3 +- packages/ecs/src/perftest/flat.ts | 9 +- packages/ecs/src/perftest/growth.ts | 3 +- packages/ecs/src/perftest/hierarchy.ts | 11 +- packages/ecs/src/perftest/particles.ts | 9 +- packages/ecs/src/perftest/structural.ts | 15 +- packages/ecs/src/perftest/suite.ts | 5 +- packages/ecs/src/perftest/values.ts | 3 +- .../src/samples/particles/gpu/particles.ts | 10 +- .../src/samples/todo/features/todos/data.ts | 4 +- 43 files changed, 568 insertions(+), 234 deletions(-) create mode 100644 packages/ecs/src/implementation/compiler/archetypes.test.ts create mode 100644 packages/ecs/src/implementation/compiler/archetypes.ts delete mode 100644 packages/ecs/src/interface/archetype/archetype.ts delete mode 100644 packages/ecs/src/interface/archetype/archetype.type-test.ts delete mode 100644 packages/ecs/src/interface/archetype/create.test.ts delete mode 100644 packages/ecs/src/interface/archetype/create.ts delete mode 100644 packages/ecs/src/interface/archetype/index.ts create mode 100644 packages/ecs/src/interface/registry/archetypes.test.ts create mode 100644 packages/ecs/src/interface/system/lists.type-test.ts rename packages/ecs/src/interface/{archetype => system}/values-of.ts (100%) diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index e4df5ed0..e390651f 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -29,14 +29,14 @@ const install = (): void => { }; const consumer = ` -import { Archetype, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; +import { Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; export const position = Symbol("exportsTest/position"); export const element = Symbol("exportsTest/element"); export const shown = Symbol("exportsTest/shown"); declare module "@adobe/ecs" { interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } } } -export const Placed = Archetype.create([position, element]); +export const Placed = [position, element] as const; export const showing = Rule.create({ components: { with: [element] }, derive: [shown] }); export const move = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); export const all = System.create({ systems: [move] }); diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts index ba07c29e..6ec401ce 100644 --- a/packages/ecs/src/guide/02-entities.test.ts +++ b/packages/ecs/src/guide/02-entities.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Archetype, System, Vec3, World } from "../index.js"; +import { System, Vec3, World } from "../index.js"; // A component is a key whose values entities hold, declared as a resource is. export const position = Symbol(); @@ -16,8 +16,9 @@ declare module "@adobe/ecs" { } } -// An archetype names a list of components, once at module scope; it goes wherever a list of components does. -export const Particle = Archetype.create([position, velocity]); +// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever +// a list of components does. +export const Particle = [position, velocity] as const; // Systems spawn entities: name the components, then give their values. export const spawn = System.create(({ commands }) => { diff --git a/packages/ecs/src/implementation/compiler/archetypes.test.ts b/packages/ecs/src/implementation/compiler/archetypes.test.ts new file mode 100644 index 00000000..c2faedb5 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/archetypes.test.ts @@ -0,0 +1,48 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { archetypeInsertions } from './archetypes.js'; +import { insertInto } from './source-map.js'; +import { moduleOf } from './type-schemas.js'; + +/** The module as the pass leaves it without the function it appends, its failure's message, or null. */ +const passed = (code: string): string | null => { + const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts"))); + if (result === null) return null; + if ("failure" in result) return `failure: ${result.failure.message}`; + return insertInto(code, result.insertions, "/src/lists.ts").code.replace(/\nfunction archetype\$\d*\(list\) \{.*\}\n$/, ""); +}; + +const keys = `import { label, type Other } from "./keys.js";\nconst todo = Symbol();\nconst done = Symbol();\n`; + +describe("archetypeInsertions", () => { + test("gives a top-level list of keys declared as const an index, its own keys or imported", () => { + expect(passed(`${keys}export const Todo = [todo, label, done] as const;`)).toBe(`${keys}export const Todo = archetype$([todo, label, done] as const);`); + }); + test("gives an index to lists spreading lists of keys, and to lists in what the module makes as it loads", () => { + expect(passed(`${keys}const Node = [todo, done] as const;\nconst Kinds = [[...Node, label] as const, { list: [label] as const }];`)) + .toBe(`${keys}const Node = archetype$([todo, done] as const);\nconst Kinds = [archetype$([...Node, label] as const), { list: archetype$([label] as const) }];`); + }); + test("leaves a list spreading what isn't a list of keys", () => { + expect(passed(`${keys}const named = [todo];\nconst Node = [...named, done] as const;`)).toBe(null); + }); + test("leaves lists it can't tell are only keys, or that aren't declared as const", () => { + expect(passed(`${keys}const loose = [todo, done];\nconst mixed = [todo, 1] as const;\nconst named = [todo, other] as const;\nconst empty = [] as const;`)).toBe(null); + }); + test("leaves a list in a function, which isn't made once", () => { + expect(passed(`${keys}const make = () => [todo, done] as const;`)).toBe(null); + }); + test("fails for a list naming one of its module's keys twice, and leaves one naming an import twice", () => { + expect(passed(`${keys}const Todo = [todo, done, todo] as const;`)).toBe("failure: this list names todo twice; a list of components names each once"); + expect(passed(`${keys}const Labels = [label, label] as const;`)).toBe(null); + }); + test("leaves a module binding a global the appended function uses", () => { + expect(passed(`${keys}const Object = 1;\nconst Todo = [todo, done] as const;`)).toBe(null); + }); + test("appends a function giving each list its index once", () => { + const code = `${keys}const Todo = [todo, done] as const;`; + const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts"))); + const whole = result !== null && "insertions" in result ? insertInto(code, result.insertions, "/src/lists.ts").code : ""; + expect(whole.match(/function archetype\$\(list\)/g)?.length).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/archetypes.ts b/packages/ecs/src/implementation/compiler/archetypes.ts new file mode 100644 index 00000000..db2f0f80 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/archetypes.ts @@ -0,0 +1,131 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { archetypeCountName, archetypeIndexName } from '../../interface/registry/archetypes.js'; +import { declarationsName } from '../../interface/registry/ids.js'; +import type { PassResult } from './input-schemas.js'; +import type { Insertion } from './source-map.js'; +import type { Module, Node, Statement } from './type-schemas.js'; + +/** + * The function the plugin appends, as `name`, to a module with lists of keys it can read: it gives a list a + * program-wide index, by which worlds find what they resolved for it without comparing components, when every member is + * a component a module declared, each once, and returns the list. Generated code imports nothing, so it reads the + * declarations and shares the count through globals, as every copy of the package does (`registry/archetypes.ts`). + */ +export const archetypeFunction = (name: string): string => [ + `function ${name}(list) {`, + ` const declared = globalThis[Symbol.for(${JSON.stringify(declarationsName)})], index = Symbol.for(${JSON.stringify(archetypeIndexName)}), count = Symbol.for(${JSON.stringify(archetypeCountName)});`, + ` if (!declared || Object.prototype.hasOwnProperty.call(list, index) || !Object.isExtensible(list)) return list;`, + ` for (let k = 0; k < list.length; k++) { const entry = declared.get(list[k]); if (!entry || entry.kind !== "component" || list.indexOf(list[k]) < k) return list; }`, + ` const next = globalThis[count] || 0; globalThis[count] = next + 1; Object.defineProperty(list, index, { value: next });`, + ` return list;`, + `}`, +].join(""); + +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; code.includes(name); n++) name = `${base}${n}`; + return name; +}; + +type Declarations = Extract; +type Expression = NonNullable; +/** `[...] as const`. */ +type ConstList = Extract & { readonly expression: Extract }; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "type") === "string"; + +const isConstList = (node: Node): node is ConstList => { + if (node.type !== "TSAsExpression") return false; + const expression: unknown = Reflect.get(node, "expression"); + const annotation: unknown = Reflect.get(node, "typeAnnotation"); + const typeName: unknown = typeof annotation === "object" && annotation !== null ? Reflect.get(annotation, "typeName") : undefined; + return typeof expression === "object" && expression !== null && Reflect.get(expression, "type") === "ArrayExpression" + && typeof typeName === "object" && typeName !== null && Reflect.get(typeName, "type") === "Identifier" && Reflect.get(typeName, "name") === "const"; +}; + +/** Nodes whose code runs when called, not when the module loads. */ +const functions: ReadonlySet = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration", "ClassDeclaration", "ClassExpression"]); + +/** A top-level statement's variable declaration, exported or not. */ +const variablesOf = (statement: Statement): Declarations | null => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "VariableDeclaration" ? declared : null; +}; + +/** Whether a module binds at top level a global the appended function uses. */ +const bindsGlobals = (module: Module): boolean => module.program.body.some(statement => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + const names = declared?.type === "VariableDeclaration" ? declared.declarations.map(declarator => declarator.id.type === "Identifier" ? declarator.id.name : "") + : (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") ? [declared.id?.name ?? ""] + : statement.type === "ImportDeclaration" ? statement.specifiers.map(specifier => specifier.local.name) + : []; + return names.some(name => name === "Symbol" || name === "globalThis" || name === "Object"); +}); + +/** + * Gives each top-level list of keys declared `as const`, `const List = [a, b] as const`, a program-wide index: its + * members are the module's `Symbol()` constants or value imports, and the appended function indexes it once they're + * all declared components. `as const` makes it a readonly tuple, which nothing changes, as an indexed list must not. + * A list naming one of the module's keys twice fails; one naming an import twice is left without an index. + */ +export const archetypeInsertions = (module: Module): PassResult | null => { + const { code, program } = module; + if (!code.includes("as const") || bindsGlobals(module)) return null; + const keys = new Set(); + const local = new Set(); + for (const [name, imported] of module.imports) if (imported.value && imported.imported !== null) keys.add(name); + for (const statement of program.body) { + const variables = variablesOf(statement); + if (variables?.kind !== "const") continue; + for (const { id, init } of variables.declarations) { + if (id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") { + keys.add(id.name); + local.add(id.name); + } + } + } + if (keys.size === 0) return null; + /** The module's top-level constants holding lists it indexes, which other lists may spread. */ + const lists = new Set(); + const insertions: Insertion[] = []; + const name = uniqueName(code, "archetype$"); + /** Lists naming one of the module's keys twice, which fail the build. */ + const repeated: { readonly name: string; readonly at: number }[] = []; + /** Whether a list declared `as const` names only keys: the module's or imported, or spreads lists of them. */ + const indexes = (list: ConstList): boolean => { + const named: string[] = []; + for (const element of list.expression.elements) { + if (element?.type === "Identifier" && keys.has(element.name)) named.push(element.name); + else if (element?.type !== "SpreadElement" || element.argument.type !== "Identifier" || !(lists.has(element.argument.name) || module.imports.get(element.argument.name)?.value === true)) return false; + } + const twice = named.find((member, k) => named.indexOf(member) < k); + if (twice === undefined) return true; + if (local.has(twice)) repeated.push({ name: twice, at: list.start }); + return false; + }; + // Lists made when the module loads: those in functions are made on every call, so they aren't indexed. + const visit = (node: unknown, binding: string | null): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item, null); + return; + } + if (typeof node !== "object" || node === null || !isNode(node) || functions.has(node.type)) return; + if (isConstList(node) && node.expression.elements.length > 0 && indexes(node)) { + insertions.push({ at: node.start, text: `${name}(` }, { at: node.end, text: ")" }); + if (binding !== null) lists.add(binding); + return; + } + for (const key of Object.keys(node)) if (key !== "parent") visit(Reflect.get(node, key), null); + }; + for (const statement of program.body) { + const variables = variablesOf(statement); + // A list a top-level `const` holds may be spread by those after it. + if (variables?.kind === "const") for (const { id, init } of variables.declarations) visit(init, id.type === "Identifier" ? id.name : null); + else visit(statement, null); + } + const [twice] = repeated; + if (twice !== undefined) return { failure: { message: `this list names ${twice.name} twice; a list of components names each once`, position: twice.at, frames: [] }, dependencies: [] }; + if (insertions.length === 0) return null; + insertions.push({ at: code.length, text: `\n${archetypeFunction(name)}\n` }); + return { insertions, dependencies: [] }; +}; diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts index c6e1e032..39ea399b 100644 --- a/packages/ecs/src/implementation/compiler/lists.ts +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -11,7 +11,7 @@ export type ListNames = readonly string[] | { readonly error: string }; export type Lists = { /** * The keys a list names: an array literal of the kernel's module-scope identifiers, or a constant, of the kernel's - * module or imported through any re-exports and namespaces, holding an array literal or `Archetype.create` of one. + * module or imported through any re-exports and namespaces, holding an array literal. * A key of another module's list is named by the kernel's binding of the same definition, or else by the list * itself, as `List[k]`. */ @@ -56,14 +56,11 @@ const isKey = (node: Expression, module: Module): boolean => { return module.bindings.get(node.callee.name)?.kind === "function" && symbol !== undefined && symbol.type !== "SpreadElement" && isSymbolCall(symbol, module); }; -/** `Archetype.create()`, with `Archetype` imported under any name: its array literal, else null. */ -const createdList = (node: Expression, module: Module): ArrayExpression | null => { - if (node.type !== "CallExpression" || node.arguments.length !== 1) return null; - const { callee } = node; - if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier" || callee.property.name !== "create") return null; - const binding = module.bindings.get(callee.object.name); +/** A list's literal as the plugin gives it an index, `archetype$([...])` with its appended function: the literal, else null. */ +const indexedList = (node: Expression, module: Module): ArrayExpression | null => { + if (node.type !== "CallExpression" || node.arguments.length !== 1 || node.callee.type !== "Identifier" || !/^archetype\$\d*$/.test(node.callee.name)) return null; const [list] = node.arguments; - return binding?.kind === "import" && binding.imported === "Archetype" && list.type === "ArrayExpression" ? list : null; + return module.bindings.get(node.callee.name)?.kind === "function" && list.type === "ArrayExpression" ? list : null; }; const fileOf = (module: Module, kernel: Module): string => module === kernel ? "this module" : module.file; @@ -78,14 +75,14 @@ const follow = (name: string, binding: ModuleBinding, module: Module, kernel: Mo case "const": { const { init } = binding; if (init.type === "ArrayExpression") return { literal: init, module, reference }; - const created = createdList(init, module); - if (created !== null) return { literal: created, module, reference }; + const indexed = indexedList(init, module); + if (indexed !== null) return { literal: indexed, module, reference }; if (init.type === "Identifier") { const aliased = module.bindings.get(init.name); if (aliased !== undefined) return follow(init.name, aliased, module, kernel, link, reference, wanted, seen); } if (isKey(init, module)) return { key: identity }; - const declare = wanted === "list" ? "declare it as an array literal, or Archetype.create of one" : "declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"; + const declare = wanted === "list" ? "declare it as an array literal, `as const`" : "declare a key as Symbol(...), or a list as an array literal, `as const`"; return { error: `${reference} is computed when ${fileOf(module, kernel)} runs, so ${unread}; ${declare}` }; } case "import": case "importNamespace": @@ -117,7 +114,7 @@ const chain = (node: Node): readonly string[] | null => { const find = (node: Node, kernel: Module, link: Link | null, wanted: Wanted): Followed => { if (node.type === "ArrayExpression") return { literal: node, module: kernel, reference: "" }; const names = chain(node); - if (names === null) return { error: "commands and kernel declarations name a list as an array literal, or a constant holding one or Archetype.create of one" }; + if (names === null) return { error: "commands and kernel declarations name a list as an array literal, or a constant holding one" }; const [first, ...members] = names; const reference = names.join("."); const binding = kernel.bindings.get(first); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index f9cc6cfa..7dd3fa56 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -9,6 +9,7 @@ import { describeFailure } from './failure.js'; import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; +import { archetypeInsertions } from './archetypes.js'; import { augmentationInsertions, reductionsIn, registeredReductionsIn, type AugmentationHost } from './augmentations.js'; import { inputInsertions, type InputHost } from './input-schemas.js'; import { copyForImporter, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; @@ -127,9 +128,10 @@ const inlineKernels = (code: string, kernels: readonly string[], hashes: readonl const typed = /\.[cm]?tsx?$/; /** - * Registers the keys `declare module "@adobe/ecs"` blocks declare (`augmentations.ts`), and passes each system's + * Registers the keys `declare module "@adobe/ecs"` blocks declare (`augmentations.ts`), passes each system's * `create` the schema of its body's input, from the type of the body's parameter after its context - * (`input-schemas.ts`). It runs before TypeScript is stripped, which both read. + * (`input-schemas.ts`), and gives lists of keys declared `as const` their indexes (`archetypes.ts`). It runs before + * TypeScript is stripped, which all three read. */ const inputPlugin = (): Plugin => { let repeated = false; @@ -166,8 +168,8 @@ const inputPlugin = (): Plugin => { async transform(code, id) { const file = id.split("?")[0] ?? id; if (repeated || id.includes("\0") || !typed.test(file)) return null; - // Only a module declaring keys, or creating systems, has anything for either pass. - if (!(code.includes("declare module") && code.includes("@adobe/ecs")) && !(code.includes("System") && code.includes(".create("))) return null; + // Only a module declaring keys, creating systems or listing keys `as const` has anything for the passes. + if (!(code.includes("declare module") && code.includes("@adobe/ecs")) && !(code.includes("System") && code.includes(".create(")) && !code.includes("as const")) return null; const { parseAst } = await import("vite"); const resolved = async (specifier: string, importer: string) => await this.resolve(specifier, importer, { skipSelf: true }); const host: InputHost & AugmentationHost = { @@ -193,7 +195,7 @@ const inputPlugin = (): Plugin => { }, }; const module = moduleOf(file, code, host.parse(code, file)); - const results = [await augmentationInsertions(module, host), await inputInsertions(module, host)].filter(result => result !== null); + const results = [await augmentationInsertions(module, host), await inputInsertions(module, host), archetypeInsertions(module)].filter(result => result !== null); const read = [...new Set(results.flatMap(result => result.dependencies))].filter(dependency => dependency !== file); for (const dependency of read) this.addWatchFile(dependency); depend(file, read); diff --git a/packages/ecs/src/implementation/compiler/spawns.ts b/packages/ecs/src/implementation/compiler/spawns.ts index a6615a7b..2b9f645c 100644 --- a/packages/ecs/src/implementation/compiler/spawns.ts +++ b/packages/ecs/src/implementation/compiler/spawns.ts @@ -31,15 +31,15 @@ const keyOf = (property: Property | SpreadElement, module: ModuleScope, declared return module.has(key.name) && !declared.has(key.name) ? key.name : null; }; -/** Whether an add's components are a list as written: an array literal, or a module constant of one or of `Archetype.create`. */ +/** Whether an add's components are a list as written: an array literal, or a module constant of one, as the plugin may give it an index. */ const listed = (components: Expression, module: ModuleScope, declared: ReadonlySet): boolean => { if (components.type === "ArrayExpression") return true; if (components.type !== "Identifier" || declared.has(components.name)) return false; const binding = module.get(components.name); if (binding?.kind !== "const") return false; const { init } = binding; - return init.type === "ArrayExpression" || (init.type === "CallExpression" && init.callee.type === "MemberExpression" && !init.callee.computed - && init.callee.property.type === "Identifier" && init.callee.property.name === "create" && init.arguments[0]?.type === "ArrayExpression"); + return init.type === "ArrayExpression" || (init.type === "CallExpression" && init.callee.type === "Identifier" && /^archetype\$\d*$/.test(init.callee.name) + && init.arguments[0]?.type === "ArrayExpression"); }; const plainKeyFailure = "a command's keys are its components' symbols, as [key]; a plain or string key is never a component's"; @@ -113,3 +113,53 @@ export const spawnSites = (body: Node, module: ModuleScope, context: number): { visit(body.body); return { commands: reached.viaContext ? `${reached.name}.commands` : reached.name, sites }; }; + +/** An array literal of keys a command takes as its components, and the keys' names. */ +export type ListSite = { readonly start: number; readonly end: number; readonly names: readonly string[] }; + +/** Whether a module-scope name is a key: a constant of `Symbol(...)`, or an import, which the appended function checks. */ +const isKeyName = (name: string, module: ModuleScope, declared: ReadonlySet): boolean => { + if (declared.has(name)) return false; + const binding = module.get(name); + if (binding?.kind === "import") return true; + return binding?.kind === "const" && binding.init.type === "CallExpression" && binding.init.callee.type === "Identifier" && binding.init.callee.name === "Symbol"; +}; + +/** + * The array literals of keys a main-thread system body passes its own commands as components, `spawn([...])`, + * `add(entity, [...], ...)` and `remove(entity, [...])`, which the transform makes once, with an index, rather than on + * every call. A list naming a key twice fails. + */ +export const listSites = (body: Node, module: ModuleScope, context: number): readonly ListSite[] => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return []; + const reached = commandsOf(body.params[context]); + if (reached === null) return []; + const declared = new Set(); + declaredIn(body.body, name => declared.add(name)); + if (declared.has(reached.name)) return []; + const isCommands = (node: Node): boolean => reached.viaContext + ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name + && node.property.type === "Identifier" && node.property.name === "commands" + : node.type === "Identifier" && node.name === reached.name; + const sites: ListSite[] = []; + const visit = (node: Node): void => { + if (node.type === "CallExpression" && !node.optional && node.callee.type === "MemberExpression" && !node.callee.computed && !node.callee.optional + && isCommands(node.callee.object) && node.callee.property.type === "Identifier") { + const { name } = node.callee.property; + const list = name === "spawn" && node.arguments.length === 1 ? node.arguments[0] + : (name === "add" || name === "remove") && node.arguments.length >= 2 ? node.arguments[1] + : undefined; + if (list?.type === "ArrayExpression" && list.elements.length > 0) { + const names = list.elements.map(element => element?.type === "Identifier" && isKeyName(element.name, module, declared) ? element.name : null); + if (names.every((key): key is string => key !== null)) { + const repeated = names.find((key, k) => names.indexOf(key) < k); + if (repeated !== undefined) return fail(list, `this list names ${repeated} twice; a list of components names each once`); + sites.push({ start: list.start, end: list.end, names }); + } + } + } + for (const child of children(node)) visit(child); + }; + visit(body.body); + return sites; +}; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 158894c6..e30f3c6d 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -712,9 +712,9 @@ describe("context", () => { ]); }); - test("commands name lists through module constants: array literals and Archetype.create", async () => { - const kernel = await compile(`import { Archetype } from "ecs"; - const unit = Archetype.create([position, alive, score]); + test("commands name lists through module constants: array literals, and lists the build gave an index", async () => { + const kernel = await compile(`function archetype$(list) { return list; } + const unit = archetype$([position, alive, score]); const pair = [mass, score]; const kernel = System.create({ kernel: true, components: { with: [position] } }, (row, { commands }) => { const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); @@ -731,8 +731,8 @@ describe("context", () => { }); test("kernel declarations list components through module constants", async () => { - const kernel = await compile(`import { Archetype } from "ecs"; - const moving = Archetype.create([position, velocity]); + const kernel = await compile(` + const moving = [position, velocity]; const kernel = System.create({ kernel: true, components: { with: [score, ...moving] } }, row => { row[score] = row[position] + row[velocity]; });`); @@ -1051,6 +1051,25 @@ describe("commands in main-thread systems", () => { expect(calls.every(call => Array.isArray(call) && call[0] === "spawn")).toBe(true); expect(calls.length).toBe(1); }); + describe("lists of keys", () => { + const keys = "const a = Symbol(); const b = Symbol();\n"; + test("are made once, with an index, for spawns, adds and removes", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.spawn([a, b])({ [a]: 1, [b]: 2 }); commands.add(1, [b], { [b]: 3 }); commands.remove(1, [a, b]); commands.spawn([a]); });`); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed.match(/\(list\$\d+ \?\?= archetype\$\(\[[ab, ]+\]\)\)/g)).toEqual([ + "(list$0 ??= archetype$([a, b]))", "(list$1 ??= archetype$([b]))", "(list$2 ??= archetype$([a, b]))", "(list$3 ??= archetype$([a]))", + ]); + expect(transformed).toContain("function archetype$(list)"); + }); + test("leave a list naming anything but module keys as written", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { const c = a; commands.spawn([a, c]); commands.remove(1, [a, 2]); });`); + expect(result).toBe(null); + }); + test("fail the build for a list naming a key twice", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.remove(1, [a, b, a]); });`); + expect(result !== null && "failure" in result ? result.failure.message : null).toBe("this list names a twice; a list of components names each once"); + }); + }); describe("adds", () => { test("pass literal values positionally, after the entity, in source order", async () => { const { code, calls } = await spawned("let i = 0; commands.add(++i, [score, mass, ++i], { [score]: ++i, [mass]: ++i });"); @@ -1227,10 +1246,10 @@ describe("imported lists", () => { const files = { "/components.js": `export const health = "health";\nexport const armor = "armor";\nexport const tagged = "tagged";\n`, "/archetypes.js": [ - `import { Archetype } from "ecs";`, `import { health, armor, tagged } from "./components.js";`, `export const shield = "shield";`, - `export const Unit = Archetype.create([health, armor, tagged]);`, + `export const Unit = archetype$([health, armor, tagged]);`, + `function archetype$(list) { return list; }`, `export const Guard = [shield, armor];`, `export const Alias = Unit;`, `export const Computed = [health].concat([armor]);`, @@ -1291,7 +1310,7 @@ const kernel = System.create({ kernel: true, components: { with: [score, ...Guar }); test.each([ - ["computed when its module runs", "commands.spawn(Computed)({});", "commands.spawn: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"], + ["computed when its module runs", "commands.spawn(Computed)({});", "commands.spawn: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, `as const`"], ["not a const", "commands.spawn(Changing)({});", "commands.spawn: Changing isn't a module-scope const, so the compiler can't read its components"], ["holding a spread", "commands.spawn(Spread)({});", "commands.spawn: Spread names a key by something other than an identifier bound at the scope of /archetypes.js"], ["not exported", "commands.spawn(Missing)({});", "commands.spawn: Missing isn't exported by /archetypes.js"], @@ -1322,7 +1341,7 @@ const kernel = System.create({ kernel: true, components: { with: [score, ...Guar }); test.each([ - ["computed when its module runs", "commands.add(row.entity, Computed, {});", "commands.add: Computed is computed when /archetypes.js runs, so the compiler can't tell whether it's a key or a list; declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"], + ["computed when its module runs", "commands.add(row.entity, Computed, {});", "commands.add: Computed is computed when /archetypes.js runs, so the compiler can't tell whether it's a key or a list; declare a key as Symbol(...), or a list as an array literal, `as const`"], ["not a const", "commands.remove(row.entity, Changing);", "commands.remove: Changing isn't a module-scope const, so the compiler can't tell whether it's a key or a list"], ["not exported", "commands.remove(row.entity, Missing);", "commands.remove: Missing isn't exported by /archetypes.js"], ["a namespace", "commands.add(row.entity, Archetypes, 1);", "commands.add: Archetypes is a module namespace, not a key or a list of components"], @@ -1339,7 +1358,7 @@ const kernel = System.create({ kernel: true, components: { with: [score, ...Guar test("fail the build for a declaration's list computed when its module runs", async () => { expect(await failure(`import { Computed } from "./archetypes.js"; const kernel = System.create({ kernel: true, components: { with: Computed } }, (row) => { void row.entity; });`, files)) - .toBe("components.with: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); + .toBe("components.with: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, `as const`"); }); }); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 286b762a..84d6818a 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -7,7 +7,8 @@ import { fail, failureOf, type Failure } from './failure.js'; import { isPending, type Link, type Linker } from './link.js'; import { createModule, locateIn, type Module, type ModuleBinding } from './module-scope.js'; import type { PackageCopy } from './paths.js'; -import { spawnSites } from './spawns.js'; +import { archetypeFunction } from './archetypes.js'; +import { listSites, spawnSites } from './spawns.js'; import { kernelOf, type KernelRequest } from './signature.js'; import { usedBy, type Used } from './body-access.js'; import { accessText, followsAll, inOrder, resolvedDeclaration, unrequired } from './inferred.js'; @@ -232,6 +233,9 @@ export const transformKernels = async ( const terminal = uniqueName(code, "terminal$"); const prepareLookupName = uniqueName(code, "prepareLookup$"); const lookupName = uniqueName(code, "lookup$"); + const listName = uniqueName(code, "list$"); + const archetypeName = uniqueName(code, "archetype$"); + let indexing = false; let looking = false; let terminals = false; const edits: Edit[] = []; @@ -303,6 +307,17 @@ export const transformKernels = async ( // Index lookups pass their key positionally, through a list of their own (`world/prepared-lookup.ts`). const lookups = declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); const spawned = spawns?.sites ?? []; + // Lists of keys its commands take are made once, with an index, rather than on every call. + const made = new Map(); + const listed = listSites(body, module.bindings, positions.context); + for (const site of listed) { + const inside = [...spawned, ...lookups].some(other => other.start <= site.start && site.end <= other.end && !("components" in other && other.components.start === site.start)); + if (inside) continue; + const name = `${listName}${prepared.length}`; + prepared.push(`var ${name};`); + indexing = true; + made.set(site.start, { end: site.end, text: `(${name} ??= ${archetypeName}([${site.names.join(", ")}]))` }); + } for (const site of lookups) { if (spawned.some(other => other.start < site.end && site.start < other.end)) continue; const name = `${lookupName}${prepared.length}`; @@ -317,11 +332,18 @@ export const transformKernels = async ( prepared.push(site.kind === "spawn" ? spawnFunction(name, prepare, site.keys) : addFunction(name, prepareAddName, site.keys)); if (site.kind === "add") adding = true; else spawning = true; - const values = [...site.entity === null ? [] : [site.entity], site.components, ...site.values].map(value => code.slice(value.start, value.end)); + const values = [...site.entity === null ? [] : [site.entity], site.components, ...site.values].map(value => { + const list = made.get(value.start); + if (list === undefined || list.end !== value.end) return code.slice(value.start, value.end); + made.delete(value.start); + return list.text; + }); edits.push({ start: site.start, end: site.end, text: `${name}(${[spawns.commands, ...values].join(", ")})` }); } + for (const [start, { end, text }] of made) edits.push({ start, end, text }); return; } + for (const [start, { end, text }] of made) edits.push({ start, end, text }); } const inner = nested || isFunction(node); for (const child of children(node)) visit(child, inner); @@ -358,5 +380,6 @@ export const transformKernels = async ( const marking = terminals ? [`function ${terminal}(body) { const set = Symbol.for(${JSON.stringify(terminalBodiesName)}); (globalThis[set] || (globalThis[set] = new WeakSet())).add(body); return body; }`] : []; // An internal factory may have given its declaration what its body uses first, which stands. const giving = accessing ? [`function ${accessName}(made, uses) { const key = Symbol.for(${JSON.stringify(accessName$)}); const map = globalThis[key] || (globalThis[key] = new WeakMap()); if (!map.has(made)) map.set(made, uses); return made; }`] : []; - return { code: [...imports, result, ...prepared, ...marking, ...giving].join("\n"), kernels, dependencies: [...dependencies], warnings }; + const indexed = indexing ? [archetypeFunction(archetypeName)] : []; + return { code: [...imports, result, ...prepared, ...marking, ...giving, ...indexed].join("\n"), kernels, dependencies: [...dependencies], warnings }; }; diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts index d3494b25..24a8d862 100644 --- a/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Archetype } from "../../interface/archetype/archetype.js"; import { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import type { I32 } from "../../interface/math/i32/index.js"; @@ -46,9 +45,9 @@ const text = { type: "object", properties: { text: { type: "string" } } } as con /** Rows in the archetype: five chunks, the last partial. */ const rows = 4500; -const plain = Archetype.create([value, at, seen, group]); -const marked = Archetype.create([value, at, seen, group, tagged]); -const noted = Archetype.create([value, at, note]); +const plain = [value, at, seen, group] as const; +const marked = [value, at, seen, group, tagged] as const; +const noted = [value, at, note] as const; let entities: Entity[] = []; const seed = System.create({}, ({ commands }) => { diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index f3cb70b3..7900ac56 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Archetype } from "../../interface/archetype/archetype.js"; import { Report } from "../../interface/report/report.js"; import type { Essential } from "../../interface/component/value.js"; import { Entity } from "../../interface/entity/entity.js"; @@ -53,9 +52,9 @@ declare module "@adobe/ecs" { } Rule.create({ components: { with: [other, score] }, derive: [slot] }); -const unit = Archetype.create([position, score, tag]); -const made = Archetype.create([score, tag]); -const marks = Archetype.create([other, label]); +const unit = [position, score, tag] as const; +const made = [score, tag] as const; +const marks = [other, label] as const; /** Every component of these tests an entity has, or `null` if it is dead. */ const components = (world: World, entity: Entity) => @@ -79,7 +78,7 @@ const mark = System.create({ kernel: true, components: { with: [tag], not: [othe const unmark = System.create({ kernel: true, components: { with: [other] } }, (row, { commands }) => { commands.remove(row.entity, marks); }); -const defaulted = Archetype.create([score, label, tag]); +const defaulted = [score, label, tag] as const; /** A world, and a function running `body` once in a system with the world's commands, rejecting with what it threw. */ const withCommands = () => { @@ -95,7 +94,7 @@ const withCommands = () => { }; describe("spawn", () => { - it("spawns exactly the components listed, through Archetype.create, plain and inline lists", async () => { + it("spawns exactly the components listed, through lists declared as const, made at runtime and inline", async () => { const { world, run } = withCommands(); const plain: readonly Essential[] = [score, tag]; const spawned: Entity[] = []; @@ -109,6 +108,31 @@ describe("spawn", () => { expect(await components(world, spawned[2])).toEqual({ [position]: [6, 6, 6], [score]: 6 }); await world.dispose(); }); + it("spawns a list made at runtime as it is at each spawn, though it changed since the last", async () => { + const { world, run } = withCommands(); + const changing: Essential[] = [score]; + const spawned: Entity[] = []; + await run(commands => { spawned.push(commands.spawn(changing)({ [score]: 1 })); }); + changing.push(tag); + await run(commands => { spawned.push(commands.spawn(changing)({ [score]: 2 })); }); + expect(await components(world, spawned[0])).toEqual({ [score]: 1 }); + expect(await components(world, spawned[1])).toEqual({ [score]: 2, [tag]: true }); + await world.dispose(); + }); + it("spawns a list made at runtime in each world that uses it", async () => { + const first = withCommands(); + const second = withCommands(); + const shared: readonly Essential[] = [score, tag]; + const spawned: Entity[] = []; + for (let round = 0; round < 2; round++) { + await first.run(commands => { spawned.push(commands.spawn(shared)({ [score]: 1 })); }); + await second.run(commands => { spawned.push(commands.spawn(shared)({ [score]: 2 })); }); + } + expect(await components(first.world, spawned[2])).toEqual({ [score]: 1, [tag]: true }); + expect(await components(second.world, spawned[3])).toEqual({ [score]: 2, [tag]: true }); + await first.world.dispose(); + await second.world.dispose(); + }); it("starts a tag or a component whose type allows null, left out or given undefined, at true or null, never leaving it out", async () => { const { world, run } = withCommands(); const spawned: Entity[] = []; @@ -153,7 +177,7 @@ describe("spawn", () => { it("spawns into more archetypes than spawns cache, each with its values", async () => { const { world, run } = withCommands(); const lists = Array.from({ length: 16 }, (_, set): (typeof score | (typeof bits)[number])[] => [score, ...bits.filter((_, bit) => (set & (1 << bit)) !== 0)]); - const archetypes = lists.map(list => Archetype.create([...list])); + const archetypes = lists.map(list => [...list] as const); const spawned: Entity[] = []; await run(commands => { for (let i = 0; i < 64; i++) spawned.push(commands.spawn(i % 2 === 0 ? archetypes[i % 16] : [...lists[i % 16]])({ [score]: i })); @@ -316,7 +340,7 @@ describe("spawners where code can't be generated", () => { }); describe("kernels", () => { - it("spawn, add and remove through Archetype.create bindings", async () => { + it("spawn, add and remove through lists declared as const", async () => { const { world, run } = withCommands(); await run(commands => { for (let i = 0; i < 3; i++) commands.spawn([score])({ [score]: i }); }); await world.run(spawnFrom); diff --git a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts index 3452c142..ed006038 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from '../../interface/archetype/archetype.js'; /** Keys and lists another module's kernels import, in `imported-lists.test.ts`. */ @@ -15,5 +14,5 @@ declare module "@adobe/ecs" { } } -export const Spark = Archetype.create([ember, glow]); +export const Spark = [ember, glow] as const; export const Marks = [ember, marked] as const; diff --git a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts index c2531f1a..f4dd0815 100644 --- a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Vec3 } from "../../index.js"; +import { Vec3 } from "../../index.js"; export const position = Symbol("kernelImports/position"); export const spark = Symbol("kernelImports/spark"); @@ -13,4 +13,4 @@ declare module "@adobe/ecs" { /** A list a kernel imports: the build reads its components here, and compiles the kernel again when this module changes. */ -export const sparks = Archetype.create([position, spark]); +export const sparks = [position, spark] as const; diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index 19b7e39c..63908533 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Archetype } from "../../interface/archetype/archetype.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { count, read, readEach, select } from "./reads.test-helper.js"; @@ -31,7 +30,7 @@ declare module "@adobe/ecs" { } -const spawned = Archetype.create([value, made]); +const spawned = [value, made] as const; const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); diff --git a/packages/ecs/src/implementation/world/lists.ts b/packages/ecs/src/implementation/world/lists.ts index 4f672b86..e17fd6c4 100644 --- a/packages/ecs/src/implementation/world/lists.ts +++ b/packages/ecs/src/implementation/world/lists.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from '../../interface/entity/entity.js'; -import { archetypeIndexOf, labelOf } from '../../interface/registry/index.js'; +import { archetypeIndexKey, labelOf, nextArchetypeIndex } from '../../interface/registry/index.js'; import { batchOf } from './batches.js'; import { reserve } from './entities.js'; import { claim, generated, openRow, writerOf, type Column, type Positional, type SpawnBatch, type Writer } from './staging.js'; @@ -61,17 +61,26 @@ const resolve = (state: State, list: readonly unknown[]): ListShape => { }; /** - * The shape of a list of components: an archetype `Archetype.create` indexed, which never changes, by its index; any - * other list by its keys among the recent plain lists, else resolved and made recent. Lists of the same components in - * any order share a shape. + * The index a list has: the program-wide one the plugin gave it, which never changes; for one made at runtime, the + * one it keeps while its keys are those it was resolved with; else -1. Both are read from one property, so a list of + * either kind is one lookup. + */ +export const listIndexOf = (list: readonly unknown[]): number => { + const index: unknown = Reflect.get(list, archetypeIndexKey); + return typeof index === "number" ? index : isKept(index) && sameKeys(index.keys, list) ? index.index : -1; +}; + +/** + * The shape of a list of components, by its index (`listIndexOf`); for a list made at runtime without one, by its keys + * among the recent ones, else resolved, given an index it keeps and made recent. Lists of the same components in any + * order share a shape. */ export const listShapeOf = (state: State, list: readonly unknown[]): ListShape => { - const index = archetypeIndexOf(list); - // Kept small, so it inlines into the systems spawning and adding: the rest is outlined. + const index = listIndexOf(list); return (index >= 0 ? state.byIndex[index] : undefined) ?? shapeOfList(state, list, index); }; -/** A list's shape, other than an archetype's already resolved. */ +/** A list's shape, other than one already resolved by its index. */ const shapeOfList = (state: State, list: readonly unknown[], index: number): ListShape => { if (index >= 0) { const shape = resolve(state, list); @@ -79,21 +88,37 @@ const shapeOfList = (state: State, list: readonly unknown[], index: number): Lis return shape; } const { recent } = state; - const length = list.length; - for (let r = 0; r < recent.length; r++) { - const { keys } = recent[r]; - if (keys.length !== length) continue; - let same = true; - for (let k = 0; k < length && same; k++) same = keys[k] === list[k]; - if (same) return recent[r].shape; - } + for (let r = 0; r < recent.length; r++) if (sameKeys(recent[r].keys, list)) return recent[r].shape; const shape = resolve(state, list); const entry: Recent = { keys: [...list], shape }; if (recent.length < recentCount) recent.push(entry); else recent[state.nextRecent = (state.nextRecent + 1) % recentCount] = entry; + // Only a list resolved keeps an index, so copies made on every call stay among the recent ones and allocate nothing. + const kept: unknown = Reflect.get(list, archetypeIndexKey); + const own: Kept | null = isKept(kept) ? kept : kept === undefined && Object.isExtensible(list) ? { keys: entry.keys, index: -1 } : null; + if (own === null) return shape; + if (kept === undefined) Object.defineProperty(list, archetypeIndexKey, { value: own }); + // A list that changed takes a new index, since its shape may differ. + own.keys = entry.keys; + own.index = nextArchetypeIndex(); + state.byIndex[own.index] = shape; return shape; }; +/** What a list made at runtime keeps: the keys it was last resolved with, and the index it has while it holds them. */ +type Kept = { keys: readonly unknown[]; index: number }; + +/** Whether an index property holds a list's record: only this module puts an object there, and indexes are numbers. */ +const isKept = (value: unknown): value is Kept => typeof value === "object" && value !== null; + +/** Whether a list holds exactly the keys copied from one before. */ +const sameKeys = (keys: readonly unknown[], list: readonly unknown[]): boolean => { + const length = list.length; + if (keys.length !== length) return false; + for (let k = 0; k < length; k++) if (keys[k] !== list[k]) return false; + return true; +}; + /** Where a shape's spawns go, resolved once. A constant's column holds nothing, so nothing is written or read for it. */ export const writtenOf = (state: State, shape: ListShape): Written => { if (shape.written !== null) return shape.written; diff --git a/packages/ecs/src/implementation/world/prepared-spawn.ts b/packages/ecs/src/implementation/world/prepared-spawn.ts index 1447de3c..0800c321 100644 --- a/packages/ecs/src/implementation/world/prepared-spawn.ts +++ b/packages/ecs/src/implementation/world/prepared-spawn.ts @@ -4,9 +4,8 @@ import type { Essential } from '../../interface/component/value.js'; import type { Entity } from '../../interface/entity/entity.js'; import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js'; import { commandsTarget, type CommandsTarget } from './commands.js'; -import { listShapeOf, positionalOf, type ListShape, type Positional } from './lists.js'; +import { listIndexOf, listShapeOf, positionalOf, type ListShape, type Positional } from './lists.js'; import type { Key, State } from './state.js'; -import { archetypeIndexOf } from '../../interface/registry/index.js'; type TargetedCommands = Commands & { readonly [commandsTarget]: CommandsTarget }; @@ -22,17 +21,17 @@ const valuesOf = (keys: readonly Key[], values: readonly unknown[]): { [key: str /** What a site resolved for a world and shape. */ type Planned = { readonly state: State; readonly shape: ListShape }; -/** A site's plans: by archetype index, for the world that last used it, and by shape, which belongs to one world and is held only while it lives. */ -type Plans

= { readonly byIndex: (P | undefined)[]; readonly byShape: WeakMap }; +/** A site's plans, each for the world that last used it: by its list's index, and by its shape's index in that world. */ +type Plans

= { readonly byIndex: (P | undefined)[]; readonly byShape: (P | undefined)[] }; -const createPlans =

(): Plans

=> ({ byIndex: [], byShape: new WeakMap() }); +const createPlans =

(): Plans

=> ({ byIndex: [], byShape: [] }); /** * A site's plan for a world and list: a kept one, else one `make` makes for its shape and the site's keys, kept from * then on. `make` is one function per kind of site, so finding a plan allocates nothing. */ const planFor =

(plans: Plans

, state: State, list: readonly unknown[], site: readonly Key[], make: (state: State, shape: ListShape, site: readonly Key[]) => P): P => { - const index = archetypeIndexOf(list); + const index = listIndexOf(list); const indexed = index >= 0 ? plans.byIndex[index] : undefined; // Kept small, so it inlines into the systems spawning and adding: the rest is outlined. return indexed !== undefined && indexed.state === state ? indexed : planned(plans, state, list, site, make, index); @@ -41,12 +40,14 @@ const planFor =

(plans: Plans

, state: State, list: readonl /** A site's plan other than one kept by archetype index for this world. */ const planned =

(plans: Plans

, state: State, list: readonly unknown[], site: readonly Key[], make: (state: State, shape: ListShape, site: readonly Key[]) => P, index: number): P => { const shape = listShapeOf(state, list); - let plan = plans.byShape.get(shape); - if (plan === undefined) { + let plan = plans.byShape[shape.index]; + if (plan === undefined || plan.shape !== shape) { plan = make(state, shape, site); - plans.byShape.set(shape, plan); + plans.byShape[shape.index] = plan; } - if (index >= 0) plans.byIndex[index] = plan; + // A list made at runtime has an index once resolved. + const kept = index >= 0 ? index : listIndexOf(list); + if (kept >= 0) plans.byIndex[kept] = plan; return plan; }; diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index f9511a86..2ac3b08e 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -139,8 +139,9 @@ export type State = { readonly batches: SpawnBatch[]; readonly batchByKey: Map; /** - * Lists of components commands named, by their sorted ids; archetypes by their program-wide index, and the last - * eight plain lists, which find them first. + * Lists of components commands named, by their sorted ids; lists the build gave indexes, and those made at runtime + * that keep one while their keys stay the same, by their index; and the last eight made at runtime, by their keys, + * which find copies of them before they keep their own (`lists.ts`). */ readonly lists: Map; readonly byIndex: ListShape[]; diff --git a/packages/ecs/src/interface/archetype/archetype.ts b/packages/ecs/src/interface/archetype/archetype.ts deleted file mode 100644 index 85052666..00000000 --- a/packages/ecs/src/interface/archetype/archetype.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export type Archetype = never; - -export * as Archetype from './index.js'; diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts deleted file mode 100644 index 0bb3fe90..00000000 --- a/packages/ecs/src/interface/archetype/archetype.type-test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; -import { Component } from '../component/component.js'; -import { Archetype, System } from "@adobe/ecs"; - -const position = Symbol("archetypeTypeTest/position"); -const velocity = Symbol("archetypeTypeTest/velocity"); -const scale = Symbol("archetypeTypeTest/scale"); -declare module "@adobe/ecs" { - interface Components { - [position]: { type: number }; - [velocity]: { type: number }; - } - interface Resources { - [scale]: { type: number }; - } -} - -type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -const expectType = (): T | undefined => undefined; - -// `create` returns its argument, typed as the tuple it was given. -export const particle = Archetype.create([position, velocity]); -expectType>(); -export const one: readonly Component.Key[] = particle; -// A plain readonly list of components is an archetype too. -export const plain = [position] as const; -export const moveOver = System.create({ components: { with: [...plain] } }, row => { row[position] += 1; }); -// @ts-expect-error a resource is never in an archetype -Archetype.create([position, scale]); -// @ts-expect-error nor is a string -Archetype.create(["position"]); -// Archetypes go wherever component lists do. -export const move = System.create({ components: { with: [...particle] } }, row => { row[position] += row[velocity]; }); -export const read = System.create({ observer: true, components: { with: [...particle] } }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/interface/archetype/create.test.ts b/packages/ecs/src/interface/archetype/create.test.ts deleted file mode 100644 index fa5c13e5..00000000 --- a/packages/ecs/src/interface/archetype/create.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, expect, it } from "vitest"; -import { archetypeIndexOf } from "../registry/index.js"; -import { Archetype } from "./archetype.js"; - -const position = Symbol("archetypeCreateTest/position"); -const velocity = Symbol("archetypeCreateTest/velocity"); -declare module "@adobe/ecs" { - interface Components { - [position]: { type: number }; - [velocity]: { type: number }; - } -} - -describe("Archetype.create", () => { - it("returns its argument, with a program-wide index of its own", () => { - const list = [position, velocity] as const; - const particle = Archetype.create(list); - const other = Archetype.create([velocity]); - expect(particle).toBe(list); - expect(particle).toEqual([position, velocity]); - expect(archetypeIndexOf(particle)).toBeGreaterThanOrEqual(0); - expect(archetypeIndexOf(other)).toBeGreaterThan(archetypeIndexOf(particle)); - expect(Object.keys(particle)).toEqual(["0", "1"]); - }); - it("keeps the index of a list given again", () => { - const particle = Archetype.create([position]); - const index = archetypeIndexOf(particle); - expect(Archetype.create(particle)).toBe(particle); - expect(archetypeIndexOf(particle)).toBe(index); - }); - it("gives a plain list no index", () => { - expect(archetypeIndexOf([position])).toBe(-1); - }); - it("throws for a component listed twice", () => { - expect(() => Archetype.create([position, velocity, position])).toThrow(`An archetype lists "archetypeCreateTest/position" twice.`); - }); -}); diff --git a/packages/ecs/src/interface/archetype/create.ts b/packages/ecs/src/interface/archetype/create.ts deleted file mode 100644 index 238645fc..00000000 --- a/packages/ecs/src/interface/archetype/create.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from '../component/component.js'; -import { indexArchetype } from '../registry/archetypes.js'; - -/** - * Declares an archetype, the components an entity has, listed once each: returns `components`, typed as the tuple - * given, with a program-wide index the world finds its storage by. It goes wherever a list of components does, as any - * readonly list of components does. Throws for a component listed twice. - */ -export const create = (components: C): C => { - indexArchetype(components); - return components; -}; diff --git a/packages/ecs/src/interface/archetype/index.ts b/packages/ecs/src/interface/archetype/index.ts deleted file mode 100644 index 08656ff1..00000000 --- a/packages/ecs/src/interface/archetype/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './create.js'; diff --git a/packages/ecs/src/interface/index.ts b/packages/ecs/src/interface/index.ts index f6c0066c..a60a0fbc 100644 --- a/packages/ecs/src/interface/index.ts +++ b/packages/ecs/src/interface/index.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { Archetype } from './archetype/archetype.js'; export { Component } from './component/component.js'; export { Entity } from './entity/entity.js'; export { Finalizer } from './finalizer/finalizer.js'; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts index a0c0e8ce..63e2159a 100644 --- a/packages/ecs/src/interface/index.type-test.ts +++ b/packages/ecs/src/interface/index.type-test.ts @@ -40,7 +40,7 @@ export type NoThread = Ecs.Thread; export type NoCadence = Ecs.Cadence; // @ts-expect-error a rule is the value Rule.create returns: ReturnType export const noRule: Rule = Rule.create({ components: { with: [position] }, derive: [slot] }); -// @ts-expect-error an archetype is a readonly list of components +// @ts-expect-error an archetype is a list of components declared as const export const noArchetype: Ecs.Archetype = [position]; // @ts-expect-error a trait's key is keyof Traits export type NoTraitKey = Ecs.Trait.Key; diff --git a/packages/ecs/src/interface/registry/archetypes.test.ts b/packages/ecs/src/interface/registry/archetypes.test.ts new file mode 100644 index 00000000..d20a0a16 --- /dev/null +++ b/packages/ecs/src/interface/registry/archetypes.test.ts @@ -0,0 +1,44 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { archetypeIndexOf, indexArchetype } from "./archetypes.js"; + +const position = Symbol(); +const velocity = Symbol(); +const gravity = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } + interface Resources { + [gravity]: { type: number }; + } +} + +/** Lists the build gives indexes: top-level lists of components declared as const. */ +const Particle = [position, velocity] as const; +const Moving = [velocity] as const; +/** A list naming a resource isn't a list of components, so it has no index. */ +const WithResource = [position, gravity] as const; + +describe("archetype indexes", () => { + it("are given by the build to each top-level list of components declared as const", () => { + expect(archetypeIndexOf(Particle)).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(Moving)).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(Moving)).not.toBe(archetypeIndexOf(Particle)); + expect(Object.keys(Particle)).toEqual(["0", "1"]); + }); + it("aren't given to a list made at runtime, or one naming anything but components", () => { + expect(archetypeIndexOf([position, velocity])).toBe(-1); + expect(archetypeIndexOf(WithResource)).toBe(-1); + }); + it("are given to the package's own lists once, and never twice", () => { + const list = [position]; + indexArchetype(list); + const index = archetypeIndexOf(list); + indexArchetype(list); + expect(index).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(list)).toBe(index); + expect(() => indexArchetype([position, velocity, position])).toThrow("twice"); + }); +}); diff --git a/packages/ecs/src/interface/registry/archetypes.ts b/packages/ecs/src/interface/registry/archetypes.ts index 2f829628..6425a00e 100644 --- a/packages/ecs/src/interface/registry/archetypes.ts +++ b/packages/ecs/src/interface/registry/archetypes.ts @@ -1,23 +1,42 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { labelOf } from './names.js'; -/** Where an archetype holds its program-wide index: a property no other code names. */ -const indexKey = Symbol("archetypeIndex"); +/** + * The `Symbol.for` names of the property holding a list's program-wide archetype index, and of the global count of + * indexes given, which code the plugin generates and every copy of the package share. Their version changes with the + * format. + */ +export const archetypeIndexName = "@adobe/ecs/archetype@1"; +export const archetypeCountName = "@adobe/ecs/archetypes@1"; -let next = 0; +/** + * The property a list holds its index in: a number for a list the plugin or the package indexed, which never changes; + * the world's record of a list made at runtime, which it checks against the list's keys (`world/lists.ts`). + */ +export const archetypeIndexKey: symbol = Symbol.for(archetypeIndexName); +const indexKey = archetypeIndexKey; +const countKey = Symbol.for(archetypeCountName); /** * Gives a list of components a program-wide index, once, after checking it names no component twice. Throws for a - * component listed twice. + * component listed twice. The package's own lists take it here; the plugin gives consumers' theirs. */ export const indexArchetype = (components: readonly unknown[]): void => { for (let k = 1; k < components.length; k++) { if (components.indexOf(components[k]) < k) throw new Error(`An archetype lists "${labelOf(components[k])}" twice.`); } - if (!Reflect.has(components, indexKey)) Object.defineProperty(components, indexKey, { value: next++ }); + if (typeof Reflect.get(components, indexKey) !== "number") Object.defineProperty(components, indexKey, { value: nextArchetypeIndex() }); +}; + +/** The next program-wide archetype index, from the count every copy of the package and generated code share. */ +export const nextArchetypeIndex = (): number => { + const count: unknown = Reflect.get(globalThis, countKey); + const next = typeof count === "number" ? count : 0; + Reflect.set(globalThis, countKey, next + 1); + return next; }; -/** The program-wide index `Archetype.create` gave a list, or -1 for a list it wasn't given. */ +/** The program-wide index a list was given, or -1 for one never given one, such as a list made at runtime. */ export const archetypeIndexOf = (components: readonly unknown[]): number => { const index: unknown = Reflect.get(components, indexKey); return typeof index === "number" ? index : -1; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts index 1529e034..8c2bd2e8 100644 --- a/packages/ecs/src/interface/registry/index.ts +++ b/packages/ecs/src/interface/registry/index.ts @@ -24,7 +24,7 @@ export type { Leaf } from '../system/leaf.js'; export type { Composite } from '../system/composite.js'; export type { Rule } from '../rule/created.js'; export type { Finalizer } from '../finalizer/created.js'; -export { archetypeIndexOf } from './archetypes.js'; +export { archetypeIndexKey, archetypeIndexOf, nextArchetypeIndex } from './archetypes.js'; export { indexDefinitionOf, indexOfLookup, lookupKeysOf, orderKeysOf, watchedBy, watchesEntries } from './indexes.js'; export type { Created as IndexDefinition } from '../indexes/created.js'; export type { Declaration as IndexDeclaration } from '../indexes/declaration.js'; diff --git a/packages/ecs/src/interface/report/index.ts b/packages/ecs/src/interface/report/index.ts index ac4007f4..6f65dbe5 100644 --- a/packages/ecs/src/interface/report/index.ts +++ b/packages/ecs/src/interface/report/index.ts @@ -1,9 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { create as createArchetype } from '../archetype/create.js'; +import { indexArchetype } from '../registry/archetypes.js'; import { run } from './components.js'; export { errors, run } from './components.js'; export type { Failure, Warning } from './failure.js'; /** A report's archetype: the world gives every run and tick one, which gains `errors` when something went wrong. */ -export const Archetype = createArchetype([run]); +export const Archetype = [run] as const; +// The package's own lists are built without the plugin, so they take their indexes here. +indexArchetype(Archetype); diff --git a/packages/ecs/src/interface/system/commands.ts b/packages/ecs/src/interface/system/commands.ts index 6269d7b2..ce195363 100644 --- a/packages/ecs/src/interface/system/commands.ts +++ b/packages/ecs/src/interface/system/commands.ts @@ -2,7 +2,7 @@ import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Essential } from '../component/value.js'; -import type { ValuesOf } from '../archetype/values-of.js'; +import type { ValuesOf } from './values-of.js'; /** The value an `add` of one component takes: none for a tag. */ type ValueArgument = Component.Value extends true ? [value?: true] : [value: Component.Value]; diff --git a/packages/ecs/src/interface/system/commands.type-test.ts b/packages/ecs/src/interface/system/commands.type-test.ts index c8d8e2bc..127eebd8 100644 --- a/packages/ecs/src/interface/system/commands.type-test.ts +++ b/packages/ecs/src/interface/system/commands.type-test.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Resource } from '../resource/resource.js'; import { Component } from '../component/component.js'; -import { Archetype } from '../archetype/archetype.js'; import type { Entity } from '../entity/entity.js'; import type { Commands } from './commands.js'; @@ -27,7 +26,7 @@ declare module '@adobe/ecs' { } } -const particle = Archetype.create([position, velocity, label, player]); +const particle = [position, velocity, label, player] as const; export const issue = (commands: Commands, entity: Entity): void => { // `spawn` is curried: the components first, then their values, giving an entity. diff --git a/packages/ecs/src/interface/system/lists.type-test.ts b/packages/ecs/src/interface/system/lists.type-test.ts new file mode 100644 index 00000000..c3e37b94 --- /dev/null +++ b/packages/ecs/src/interface/system/lists.type-test.ts @@ -0,0 +1,53 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component } from '../component/component.js'; +import { Entity, Index, System } from "@adobe/ecs"; + +const position = Symbol("listsTypeTest/position"); +const velocity = Symbol("listsTypeTest/velocity"); +const scale = Symbol("listsTypeTest/scale"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } + interface Resources { + [scale]: { type: number }; + } +} + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +const expectType = (): T | undefined => undefined; + +// A list of components declared `as const` is a tuple, an archetype wherever component lists go. +export const Particle = [position, velocity] as const; +expectType>(); +export const one: readonly Component.Key[] = Particle; +export const move = System.create({ components: { with: [...Particle] } }, row => { row[position] += row[velocity]; }); +export const read = System.create({ observer: true, components: { with: [...Particle] } }, row => { void row[velocity]; }); + +// Declared without `as const`, a list's keys widen to `symbol`, which no list of components takes. +const loose = [position, velocity]; +expectType>(); +// @ts-expect-error so it's never a list of components +export const looseList: readonly Component.Key[] = loose; +// @ts-expect-error nor an index's +export const byLoose = Index.create({ key: [position], components: { with: loose } }); + +/** A list whose components are known only at runtime says so by its type. */ +const runtime: readonly (typeof position | typeof velocity)[] = Particle; + +export const spawn = System.create(({ commands }) => { + commands.spawn(Particle)({ [position]: 1, [velocity]: 2 }); + // @ts-expect-error a tuple's values are required + commands.spawn(Particle)({ [position]: 1 }); + commands.spawn([position, velocity])({ [position]: 1, [velocity]: 2 }); + // @ts-expect-error a list declared without `as const` can't be spawned + commands.spawn(loose); + // @ts-expect-error nor added + commands.add(Entity.none, loose, {}); + // A runtime list takes any of its values. + commands.spawn(runtime)({ [position]: 1 }); + commands.add(Entity.none, Particle, { [position]: 1, [velocity]: 2 }); + // @ts-expect-error a resource is never in a list of components + commands.spawn([position, scale]); +}); diff --git a/packages/ecs/src/interface/archetype/values-of.ts b/packages/ecs/src/interface/system/values-of.ts similarity index 100% rename from packages/ecs/src/interface/archetype/values-of.ts rename to packages/ecs/src/interface/system/values-of.ts diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts index 4580d0ec..6f967bff 100644 --- a/packages/ecs/src/perftest/editor.ts +++ b/packages/ecs/src/perftest/editor.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import { Entity } from "../interface/entity/entity.js"; import { Index } from "../interface/indexes/index-type.js"; import { workers } from "../interface/resources/workers.js"; @@ -38,10 +37,10 @@ declare module "@adobe/ecs" { } const node = [localX, localY, worldX, worldY, opacity, worldOpacity, paintDepth] as const; -const Document = Archetype.create([...node, group]); -const Group = Archetype.create([...node, parent, group]); -const Shape = Archetype.create([...node, parent, shape]); -const Image = Archetype.create([...node, parent, image]); +const Document = [...node, group] as const; +const Group = [...node, parent, group] as const; +const Shape = [...node, parent, shape] as const; +const Image = [...node, parent, image] as const; const byPaintDepth = Index.create({ key: [paintDepth], sorted: true }); const childrenOf = Index.create({ key: [parent] }); diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts index 6629304a..e0fee4b0 100644 --- a/packages/ecs/src/perftest/filters.ts +++ b/packages/ecs/src/perftest/filters.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import type { I32 } from "../interface/math/i32/index.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; @@ -32,7 +31,7 @@ declare module "@adobe/ecs" { } const rows = 100_000; -const filtered = Archetype.create([value, other, at, out0, out1, out2, out3]); +const filtered = [value, other, at, out0, out1, out2, out3] as const; const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(filtered)({ [value]: 0, [other]: 0, [at]: i, [out0]: 0, [out1]: 0, [out2]: 0, [out3]: 0 }); diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts index 45715f9c..9d5edfb0 100644 --- a/packages/ecs/src/perftest/flat.ts +++ b/packages/ecs/src/perftest/flat.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { System } from "../interface/system/system.js"; -import { Archetype } from '../interface/archetype/archetype.js'; import type { F32, U32 } from '../interface/math/index.js'; // Mirrors adobe/data's ecs2 `create` perftest (packages/data/src/perftest/ecs2-perf.ts): 1M particles of eight scalar @@ -36,10 +35,10 @@ declare module "@adobe/ecs" { const scalars = [color, mass, positionX, positionY, positionZ, velocityX, velocityY, velocityZ] as const; /** Entity i goes to archetype i % 4, like ecs2's seedSingle. */ const archetypes = [ - Archetype.create([...scalars, visible, enabled]), - Archetype.create([...scalars, visible]), - Archetype.create([...scalars, enabled]), - Archetype.create(scalars), + [...scalars, visible, enabled] as const, + [...scalars, visible] as const, + [...scalars, enabled] as const, + scalars, ] as const; /** Spawns with values passed inline. */ diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 454d9e91..28a41ecc 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import { System } from "../interface/system/system.js"; import type { F32 } from "../interface/math/index.js"; @@ -30,7 +29,7 @@ declare module "@adobe/ecs" { const tags = [g0, g1, g2, g3, g4, g5, g6] as const; /** `value` with each of the first 100 sets of the seven tags, by their bits. */ -const hundred = Array.from({ length: 100 }, (_, bits) => Archetype.create([value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); +const hundred = Array.from({ length: 100 }, (_, bits) => [value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)] as const); /** Spawns `count` entities into each of the hundred archetypes. */ export const fillEach = System.create({}, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 611ce5eb..bfaf00f2 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; import type { F32 } from "../interface/math/index.js"; @@ -19,9 +18,9 @@ declare module "@adobe/ecs" { } -const root = Archetype.create([local, world]); -const child = Archetype.create([local, world, parent]); -const taggedChild = Archetype.create([local, world, parent, leaf]); +const root = [local, world] as const; +const child = [local, world, parent] as const; +const taggedChild = [local, world, parent, leaf] as const; /** Each row's world value is its parent's plus its own. */ export const propagate = System.create({ @@ -94,8 +93,8 @@ declare module "@adobe/ecs" { } -const gpuRoot = Archetype.create([offset, placed]); -const gpuChild = Archetype.create([offset, placed, parent]); +const gpuRoot = [offset, placed] as const; +const gpuChild = [offset, placed, parent] as const; /** `propagate` in 4-byte values, on the GPU. */ export const place = System.create({ diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 996e66c9..7b00ceae 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; import type { F32, U32 } from "../interface/math/index.js"; @@ -31,10 +30,10 @@ declare module "@adobe/ecs" { /** Interleaved like ecs2's seedSingle: entity i goes to archetype i % 4. */ const archetypes = [ - Archetype.create([color, mass, position, velocity, visible, enabled]), - Archetype.create([color, mass, position, velocity, visible]), - Archetype.create([color, mass, position, velocity, enabled]), - Archetype.create([color, mass, position, velocity]), + [color, mass, position, velocity, visible, enabled] as const, + [color, mass, position, velocity, visible] as const, + [color, mass, position, velocity, enabled] as const, + [color, mass, position, velocity] as const, ] as const; export const spawn = System.create({}, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index 271fb30f..1aaf4c0d 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import type { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; import type { F32, U32 } from "../interface/math/index.js"; @@ -39,16 +38,16 @@ declare module "@adobe/ecs" { const seeded = [ - Archetype.create([index, value]), - Archetype.create([index, value, source1]), - Archetype.create([index, value, source2]), - Archetype.create([index, value, source1, source2]), + [index, value] as const, + [index, value, source1] as const, + [index, value, source2] as const, + [index, value, source1, source2] as const, ] as const; -const made = Archetype.create([index, value, spawned]); +const made = [index, value, spawned] as const; /** Every set of the four tags `t0` to `t3`, by its bits. */ const tags = [t0, t1, t2, t3] as const; -const sixteen = Array.from({ length: 16 }, (_, bits) => Archetype.create([index, value, spawned, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); -const several = Archetype.create([flag, amount, extra]); +const sixteen = Array.from({ length: 16 }, (_, bits) => [index, value, spawned, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)] as const); +const several = [flag, amount, extra] as const; const counted = { type: "object", properties: { count: { type: "integer" } } } as const; diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index b31bc5e4..90d5d147 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import { Entity } from "../interface/entity/entity.js"; import type { I32 } from "../interface/math/i32/index.js"; import type { Schema } from "../interface/schema/schema.js"; @@ -43,8 +42,8 @@ declare module "@adobe/ecs" { } -const moving = Archetype.create([position, velocity, index]); -const following = Archetype.create([position, velocity, index, target]); +const moving = [position, velocity, index] as const; +const following = [position, velocity, index, target] as const; /** `count` entities, each targeting the one spawned before it. */ export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 8bb6b7b1..de87d372 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; @@ -28,7 +27,7 @@ declare module "@adobe/ecs" { const number = { type: "number" } as const; const poseSchema = { type: "object", properties: { tx: number, ty: number, rotation: number, sx: number, sy: number }, required: ["tx", "ty", "rotation", "sx", "sy"] } as const; -const Node = Archetype.create([pose, shown, extent, order]); +const Node = [pose, shown, extent, order] as const; const rows = 100_000; diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 5e8e0be5..8dc445e5 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, type F32, Gpu, System, type U32, Vec3 } from '../../../index.js'; +import { type F32, Gpu, System, type U32, Vec3 } from '../../../index.js'; // The perf particles (src/perftest/particles.ts) on screen: 1M particles over four archetypes by visible and // enabled, with screen-space positions and velocities in pixels per frame. `move` is the perf move kernel, on the GPU. @@ -24,10 +24,10 @@ declare module '@adobe/ecs' { /** Entity i goes to archetype i % 4. */ const archetypes = [ - Archetype.create([color, mass, position, velocity, visible, enabled]), - Archetype.create([color, mass, position, velocity, visible]), - Archetype.create([color, mass, position, velocity, enabled]), - Archetype.create([color, mass, position, velocity]), + [color, mass, position, velocity, visible, enabled] as const, + [color, mass, position, velocity, visible] as const, + [color, mass, position, velocity, enabled] as const, + [color, mass, position, velocity] as const, ] as const; /** A deterministic sequence in [0, 1), so every mode starts from the same particles. */ diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts index 1349d2cf..a491e5a8 100644 --- a/packages/ecs/src/samples/todo/features/todos/data.ts +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Index } from "../../../../index.js"; +import { Index } from "../../../../index.js"; /** A todo's text. */ export const label = Symbol(); @@ -17,7 +17,7 @@ declare module "@adobe/ecs" { } } -export const Todo = Archetype.create([todo, label, done, order]); +export const Todo = [todo, label, done, order] as const; /** Every todo, in list order. */ export const ordered = Index.create({ key: [order], sorted: true, components: { with: [todo] } }); From 2180ed9e13daecc137530e1ef505b2475e869883 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:29:23 -0700 Subject: [PATCH 089/127] ecs: docs describe archetypes as lists declared as const, which the build indexes Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 6 +++--- packages/ecs/src/guide/CLAUDE.md | 2 +- packages/ecs/src/guide/guide.md | 7 ++++--- packages/ecs/src/implementation/compiler/subset.md | 2 +- packages/ecs/src/implementation/world/perf.md | 6 ++++++ packages/ecs/threads.md | 2 +- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 4ed36a45..71d2fe13 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -71,7 +71,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`Changed` is conservative:** it never misses a change but may report a component whose value is unchanged. Consumers must treat it as "may have changed", which keeps early cutoff a pure optimization. - **System bodies can't carry state from one row to the next.** Cross-row results (sums, counts) need an explicit reduction construct, so work can later be split without changing semantics. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. -- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. +- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Rule`, as `Component`, `Resource` and `Gpu`, names only a namespace. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype is a list of components declared `as const`. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails then is thrown once, from that read, and given as the reason the key's uses fail. A library's shipped build records its keys the same way. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. @@ -90,7 +90,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, whatever other keys have, so a label never changes as modules load; an error about an id names the id too. Labels are for display; nothing looks a key up by its label. -- **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. +- **Archetypes:** an archetype is a list of components declared `as const`, a readonly tuple, which goes wherever component lists do (spawns, adds and removes, systems, indexes, rules, finalizers). There's no constructor: the build gives every list it can see an index. Before TypeScript is stripped it wraps each list declared `as const` that the module evaluates as it loads, outside functions, whose members are its `Symbol()` keys, imports or spreads of such lists, as `archetype$([...] as const)` (`compiler/archetypes.ts`). After, it hoists each array literal of keys a main-thread body passes its commands into a module variable made once, `(list$0 ??= archetype$([a, b]))`, so the call allocates nothing (`compiler/spawns.ts`). The appended function, which imports nothing, gives a list a program-wide index in the property `Symbol.for("@adobe/ecs/archetype@1")`, counted by a global every copy of the package shares, when every member is a component a module declared, each once, and the list is extensible; worlds find what they resolved for it by that index without comparing components. A list naming one of its module's keys twice fails the build. Without `as const`, a list's keys widen to `symbol`, which no list of components takes, so the types require it. A list made at runtime is resolved by its keys among the eight recent ones, and once resolved keeps a record of its keys and an index in the same property, which holds while its keys stay the same, since such a list may change. The package's own lists, built without the plugin, take their indexes from `indexArchetype`. A resource or string in a list is a type error. The kernel compiler reads an array literal, or a constant holding one, indexed or not, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts export const follow = System.create({ @@ -132,7 +132,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in an observer or render system. No component may be named `entity`. - `context` holds each kind under its own key: `resources` (every resource, assignable but reduced and world-written ones), `lookup` (functions by name, when declared), `reduce` (contribution functions by reduced resource) and `up` (with `up` declared). It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. + - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. - **Composites** flatten their members. diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md index e45d2e2d..06342577 100644 --- a/packages/ecs/src/guide/CLAUDE.md +++ b/packages/ecs/src/guide/CLAUDE.md @@ -29,5 +29,5 @@ code it shows: every excerpt comes from a file here that runs as a test, so the ## Code conventions - Keys are `Symbol()` with no description: the build's id names each by its binding. -- Archetypes are types, so their names are PascalCase: `const Particle = Archetype.create([position, velocity])`. +- Archetypes are types, so their names are PascalCase: `const Particle = [position, velocity] as const`. - Use the public API only, as a consumer imports it, not internal modules. Read a world through observer systems. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 861c24d9..e2eec1e4 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -129,8 +129,9 @@ declare module "@adobe/ecs" { } } -// An archetype names a list of components, once at module scope; it goes wherever a list of components does. -export const Particle = Archetype.create([position, velocity]); +// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever +// a list of components does. +export const Particle = [position, velocity] as const; // Systems spawn entities: name the components, then give their values. export const spawn = System.create(({ commands }) => { @@ -162,7 +163,7 @@ it("spawns entities with components", async () => { expect(particles).toBe(1); ``` -[`02-entities.test.ts`](02-entities.test.ts#L6-L49) +[`02-entities.test.ts`](02-entities.test.ts#L6-L50) ### 3. Systems diff --git a/packages/ecs/src/implementation/compiler/subset.md b/packages/ecs/src/implementation/compiler/subset.md index 83784ce4..92de16f5 100644 --- a/packages/ecs/src/implementation/compiler/subset.md +++ b/packages/ecs/src/implementation/compiler/subset.md @@ -62,7 +62,7 @@ call = Math-function | helper | lookup | command | reducer - **Commands:** `context.commands.spawn(components)({ [component]: value, ... })`, `despawn(entity)`, `add(entity, component, value)` or `add(entity, components, { ... })`, and `remove(entity, component)` or `remove(entity, components)`, with component keys bound at module scope. A list of components is an array literal of - them, or a constant holding one or `Archetype.create` of one, in the kernel's module or imported, also through + them, or a constant holding one (`as const`), in the kernel's module or imported, also through re-exports and namespaces, its keys read in the scope of the module that declares it; a component it lists that a command gives no value starts at its start. A list the build can't read, such as one computed when its module runs, fails the build at the command. `add` and `remove` take a name as one key when its definition is `Symbol(...)`, as a diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index 015ec12e..26b54c0e 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -306,6 +306,12 @@ move, chromium, by worker count and entity count (a quarter of the entities move ## Tried and not kept +- **Lists made at runtime finding their shape through a `WeakMap` by list, or through a record of their own beside the + index property:** with `Archetype.create` gone, `spawn sixteen archetypes`, whose lists are built by `Array.from`, + took 9.8 ms and 6.9 ms against 3.96 with them indexed by hand; a microbenchmark gave 43 ns a lookup for the second + property against 5.4 for an index. The record now lives in the index property itself, 12 ns checked: 4.37 ms, the + rest the check of the list's keys that a list made at runtime needs, since it may change. Worth revisiting if a way + to know a runtime list is unchanged without reading its keys appears. - **Observer posts without allocating per post** (a reused list of chosen posts, no closure for posting, the block's cleared words a constant list, settling over reused scratch rather than a copy of the posts): `joined observer resource` 7.2–7.6 µs a tick against 6.4–8.3 without, `joined observer 10K` 0.52–0.69 ms against 0.51–0.76, within noise. Worth revisiting once posting shows in a profile of a world with many observer systems on joined threads. - **Observer factories: a count or selection with `where` tracked through an index without a key,** every row read through `at` and tested after any change: `count 1M changed factory` 23.2 ms; a sorted index on the `where` key, each entry looking up its range, takes 42 µs. Kept for selections with a page and no `order`, which stop at the page's end in entity order. - **Observer factories: descending pages finding each run of equal keys by galloping back from its last row,** three reads of most rows: `select page 100K factory` 23 µs a change; reading each row once, holding a run until its key changes, 16 µs. diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 95ee2b91..8f9f8268 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -53,7 +53,7 @@ Referenced from `design.md`. the id once by message the first time; the world maps (thread, number) to its own. The world holds runs whose slots use a number it can't map yet, in order, until that message arrives. A run of an id the world never imported rejects. -- **Archetypes are lists of keys.** The index `Archetype.create` gives a list is for its own thread's lookups; +- **Archetypes are lists of keys.** The index the build or a world gives a list is for its own thread's lookups; threads agree on an archetype through the world's numbers for its components. Commands and staged spawn batches carry those numbers, never archetype indexes. - **Inline is the same layout.** With every logical thread filled by the world's thread, storage and code paths are From 025ead6becd407c477e515f9c55d867dd96f808a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 22:35:43 -0700 Subject: [PATCH 090/127] ecs: the test of lists left as written checks the code, since every system's body is now edited for its access Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/compiler/transform.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index e30f3c6d..01946286 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1063,7 +1063,9 @@ describe("commands in main-thread systems", () => { }); test("leave a list naming anything but module keys as written", async () => { const result = await transform(`${keys}const system = System.create(({ commands }) => { const c = a; commands.spawn([a, c]); commands.remove(1, [a, 2]); });`); - expect(result).toBe(null); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed).toContain("commands.spawn([a, c]); commands.remove(1, [a, 2]);"); + expect(transformed).not.toContain("archetype$"); }); test("fail the build for a list naming a key twice", async () => { const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.remove(1, [a, b, a]); });`); From 6dac50bd7b82b074106f16556e659ca083e72bb0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:04:41 -0700 Subject: [PATCH 091/127] ecs: inference sees destructuring and loop targets, pattern defaults, deep writes and eval; rejects async bodies; finalizer rows refuse writes Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/body-access.ts | 85 +++++++++++++++---- .../implementation/compiler/inferred.test.ts | 32 +++++++ .../src/implementation/compiler/inferred.ts | 10 ++- .../implementation/compiler/transform.test.ts | 47 ++++++++++ .../src/implementation/compiler/transform.ts | 6 +- .../src/interface/finalizer/create.test.ts | 3 + .../ecs/src/interface/finalizer/create.ts | 8 +- .../ecs/src/interface/system/resolve.test.ts | 8 +- packages/ecs/src/interface/system/resolve.ts | 3 +- 9 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 packages/ecs/src/implementation/compiler/inferred.test.ts diff --git a/packages/ecs/src/implementation/compiler/body-access.ts b/packages/ecs/src/implementation/compiler/body-access.ts index e52cf4ea..836d02f7 100644 --- a/packages/ecs/src/implementation/compiler/body-access.ts +++ b/packages/ecs/src/implementation/compiler/body-access.ts @@ -29,18 +29,44 @@ const isName = (node: Node, parent: Node | undefined): boolean => { } }; -/** How `member`, a `name[key]` expression, is used: read, written, or both; null when it's a destructuring target or deleted. */ -const useOf = (member: Node, parent: Node | undefined): { readonly read: boolean; readonly write: boolean } | null => { +type Use = { readonly read: boolean; readonly write: boolean }; + +const read: Use = { read: true, write: false }; +const written: Use = { read: false, write: true }; + +/** + * How `member`, a `name[key]` expression, is used: read, written, or both; null when it's deleted. A target of a + * destructuring assignment, or of a `for…of` or `for…in` loop, is written; a pattern in a declaration only binds names. + */ +const useOf = (member: Node, parent: Node | undefined, grandparent: Node | undefined): Use | null => { switch (parent?.type) { case "AssignmentExpression": - return parent.left !== member ? { read: true, write: false } : { read: parent.operator !== "=", write: true }; + return parent.left !== member ? read : { read: parent.operator !== "=", write: true }; case "UpdateExpression": return { read: true, write: true }; - case "UnaryExpression": return parent.operator === "delete" ? null : { read: true, write: false }; - case "ArrayPattern": case "RestElement": case "AssignmentPattern": case "ForInStatement": case "ForOfStatement": - return null; - case "Property": return parent.value === member && parent.shorthand ? null : { read: true, write: false }; - default: return { read: true, write: false }; + case "UnaryExpression": return parent.operator === "delete" ? null : read; + case "ArrayPattern": case "RestElement": return written; + case "AssignmentPattern": return parent.left === member ? written : read; + case "ForInStatement": case "ForOfStatement": return parent.left === member ? written : read; + case "Property": return parent.value === member && grandparent?.type === "ObjectPattern" ? written : read; + default: return read; + } +}; + +/** + * How `member`, a `name[key]` expression, is used where `ancestors` ends with its parent: as `useOf` says, and written + * too when something inside its value is (`row[key].a.b = 1`, `delete row[key].a`, `[row[key][0]] = pair`), which + * replaces nothing but changes the value all the same. + */ +const deepUseOf = (member: Node, ancestors: readonly Node[], at: number): Use | null => { + let inner = member; + let index = at; + for (let parent = ancestors[index]; parent?.type === "MemberExpression" && parent.object === inner; parent = ancestors[index]) { + inner = parent; + index--; } + const use = useOf(inner, ancestors[index], ancestors[index - 1]); + if (inner === member) return use; + return use === null || use.write ? { read: true, write: true } : read; }; /** Whether a lookup result `value`, a child of `parent`, is only tested: compared with null, or used as a condition. */ @@ -100,6 +126,8 @@ const isGroup = (name: string): name is Group => name === "resources" || name == export const usedBy = (declaration: ObjectExpression, body: Node, module: ModuleScope, positions: { readonly row: number; readonly context: number }): Used => { if (!isFunctionBody(body)) return unknown; if (body.type === "FunctionExpression" && mentions(body.body, "arguments")) return unknown; + // Code `eval` runs can use anything. + if (mentions(body, "eval")) return unknown; const counts = new Map(); declaredIn(body.body, name => counts.set(name, (counts.get(name) ?? 0) + 1)); const parameters = new Set(body.params.flatMap(parameter => patternNames(parameter))); @@ -303,12 +331,12 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module return; } if (group === "up") { - const use = useOf(parent, grandparent); + const use = deepUseOf(parent, ancestors, at - 1); if (use === null || use.write) upKnown = false; else up.add(key); return; } - const use = useOf(parent, grandparent); + const use = deepUseOf(parent, ancestors, at - 1); if (use === null) return unknownGroup(group); if (use.read) resources.read.add(key); if (use.write) resources.write.add(key); @@ -327,11 +355,8 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module return; } const key = keyOf(property); - // A part of a value assigned, `row[key][0] = …`, writes it, which the compiler rejects as it must. - const great = ancestors.at(-3); - const partWritten = grandparent?.type === "MemberExpression" && grandparent.object === parent - && ((great?.type === "AssignmentExpression" && great.left === grandparent) || great?.type === "UpdateExpression"); - const use = key === null ? null : partWritten ? { read: true, write: true } : useOf(parent, grandparent); + // A part of a value written, `row[key][0] = …`, writes it, which the compiler rejects as it must. + const use = key === null ? null : deepUseOf(parent, ancestors, ancestors.length - 2); if (use === null || key === null) queryKnown = false; else { if (use.read) query.read.add(key); @@ -362,13 +387,39 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module }; const ancestors: Node[] = []; - /** A declaration's target pattern only binds names, so only its initializer holds references. */ const visit = (node: Node): void => { if (node.type === "Identifier" && !isName(node, ancestors.at(-1))) reference(node, ancestors); ancestors.push(node); - for (const child of node.type === "VariableDeclarator" ? (node.init === null || node.init === undefined ? [] : [node.init]) : children(node)) visit(child); + if (node.type === "VariableDeclarator") { + visitPattern(node.id); + if (node.init !== null && node.init !== undefined) visit(node.init); + } + else for (const child of children(node)) visit(child); + ancestors.pop(); + }; + /** A pattern that binds names: its names aren't references, but its defaults and computed keys hold them. */ + const visitPattern = (pattern: Node): void => { + ancestors.push(pattern); + if (pattern.type === "ObjectPattern") { + for (const property of pattern.properties) { + ancestors.push(property); + if (property.type === "RestElement") visitPattern(property.argument); + else { + if (property.computed) visit(property.key); + visitPattern(property.value); + } + ancestors.pop(); + } + } + else if (pattern.type === "ArrayPattern") for (const element of pattern.elements) { if (element !== null) visitPattern(element); } + else if (pattern.type === "RestElement") visitPattern(pattern.argument); + else if (pattern.type === "AssignmentPattern") { + visitPattern(pattern.left); + visit(pattern.right); + } ancestors.pop(); }; + for (const parameter of body.params) visitPattern(parameter); visit(body.body); return { query: queryKnown ? query : null, diff --git a/packages/ecs/src/implementation/compiler/inferred.test.ts b/packages/ecs/src/implementation/compiler/inferred.test.ts new file mode 100644 index 00000000..cba60567 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/inferred.test.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from "vitest"; +import { parseAst } from "vite"; +import type { ObjectExpression } from "estree"; +import { estreeOf } from "./estree.js"; +import { resolvedDeclaration } from "./inferred.js"; + +/** The declaration written as `source`, an object literal. */ +const declarationOf = (source: string): ObjectExpression => { + const statement = estreeOf(parseAst(`(${source});`)).body[0]; + if (statement?.type !== "ExpressionStatement" || statement.expression.type !== "ObjectExpression") throw new Error("not an object literal"); + return statement.expression; +}; + +/** The names a resolved declaration's lookup `name` reads, or null without a `read` list. */ +const readOf = (declaration: ObjectExpression, name: string): readonly string[] | null => { + const field = (object: ObjectExpression, key: string) => object.properties.find(property => + property.type === "Property" && property.key.type === "Identifier" && property.key.name === key); + const lookup = field(declaration, "lookup"); + const entry = lookup?.type === "Property" && lookup.value.type === "ObjectExpression" ? field(lookup.value, name) : undefined; + const read = entry?.type === "Property" && entry.value.type === "ObjectExpression" ? field(entry.value, "read") : undefined; + if (read?.type !== "Property" || read.value.type !== "ArrayExpression") return null; + return read.value.elements.map(element => element?.type === "Identifier" ? element.name : "?"); +}; + +describe("resolvedDeclaration", () => { + test("takes a lookup by entity whose rows the build didn't follow as reading all it requires", () => { + const declaration = declarationOf("{ lookup: { targets: { with: [mass, position] } } }"); + const resolved = resolvedDeclaration(declaration, { query: null, resources: null, lookups: new Map([["targets", null]]), up: null }); + expect(readOf(resolved, "targets")).toEqual(["mass", "position"]); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts index 46738ee4..5e2d03e6 100644 --- a/packages/ecs/src/implementation/compiler/inferred.ts +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -151,8 +151,14 @@ export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): if (value?.type !== "ObjectExpression" || used.lookups === null) break; properties.push(property("lookup", object([...parts].map(([name, lookup]) => { const lookupParts = fields(valueOf(lookup)); - const reads = used.lookups?.get(name) ?? []; - if (lookupParts.has("index")) return property(name, object([property("read", array(reads ?? [])), ...keep(lookupParts, ["index", "changed"])])); + // A lookup the body never uses reads nothing; null, one whose rows it didn't follow. + const found = used.lookups?.get(name); + const reads = found === undefined ? [] : found; + if (lookupParts.has("index")) { + // Rows the build didn't follow read everything the index holds, as the world resolves it. + const read = reads === null ? [] : [property("read", array(reads))]; + return property(name, object([...read, ...keep(lookupParts, ["index", "changed"])])); + } const withList = valueOf(lookupParts.get("with")); // Rows the build didn't follow read everything the lookup requires. const read = reads === null ? withList ?? array([]) : array(reads); diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 01946286..3d0282b1 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1425,6 +1425,53 @@ describe("inferred access", () => { expect(result !== null && "code" in result ? result.code : "").toContain("{ components: { read: [handle], write: [] }, resources: { read: [count], write: [], reduce: [] }"); }); + test("a destructuring target, or a for…of target, written", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { ({ x: row[position] } = source); }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { for ({ x: row[position] } of sources); }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { [row[position]] = pair; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("", "(context) => { ({ x: context.resources[score] } = source); }"))) + .toContain("resources: { read: [], write: [score], reduce: [] }"); + }); + + test("defaults and computed keys of declared patterns, and of parameters, as uses", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { const { a = (row[position] = 5) } = {}; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position, velocity] }", "(row) => { const { [row[velocity]]: v } = table; console.log(v); }"))) + .toContain("components: { read: [velocity], write: [] }"); + expect(await inferred(system("", "(context) => { const { a = context.resources[delta] } = {}; console.log(a); }"))) + .toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(system("components: { with: [position, velocity] }", "(row, context, step = row[velocity]) => { console.log(step); }"))) + .toContain("components: { read: [velocity], write: [] }"); + }); + + test("what a for…of or for…in loop goes over, as a read", async () => { + expect(await inferred(system("", "({ resources }) => { for (const name of resources[delta]) console.log(name); }"))) + .toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { for (const x of row[position]) console.log(x); }"))) + .toContain("components: { read: [position], write: [] }"); + }); + + test("a change inside a value, at any depth, as a write of it", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { row[position].a.b = 1; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { delete row[position].a; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { [row[position][0]] = pair; }"))) + .toContain("components: { read: [], write: [position] }"); + }); + + test("a body mentioning eval as one it can't follow", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { eval(\"row[position] = 1\"); }"))).toContain("components: null"); + }); + + test("fail the build for an async or generator body", async () => { + expect(await inferred(system("components: { with: [position] }", "async (row) => { row[position] = 1; }"))).toContain("a body can't be async"); + expect(await inferred(system("components: { with: [position] }", "function* (row) { row[position] = 1; }"))).toContain("a body can't be a generator"); + }); + test("a body given by name, read where it's bound", async () => { expect(await inferred(`const move = (row) => { row[position] = row[velocity]; };\n${system("components: { with: [position, velocity] }", "move")}`)) .toContain("components: { read: [velocity], write: [position] }"); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 84d6818a..d820b863 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -268,11 +268,15 @@ export const transformKernels = async ( for (const child of children(node)) visit(child, nested || isFunction(node)); return; } + // A row is bound to each entity in turn, so a body resuming later would read and write another's. + if ((body.type === "ArrowFunctionExpression" || body.type === "FunctionExpression" || body.type === "FunctionDeclaration") && (body.async || body.generator)) { + fail(body, `a body can't be ${body.async ? "async" : "a generator"}: its row and context are rebound for the next entity while it waits, so start the work and finish it in a later run`); + } const declared = declaration?.type === "ObjectExpression" ? declaration : empty; const used = kernel?.used ?? inOrder(usedBy(declared, body, module.bindings, positions), declared); requireRequired(used, declared, body); const where = at(body); - if (used.query === null) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); + if (used.query === null) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); if (used.lookups !== null && [...used.lookups.values()].some(read => read === null) || used.lookups === null && declares(declared, "lookup")) { warnings.push(`${where}: the build can't follow how this body uses a lookup's rows, so it takes everything they hold as read.`); } diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index cb8f15be..c03b1428 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -50,6 +50,9 @@ describe("Finalizer.create and Finalizer.changes", () => { expect(() => Finalizer.changes(identify({}, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); expect(programFinalizers()).toHaveLength(2); }); + it("reject a body writing its row, which holds the values from before", () => { + expect(() => untyped(Finalizer.create, { components: { with: [label] } }, { write: [label] })).toThrow("which is read-only"); + }); it("keep finalizers of one label apart", () => { const twin = (): Finalizer => Finalizer.changes({ watch: [label] }, () => {}); const [first, second] = [twin(), twin()]; diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index b61eebd2..0c09897e 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -48,8 +48,10 @@ const resolveShared = (what: string, declared: Declaration | ChangesDeclaration, }; }; -/** A finalizer's row lists: what its body reads, the rest of `with` only required. */ -const componentReads = (components: Declaration["components"], access: Access): Resolved["components"] => { +/** A finalizer's row lists: what its body reads, the rest of `with` only required. Throws for a body writing its row, which holds values from before. */ +const componentReads = (what: string, components: Declaration["components"], access: Access): Resolved["components"] => { + const written = access.components?.write[0]; + if (written !== undefined) throw new Error(`${what} writes "${labelOf(written)}" of its row, which is read-only: it holds the values from before the entity stopped matching.`); const read = access.components?.read ?? components.with ?? []; return compact({ read, has: (components.with ?? []).filter(key => !read.includes(key)), not: components.not }); }; @@ -99,7 +101,7 @@ export const create = ( const access = accessOf(what, declared); const declaration: Resolved = { ...resolveShared(what, declared, access), - components: componentReads(declared.components, access), + components: componentReads(what, declared.components, access), }; const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; checkResources(name, declaration.resources); diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index d59200f9..14466425 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { lookupsLists, type Access } from "./resolve.js"; +import { componentLists, lookupsLists, type Access } from "./resolve.js"; const mass = Symbol("resolveTest/mass"); const position = Symbol("resolveTest/position"); @@ -21,3 +21,9 @@ describe("lookupsLists", () => { expect(lookupsLists({ others: { with: [mass, position] } }, access({ others: [position] }))).toEqual({ others: { read: [position], has: [mass] } }); }); }); + +describe("componentLists", () => { + it("takes a row the build didn't follow as writing every member, its added and changed filters' included", () => { + expect(componentLists({ with: [mass], changed: [position] }, null, false)).toEqual({ write: [mass, position], changed: [position] }); + }); +}); diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts index a5cbbe70..2aaf9ca4 100644 --- a/packages/ecs/src/interface/system/resolve.ts +++ b/packages/ecs/src/interface/system/resolve.ts @@ -73,7 +73,8 @@ export const compact = (lists: T): T => { * reads. */ export const componentLists = (components: ComponentAccess, used: Used | null, reading: boolean): ComponentLists => { - const members = components.with ?? []; + // A row holds what its added and changed filters name too. + const members = [...new Set([...components.with ?? [], ...components.added ?? [], ...components.changed ?? []])]; const write = used === null ? reading ? [] : members.filter(key => declarationOf(key)?.reference === undefined) : used.write; From 0944cdd6eb1d34110d2715cd5ee70a0bb274727a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:08:58 -0700 Subject: [PATCH 092/127] ecs: lists made at runtime share one index per set of keys, so making them on every call takes no index and allocates nothing Co-Authored-By: Claude Opus 5.5 --- .../src/implementation/world/commands.test.ts | 16 ++++++++ .../ecs/src/implementation/world/lists.ts | 39 ++++++++++--------- .../src/interface/registry/archetypes.test.ts | 8 +++- .../ecs/src/interface/registry/archetypes.ts | 24 ++++++++++-- packages/ecs/src/interface/registry/index.ts | 2 +- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index 7900ac56..8e584516 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -119,6 +119,22 @@ describe("spawn", () => { expect(await components(world, spawned[1])).toEqual({ [score]: 2, [tag]: true }); await world.dispose(); }); + it("gives lists made at runtime on every call one index per set of components, however many are made", async () => { + const { world, run } = withCommands(); + const count = (): number => { + const value: unknown = Reflect.get(globalThis, Symbol.for("@adobe/ecs/archetypes@1")); + return typeof value === "number" ? value : 0; + }; + // Fifteen subsets of four bits, more than are kept as recent, each list made afresh at every spawn. + const spawnAll = (commands: Commands): void => { + for (let subset = 1; subset < 16; subset++) commands.spawn(bits.filter((_, bit) => (subset & (1 << bit)) !== 0))({}); + }; + await run(spawnAll); + const before = count(); + for (let round = 0; round < 20; round++) await run(spawnAll); + expect(count() - before).toBe(0); + await world.dispose(); + }); it("spawns a list made at runtime in each world that uses it", async () => { const first = withCommands(); const second = withCommands(); diff --git a/packages/ecs/src/implementation/world/lists.ts b/packages/ecs/src/implementation/world/lists.ts index e17fd6c4..31bec4c1 100644 --- a/packages/ecs/src/implementation/world/lists.ts +++ b/packages/ecs/src/implementation/world/lists.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from '../../interface/entity/entity.js'; -import { archetypeIndexKey, labelOf, nextArchetypeIndex } from '../../interface/registry/index.js'; +import { archetypeIndexKey, internArchetype, labelOf, type Interned } from '../../interface/registry/index.js'; import { batchOf } from './batches.js'; import { reserve } from './entities.js'; import { claim, generated, openRow, writerOf, type Column, type Positional, type SpawnBatch, type Writer } from './staging.js'; @@ -37,8 +37,8 @@ export type ListShape = { spawner: Spawner | null; }; -/** A plain list named recently, found by its keys, copied, since it may change. */ -export type Recent = { readonly keys: readonly unknown[]; readonly shape: ListShape }; +/** A plain list named recently, found by its keys, copied once for its shape, since it may change. */ +export type Recent = { keys: readonly unknown[]; shape: ListShape }; const recentCount = 8; @@ -89,27 +89,30 @@ const shapeOfList = (state: State, list: readonly unknown[], index: number): Lis } const { recent } = state; for (let r = 0; r < recent.length; r++) if (sameKeys(recent[r].keys, list)) return recent[r].shape; - const shape = resolve(state, list); - const entry: Recent = { keys: [...list], shape }; - if (recent.length < recentCount) recent.push(entry); - else recent[state.nextRecent = (state.nextRecent + 1) % recentCount] = entry; - // Only a list resolved keeps an index, so copies made on every call stay among the recent ones and allocate nothing. + // Lists of these keys made on every call share one index, so a shape seen before allocates nothing here. + const interned = internArchetype(list); + const shape = state.byIndex[interned.index] ?? (state.byIndex[interned.index] = resolve(state, list)); + if (recent.length < recentCount) recent.push({ keys: interned.keys, shape }); + else { + const slot = recent[state.nextRecent = (state.nextRecent + 1) % recentCount]; + slot.keys = interned.keys; + slot.shape = shape; + } + // A list keeps the record of its keys' index, so naming it again is one lookup while it holds them. const kept: unknown = Reflect.get(list, archetypeIndexKey); - const own: Kept | null = isKept(kept) ? kept : kept === undefined && Object.isExtensible(list) ? { keys: entry.keys, index: -1 } : null; - if (own === null) return shape; - if (kept === undefined) Object.defineProperty(list, archetypeIndexKey, { value: own }); - // A list that changed takes a new index, since its shape may differ. - own.keys = entry.keys; - own.index = nextArchetypeIndex(); - state.byIndex[own.index] = shape; + if (kept === undefined && Object.isExtensible(list)) { + record.value = interned; + Object.defineProperty(list, archetypeIndexKey, record); + } + else if (isKept(kept)) Reflect.set(list, archetypeIndexKey, interned); return shape; }; -/** What a list made at runtime keeps: the keys it was last resolved with, and the index it has while it holds them. */ -type Kept = { keys: readonly unknown[]; index: number }; +/** The descriptor a list's record is defined by, its value set for each: writable, since the list may change. */ +const record: PropertyDescriptor = { value: null, writable: true }; /** Whether an index property holds a list's record: only this module puts an object there, and indexes are numbers. */ -const isKept = (value: unknown): value is Kept => typeof value === "object" && value !== null; +const isKept = (value: unknown): value is Interned => typeof value === "object" && value !== null; /** Whether a list holds exactly the keys copied from one before. */ const sameKeys = (keys: readonly unknown[], list: readonly unknown[]): boolean => { diff --git a/packages/ecs/src/interface/registry/archetypes.test.ts b/packages/ecs/src/interface/registry/archetypes.test.ts index d20a0a16..cc12fd35 100644 --- a/packages/ecs/src/interface/registry/archetypes.test.ts +++ b/packages/ecs/src/interface/registry/archetypes.test.ts @@ -1,6 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { archetypeIndexOf, indexArchetype } from "./archetypes.js"; +import { archetypeIndexKey, indexArchetype } from "./archetypes.js"; + +/** The index a list was given, or -1 without one. */ +const archetypeIndexOf = (list: readonly unknown[]): number => { + const index: unknown = Reflect.get(list, archetypeIndexKey); + return typeof index === "number" ? index : -1; +}; const position = Symbol(); const velocity = Symbol(); diff --git a/packages/ecs/src/interface/registry/archetypes.ts b/packages/ecs/src/interface/registry/archetypes.ts index 6425a00e..0e64d59b 100644 --- a/packages/ecs/src/interface/registry/archetypes.ts +++ b/packages/ecs/src/interface/registry/archetypes.ts @@ -36,8 +36,24 @@ export const nextArchetypeIndex = (): number => { return next; }; -/** The program-wide index a list was given, or -1 for one never given one, such as a list made at runtime. */ -export const archetypeIndexOf = (components: readonly unknown[]): number => { - const index: unknown = Reflect.get(components, indexKey); - return typeof index === "number" ? index : -1; +/** The keys of a list made at runtime, in its order, and the one program-wide index every list of them shares. */ +export type Interned = { readonly keys: readonly unknown[]; readonly index: number }; + +type Node = { interned: Interned | null; readonly next: Map }; + +const root: Node = { interned: null, next: new Map() }; + +/** + * The index lists made at runtime of `list`'s keys, in its order, share: given the first time, then found without + * allocating, so lists made on every call take one index between them. + */ +export const internArchetype = (list: readonly unknown[]): Interned => { + let node = root; + for (let k = 0; k < list.length; k++) { + const key = list[k]; + let next = node.next.get(key); + if (next === undefined) node.next.set(key, next = { interned: null, next: new Map() }); + node = next; + } + return node.interned ??= { keys: [...list], index: nextArchetypeIndex() }; }; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts index 8c2bd2e8..c4a19401 100644 --- a/packages/ecs/src/interface/registry/index.ts +++ b/packages/ecs/src/interface/registry/index.ts @@ -24,7 +24,7 @@ export type { Leaf } from '../system/leaf.js'; export type { Composite } from '../system/composite.js'; export type { Rule } from '../rule/created.js'; export type { Finalizer } from '../finalizer/created.js'; -export { archetypeIndexKey, archetypeIndexOf, nextArchetypeIndex } from './archetypes.js'; +export { archetypeIndexKey, internArchetype, type Interned } from './archetypes.js'; export { indexDefinitionOf, indexOfLookup, lookupKeysOf, orderKeysOf, watchedBy, watchesEntries } from './indexes.js'; export type { Created as IndexDefinition } from '../indexes/created.js'; export type { Declaration as IndexDeclaration } from '../indexes/declaration.js'; From eeb67a530f812becf1233b5bca952d348bd074d8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:11:27 -0700 Subject: [PATCH 093/127] ecs: lists and spawn sites are hoisted out of a body only when no name around it, nor its parameters, shadows their keys Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/compiler/spawns.ts | 12 ++++++++--- .../implementation/compiler/transform.test.ts | 8 ++++++++ .../src/implementation/compiler/transform.ts | 20 +++++++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/spawns.ts b/packages/ecs/src/implementation/compiler/spawns.ts index 2b9f645c..4cb5e318 100644 --- a/packages/ecs/src/implementation/compiler/spawns.ts +++ b/packages/ecs/src/implementation/compiler/spawns.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Expression, Node, Property, SpreadElement } from 'estree'; -import { children, declaredIn } from './estree.js'; +import { children, declaredIn, patternNames } from './estree.js'; import { fail } from './failure.js'; import type { ModuleScope } from './module-scope.js'; @@ -62,13 +62,16 @@ const commandsOf = (parameter: Node | undefined): { readonly name: string; reado * an object literal of computed keys, each a module-scope constant. A body that redeclares the context or its commands anywhere is left alone, and so is a * key it shadows. */ -export const spawnSites = (body: Node, module: ModuleScope, context: number): { readonly commands: string; readonly sites: readonly SpawnSite[] } | null => { +export const spawnSites = (body: Node, module: ModuleScope, context: number, shadowed: ReadonlySet = new Set()): { readonly commands: string; readonly sites: readonly SpawnSite[] } | null => { if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return null; const reached = commandsOf(body.params[context]); if (reached === null) return null; const declared = new Set(); declaredIn(body.body, name => declared.add(name)); if (declared.has(reached.name)) return null; + // Names declared around the body, and its other parameters, shadow a module's keys too. + for (const name of shadowed) declared.add(name); + for (const name of body.params.flatMap(parameter => parameter === body.params[context] ? [] : patternNames(parameter))) declared.add(name); const isCommands = (node: Node): boolean => reached.viaContext ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name && node.property.type === "Identifier" && node.property.name === "commands" @@ -130,13 +133,16 @@ const isKeyName = (name: string, module: ModuleScope, declared: ReadonlySet { +export const listSites = (body: Node, module: ModuleScope, context: number, shadowed: ReadonlySet = new Set()): readonly ListSite[] => { if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return []; const reached = commandsOf(body.params[context]); if (reached === null) return []; const declared = new Set(); declaredIn(body.body, name => declared.add(name)); if (declared.has(reached.name)) return []; + // Names declared around the body, and its other parameters, shadow a module's keys too. + for (const name of shadowed) declared.add(name); + for (const name of body.params.flatMap(parameter => parameter === body.params[context] ? [] : patternNames(parameter))) declared.add(name); const isCommands = (node: Node): boolean => reached.viaContext ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name && node.property.type === "Identifier" && node.property.name === "commands" diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 3d0282b1..bb2dd0eb 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1067,6 +1067,14 @@ describe("commands in main-thread systems", () => { expect(transformed).toContain("commands.spawn([a, c]); commands.remove(1, [a, 2]);"); expect(transformed).not.toContain("archetype$"); }); + test("leave a list naming a key shadowed around the body, or by its parameters, as written", async () => { + const result = await transform(`${keys}const make = (a) => System.create(({ commands }) => { commands.spawn([a]); }); +const remove = System.create(({ commands }, b) => { commands.remove(1, [a, b]); });`); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed).toContain("commands.spawn([a]);"); + expect(transformed).toContain("commands.remove(1, [a, b]);"); + expect(transformed).not.toContain("archetype$"); + }); test("fail the build for a list naming a key twice", async () => { const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.remove(1, [a, b, a]); });`); expect(result !== null && "failure" in result ? result.failure.message : null).toBe("this list names a twice; a list of components names each once"); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index d820b863..2a1c22f9 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -2,7 +2,7 @@ import type { FunctionDeclaration, MaybeNamedFunctionDeclaration, Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; import { posix } from 'node:path'; import { compileKernel, type CompiledKernel } from './compile-kernel.js'; -import { children, isFunction } from './estree.js'; +import { children, declaredIn, isFunction } from './estree.js'; import { fail, failureOf, type Failure } from './failure.js'; import { isPending, type Link, type Linker } from './link.js'; import { createModule, locateIn, type Module, type ModuleBinding } from './module-scope.js'; @@ -246,7 +246,13 @@ export const transformKernels = async ( const prepared: string[] = []; let adding = false; let spawning = false; - const visit = (node: Node, nested: boolean): void => { + /** The names `outermost`, the outermost function around a node, declares anywhere: any may shadow a module's key there. */ + const shadowedBy = (outermost: Node | null): ReadonlySet => { + const names = new Set(); + if (outermost !== null) declaredIn(outermost, name => names.add(name)); + return names; + }; + const visit = (node: Node, nested: boolean, outermost: Node | null = null): void => { const created = node.type === "CallExpression" ? createdBy(node, locals) : null; const [first, second] = node.type === "CallExpression" ? node.arguments : []; /** Whether `node` is a function, written in place or bound by name at module scope. */ @@ -265,7 +271,7 @@ export const transformKernels = async ( // A declaration not written in place, or a body that isn't a function written in place, is one the build can't // read: it takes what it uses from an internal factory, or fails where it's made. if (positions === null || first === undefined || first.type === "SpreadElement" || body === undefined || body.type === "SpreadElement" || !isFunction(body)) { - for (const child of children(node)) visit(child, nested || isFunction(node)); + for (const child of children(node)) visit(child, nested || isFunction(node), outermost ?? (isFunction(node) ? node : null)); return; } // A row is bound to each entity in turn, so a body resuming later would read and write another's. @@ -307,13 +313,15 @@ export const transformKernels = async ( edits.push({ start: first.start, end: first.start, text: `${accessName}(` }, { start: first.end, end: first.end, text: `, ${access})` }); if (terminalBody) edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); } - const spawns = spawnSites(body, module.bindings, positions.context); + // Names declared around the body may shadow a module's keys, so only the module's own are hoisted. + const shadowed = shadowedBy(outermost); + const spawns = spawnSites(body, module.bindings, positions.context, shadowed); // Index lookups pass their key positionally, through a list of their own (`world/prepared-lookup.ts`). const lookups = declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); const spawned = spawns?.sites ?? []; // Lists of keys its commands take are made once, with an index, rather than on every call. const made = new Map(); - const listed = listSites(body, module.bindings, positions.context); + const listed = listSites(body, module.bindings, positions.context, shadowed); for (const site of listed) { const inside = [...spawned, ...lookups].some(other => other.start <= site.start && site.end <= other.end && !("components" in other && other.components.start === site.start)); if (inside) continue; @@ -350,7 +358,7 @@ export const transformKernels = async ( for (const [start, { end, text }] of made) edits.push({ start, end, text }); } const inner = nested || isFunction(node); - for (const child of children(node)) visit(child, inner); + for (const child of children(node)) visit(child, inner, outermost ?? (isFunction(node) ? node : null)); }; try { for (const { node, declaration, body, request, used } of kernelSites(program, locals, automatic, module)) { From 8c600e994f6403fccec7a9c84ac47f458b1508df Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:12:22 -0700 Subject: [PATCH 094/127] ecs: a declaration that fails registering fails only its own key's uses, each time, never a read of another key Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/world/commands.test.ts | 13 +++++++++++++ packages/ecs/src/interface/registry/declarations.ts | 7 ++----- .../ecs/src/interface/registry/declared.test.ts | 3 +-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index 8e584516..509fcb82 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -13,6 +13,7 @@ import { Observe } from "../../observe/index.js"; import { count, read, readEntity, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import { recordDeclaration } from "../../interface/registry/ids.test-helper.js"; import type { F32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; @@ -135,6 +136,18 @@ describe("spawn", () => { expect(count() - before).toBe(0); await world.dispose(); }); + it("fails only the systems using a key whose declaration failed, each time, never another key's", async () => { + const { world, run } = withCommands(); + await run(() => {}); + // As a module imported after the world exists records it. + const broken = Symbol("commandsTest/broken"); + recordDeclaration(broken, "component", { type: { type: "number" }, reduce: "+" }); + await run(commands => { commands.spawn([bit0])({}); }); + for (let round = 0; round < 2; round++) { + await expect(run(commands => { commands.spawn([broken])({}); })).rejects.toThrow("only a resource is reduced"); + } + await world.dispose(); + }); it("spawns a list made at runtime in each world that uses it", async () => { const first = withCommands(); const second = withCommands(); diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index 8a1a90d3..bea3ef26 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -144,13 +144,12 @@ let catching = false; /** * Registers what the build declared since this last ran, in the order modules evaluated, as each module registered - * its keys when it was evaluated. A declaration that fails is recorded for its key's uses and thrown once, after the - * rest register. Reads of keys while it runs see those registered so far. + * its keys when it was evaluated. A declaration that fails is recorded for its own key, whose every use fails giving + * it as the reason, and never for a read of another key. Reads of keys while it runs see those registered so far. */ const catchUp = (): void => { if (catching || declared.size === seen) return; catching = true; - let failure: unknown = undefined; try { let index = 0; for (const [key, entry] of declared) { @@ -161,14 +160,12 @@ const catchUp = (): void => { } catch (error) { if (typeof key === "symbol") recordFailure(key, error instanceof Error ? error.message : String(error)); - failure ??= error; } } } finally { catching = false; } - if (failure !== undefined) throw failure; }; installCatchUp(catchUp); diff --git a/packages/ecs/src/interface/registry/declared.test.ts b/packages/ecs/src/interface/registry/declared.test.ts index ff2b2a56..093bdfee 100644 --- a/packages/ecs/src/interface/registry/declared.test.ts +++ b/packages/ecs/src/interface/registry/declared.test.ts @@ -27,12 +27,11 @@ describe("declarations the build records", () => { recordDeclaration(again, "component", { type: { type: "number" } }); expect([kindOf(again), sameKey(first, again)]).toEqual(["component", true]); }); - it("throws a declaration that fails once, at the next use, and gives it as the reason its key's uses fail", () => { + it("never throws a declaration that fails for another key's use, and gives it as the reason its own key's uses fail", () => { const late = Symbol("declaredTest/late"); const fine = Symbol("declaredTest/fine"); recordDeclaration(late, "component", { type: { type: "number" }, reduce: "+" }); recordDeclaration(fine, "component", { type: { type: "number" } }); - expect(() => kindOf(fine)).toThrow(`Cannot register component "declaredTest/late": only a resource is reduced.`); expect([kindOf(fine), kindOf(late)]).toEqual(["component", undefined]); expect(whyUnregistered(late)).toBe(`its declaration failed to register: Cannot register component "declaredTest/late": only a resource is reduced.`); // Left valid, so any other copy of the registry reading the map later registers it. From 4cb6d032742a6d95be6fed18f119e4f37fe82a46 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:19:04 -0700 Subject: [PATCH 095/127] ecs: starts take each field's own default; every default is checked against its schema; Map and Set through aliases; one start per world; -0 kept Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 29 +++- .../implementation/compiler/augmentations.ts | 124 ++++++++++++------ .../src/implementation/world/commands.test.ts | 3 +- .../src/implementation/world/defaults.test.ts | 37 +++++- .../src/implementation/world/synchronize.ts | 5 +- .../src/interface/component/register.test.ts | 5 + .../src/interface/registry/declarations.ts | 5 +- .../ecs/src/interface/registry/schemas.ts | 24 ++++ 8 files changed, 184 insertions(+), 48 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index cce002d1..db02c08a 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -137,6 +137,29 @@ const after = 1;`, files); `const after = 1;`, ]); }); + test("writes a literal -0 as itself", async () => { + expect(await registrations(`${keys("floor")}declare module "@adobe/ecs" { interface Resources { [floor]: { type: number; default: -0 } } }`)) + .toEqual([`declare$(floor, "resource", { type: {"type":"number","default":-0} });`]); + }); + test("starts a struct's fields and a tuple's items at their own types' defaults", async () => { + const code = await passed(`import type { Schema } from "@adobe/ecs";\n${keys("level", "box", "pair", "pool")}declare module "@adobe/ecs" { + interface Resources { + [level]: { type: { readonly value: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9; default: 5 }> } }; + [box]: { type: { readonly height: Height; readonly width: F32 } }; + [pair]: { type: readonly [Height, F32] }; + [pool]: { type: Pool }; + } +} +type Pool = Map; +type Height = F32; +const Height = { schema: { type: "number", precision: 1, default: -1 } } as const;`); + expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ + `declare$(level, "resource", { type: {"type":"object","properties":{"value":{"type":"integer","minimum":1,"maximum":9,"default":5}},"default":{"value":5}} });`, + `declare$(pool, "resource", { fresh: "Map" });`, + `declare$(box, "resource", { type: {"type":"object","properties":{"height":{"type":"number","precision":1},"width":{"type":"number","precision":1}},"default":{ "height": Height.schema.default, "width": 0 }} });`, + `declare$(pair, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":2,"maxItems":2,"default":[Height.schema.default, 0]} });`, + ]); + }); test("gives a value that isn't data no schema, and its traits", async () => { const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}declare module "@adobe/ecs" { @@ -246,7 +269,11 @@ const mark = Symbol();`); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain(`a union of literals without 0, false or "" starts at none of them`); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: "all" | "done" } }`)}`)).toContain("declare `default:` one of them"); expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("starts at none of them"); - expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("const Pool = { schema: { default: ... } }"); + expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("let its type allow null"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: OffscreenCanvas } }`)}`)).toContain("let its type allow null"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)) + .toContain("give its schema one: `Schema.Type<{ ...; default: ... }>`"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain("declare a type of its own with a namespace"); expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)).toContain("0 isn't one of its values"); expect(await passed(`const start = 1;\n${keys("key")}${block(`interface Resources { [key]: { type: number; default: typeof start } }`)}`)).toContain("default is a literal type, never `typeof` a value"); expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nexport const Lower = { schema: { type: "number", default: -1 } } as const;` })).toContain("import Lower as a value"); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index cefda6a4..b6c72642 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { reductionOperators } from '../../interface/registry/reduction.js'; -import { isEntity, isString, isStruct } from '../../interface/registry/schemas.js'; +import { isEntity, isString, isStruct, matchesSchema } from '../../interface/registry/schemas.js'; import type { Schema } from '../../interface/schema/schema.js'; import { declarationsName } from '../../interface/registry/ids.js'; import type { PassResult } from './input-schemas.js'; @@ -157,37 +157,28 @@ const uniqueName = (code: string, base: string): string => { return name; }; -/** Whether a value matches a schema, as far as a literal default can be checked. */ -const matches = (schema: Schema, value: Value): boolean => { - if ("anyOf" in schema) return schema.anyOf.some(member => matches(member, value)); - if ("allOf" in schema) return schema.allOf.every(member => matches(member, value)); - if ("const" in schema) return JSON.stringify(schema.const) === JSON.stringify(value); - switch (schema.type) { - case "null": return value === null; - case "boolean": return typeof value === "boolean"; - case "string": return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? Infinity); - case "number": return typeof value === "number" && value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity); - case "integer": return typeof value === "number" && Number.isInteger(value) && ("format" in schema || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); - case "array": return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? Infinity) && value.every(item => matches(schema.items, item)); - case "object": { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const required = schema.required ?? Object.keys(schema.properties); - return Object.entries(value).every(([field, item]) => field in schema.properties && matches(schema.properties[field] ?? { allOf: [] }, item)) - && required.every(field => field in value); - } - default: return false; - } -}; - - const allowsNullSchema = (schema: Schema): boolean => ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); +/** A value as code: JSON, but -0 as itself, which JSON writes as 0. */ +const codeOf = (value: unknown): string => { + if (Object.is(value, -0)) return "-0"; + if (Array.isArray(value)) return `[${value.map(codeOf).join(",")}]`; + if (typeof value === "object" && value !== null) return `{${Object.entries(value).map(([field, item]) => `${JSON.stringify(field)}:${codeOf(item)}`).join(",")}}`; + return JSON.stringify(value); +}; + +/** Whether a schema's value is one a literal default can be: what the build reads from literal types. */ +const isValue = (value: unknown): value is Value => + value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" + || (Array.isArray(value) ? value.every(isValue) : typeof value === "object" && Object.values(value).every(isValue)); + /** What a value declared without a default starts at, by its type: a value, a fresh `Map` or `Set` per world, or why neither. */ type Start = { readonly value: Value } | { readonly fresh: "Map" | "Set" } | { readonly none: string }; /** A schema's zero value: 0, false or "", each item's and field's, a union's literal that is one, or a fresh set or map. */ const zeroOf = (schema: Schema): Start => { + if ("default" in schema && isValue(schema.default)) return { value: schema.default }; if ("const" in schema) return { value: schema.const }; if ("anyOf" in schema) { for (const member of schema.anyOf) if ("const" in member && (member.const === 0 || member.const === false || member.const === "")) return { value: member.const }; @@ -402,12 +393,55 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH return { text: `${path.join(".")}.schema.default`, local: imported === undefined ? first : undefined }; }; - /** The platform class a value that isn't data starts as a fresh, empty instance of, per world: `Map` or `Set`. */ - const freshOf = (type: TypeNode): "Map" | "Set" | null => { + /** + * What a value of `type`, of `schema`, starts at as code: as `zeroOf` says, but each field of a struct and item of + * a tuple written here starting at its own type's default, its namespaced schema's included, by the same rules; + * and the local bindings that code reads. + */ + const startOf = async (type: TypeNode, schema: Schema): Promise<{ readonly text: string; readonly locals: readonly string[] } | { readonly fresh: "Map" | "Set" } | { readonly none: string }> => { + const unwrapped = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; + const inner = unwrapped.type === "TSTypeOperator" && unwrapped.operator === "readonly" && unwrapped.typeAnnotation !== undefined ? unwrapped.typeAnnotation : unwrapped; + const namespaced = "default" in schema ? null : await namespacedDefault(inner, "it"); + if (namespaced !== null) return { text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local] }; + const parts = inner.type === "TSTypeLiteral" && "type" in schema && schema.type === "object" && !("default" in schema) + ? inner.members.flatMap(field => field.type === "TSPropertySignature" && !field.computed && field.key.type === "Identifier" && field.typeAnnotation !== null + ? [{ name: field.key.name, type: field.typeAnnotation.typeAnnotation, schema: schema.properties[field.key.name] }] : []) + : inner.type === "TSTupleType" && "type" in schema && schema.type === "array" && !("default" in schema) + ? inner.elementTypes.flatMap((element, k) => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? [] : [{ name: String(k), type: element, schema: schema.items }]) + : null; + if (parts === null || parts.length === 0) { + const zero = zeroOf(schema); + return "value" in zero ? { text: codeOf(zero.value), locals: [] } : zero; + } + const texts: string[] = []; + const locals: string[] = []; + for (const part of parts) { + const started = part.schema === undefined ? { none: "it isn't a property" } : await startOf(part.type, part.schema); + if (!("text" in started)) return { none: `its ${inner.type === "TSTupleType" ? "item" : "field"} ${part.name} starts at nothing: ${"fresh" in started ? "a set or a map in a struct has no zero value" : started.none}` }; + texts.push(inner.type === "TSTupleType" ? started.text : `${JSON.stringify(part.name)}: ${started.text}`); + locals.push(...started.locals); + } + // Without a namespaced default inside, the schema's zero value says the same, as data. + if (locals.length === 0) { + const zero = zeroOf(schema); + if ("value" in zero) return { text: codeOf(zero.value), locals }; + } + return { text: inner.type === "TSTupleType" ? `[${texts.join(", ")}]` : `{ ${texts.join(", ")} }`, locals }; + }; + + /** The platform class a value that isn't data starts as a fresh, empty instance of, per world: `Map` or `Set`, through the aliases it names. */ + const freshOf = async (type: TypeNode, at: Module, seen: Set): Promise<"Map" | "Set" | null> => { const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; - if (inner.type !== "TSTypeReference" || inner.typeName.type !== "Identifier") return null; - const { name } = inner.typeName; - return (name === "Map" || name === "Set") && !module.types.has(name) && !module.imports.has(name) ? name : null; + if (inner.type !== "TSTypeReference") return null; + const parts = typeNameParts(inner.typeName); + const found = await converter.typeNamed(parts, at); + if (found === "global") { + const [name] = parts; + return parts.length === 1 && (name === "Map" || name === "Set") ? name : null; + } + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return null; + seen.add(found.declaration); + return await freshOf(found.declaration.typeAnnotation, found.module, seen); }; /** A declared default: a literal type, never `typeof` a value. */ @@ -416,10 +450,13 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH return await converter.valueOfType(type, module, new Set()); }; - /** How to give a type's values a start no zero value gives. */ - const workaround = (type: TypeNode): string => { - const name = type.type === "TSTypeReference" ? typeNameParts(type.typeName).join(".") : "Start"; - return `declare \`default:\` as a literal, or name a type whose namespace's schema holds its default: \`type ${name} = ...; const ${name} = { schema: { default: ... } }\``; + /** How to give a type's values a start no zero value gives: by a literal, its schema, or null for a value that isn't data. */ + const workaround = (type: TypeNode, data: boolean): string => { + if (!data) return "let its type allow null, and give it a value once there is one"; + if (type.type === "TSTypeReference" && type.typeArguments !== null) return "declare `default:` as a literal, or give its schema one: `Schema.Type<{ ...; default: ... }>`"; + const name = type.type === "TSTypeReference" ? typeNameParts(type.typeName).join(".") : null; + const named = name === null ? "a type of its own" : `\`${name}\``; + return `declare \`default:\` as a literal, or declare ${named} with a namespace whose schema holds its default: \`type ${name ?? "Start"} = ...; const ${name ?? "Start"} = { schema: { ...; default: ... } }\``; }; /** Where a trait a block of this module declares is registered, which what declares it comes after. */ @@ -436,7 +473,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH return { text: named.join("."), local: imported === undefined ? first : undefined }; } const value = await converter.valueOfType(type, module, new Set()); - return { text: JSON.stringify(value), value }; + return { text: codeOf(value), value }; }; try { @@ -509,8 +546,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH } await checkBuiltIns(key.name, kind, typeNode, schema, builtIns); const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name); - if (schema !== undefined && given !== undefined && !matches(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); - let start = given === undefined ? undefined : JSON.stringify(given); + if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); + let start = given === undefined ? undefined : codeOf(given); let fresh: "Map" | "Set" | null = null; // First a literal, then the type's schema's default; only resources and derived components need a start. const namespaced = given !== undefined || (schema !== undefined && "default" in schema) ? null : await namespacedDefault(typeNode, key.name); @@ -523,18 +560,21 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH // It starts at null, as the world reads a value with neither. } else if (schema === undefined) { - fresh = freshOf(typeNode); - if (fresh === null) fail(module, member, `${kind} ${key.name} isn't data, so it has no zero value: ${workaround(typeNode)}`); + fresh = await freshOf(typeNode, module, new Set()); + if (fresh === null) fail(module, member, `${kind} ${key.name} isn't data, so it has no zero value: ${workaround(typeNode, false)}`); } else { - const zero = zeroOf(schema); + const zero = await startOf(typeNode, schema); if ("fresh" in zero) fresh = zero.fresh; - else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode)}`); + else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode, true)}`); // A constant is the one value it can start at, which the world reads from its schema. - else if (!("const" in schema)) start = JSON.stringify(zero.value); + else if (!("const" in schema)) { + start = zero.text; + for (const local of zero.locals) at = Math.max(at, endOfLocal(local)); + } } } - const parts = schema === undefined ? [] : [`type: ${start === undefined ? JSON.stringify(schema) : `${JSON.stringify(schema).slice(0, -1)},"default":${start}}`}`]; + const parts = schema === undefined ? [] : [`type: ${start === undefined ? codeOf(schema) : `${codeOf(schema).slice(0, -1)},"default":${start}}`}`]; if (schema === undefined && start !== undefined) parts.push(`default: ${start}`); parts.push(...traits); if (fresh !== null) parts.push(`fresh: "${fresh}"`); diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index 509fcb82..d4892985 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -144,7 +144,8 @@ describe("spawn", () => { recordDeclaration(broken, "component", { type: { type: "number" }, reduce: "+" }); await run(commands => { commands.spawn([bit0])({}); }); for (let round = 0; round < 2; round++) { - await expect(run(commands => { commands.spawn([broken])({}); })).rejects.toThrow("only a resource is reduced"); + // Undeclared to the types, so spawned as an untyped caller would. + await expect(run(commands => { Reflect.apply(Reflect.apply(commands.spawn, commands, [[broken]]), undefined, [{}]); })).rejects.toThrow("only a resource is reduced"); } await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/defaults.test.ts b/packages/ecs/src/implementation/world/defaults.test.ts index df89d53e..1894a58b 100644 --- a/packages/ecs/src/implementation/world/defaults.test.ts +++ b/packages/ecs/src/implementation/world/defaults.test.ts @@ -1,20 +1,33 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; import { createWorld } from "../index.js"; import { readResource } from "./reads.test-helper.js"; const pool = Symbol(); const names = Symbol(); const count = Symbol(); +const tagged = Symbol(); +const index = Symbol(); + +/** A pool declared through an alias of Map. */ +type Pool = Map; declare module "@adobe/ecs" { + export interface Components { + [tagged]: { type: true }; + [index]: { type: ReadonlyMap; derived: true }; + } export interface Resources { - [pool]: { type: Map }; + [pool]: { type: Pool }; [names]: { type: ReadonlyMap }; [count]: { type: number }; } } +Rule.create({ components: { with: [tagged] }, derive: [index] }); + describe("a resource declared without a default", () => { it("starts at its type's zero value, and a Map or Set at an empty one of its world's own", async () => { await using first = createWorld(); @@ -28,3 +41,25 @@ describe("a resource declared without a default", () => { expect(await readResource(first, count)).toBe(0); }); }); + +describe("a derived component declared without a default", () => { + it("starts every row of a world at one empty Map of its own, since values are replaced whole", async () => { + const spawn = System.create(({ commands }) => { + commands.spawn([tagged])({}); + commands.spawn([tagged])({}); + }); + const seen: unknown[] = []; + const read = System.create({ observer: true, components: { with: [index] } }, row => { seen.push(row[index]); }); + await using first = createWorld(); + await using second = createWorld(); + for (const world of [first, second]) { + await world.run(spawn); + await world.run(read); + } + expect(seen).toHaveLength(4); + expect(seen[0]).toEqual(new Map()); + expect(seen[1]).toBe(seen[0]); + expect(seen[3]).toBe(seen[2]); + expect(seen[2]).not.toBe(seen[0]); + }); +}); diff --git a/packages/ecs/src/implementation/world/synchronize.ts b/packages/ecs/src/implementation/world/synchronize.ts index fe40b98f..c38c3ad4 100644 --- a/packages/ecs/src/implementation/world/synchronize.ts +++ b/packages/ecs/src/implementation/world/synchronize.ts @@ -17,7 +17,7 @@ import { settleStops } from './stop-lists.js'; import { closureOf, derives, gainsOf } from './structure.js'; import type { Plan } from './plan.js'; import type { MoveLog } from './move-log.js'; -import { archetypeOf, defaultOf, movesOf, withComponent, withoutComponent, type State } from './state.js'; +import { archetypeOf, movesOf, withComponent, withoutComponent, type State } from './state.js'; import { releaseValue } from './transfers.js'; /** Buffers queued for the next sync point, in merge order. Commands in guarded buffers can't touch the resource entity. */ @@ -295,7 +295,8 @@ const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Arche /** Writes a derived component's default into a row it starts in, stamped as added. */ export const writeDefault = (state: State, storage: Storage, row: number, tick: number): void => { // A joined thread's object starts there, as the row's move or append says. - if (storage.remote === null) writeCell(storage, row, defaultOf(state.keys[storage.component])); + // One start per world, never one per row: values are replaced whole, so a shared start is never changed. + if (storage.remote === null) writeCell(storage, row, state.starts[storage.component]); else storage.tags[row] = startTag; if (!storage.ticked) return; storage.added[row] = storage.changed[row] = tick; diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index 0e4112db..ce8b4299 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -79,6 +79,11 @@ describe("registering a component", () => { expect(declarationOf(badReference)).toBe(undefined); expect(kindOf(badReference)).toBe(undefined); }); + it("rejects a default that isn't one of its type's values, however it's given", () => { + expect(() => register(Symbol("typesComponentTest/tallHeight"), { type: { type: "number", precision: 1, default: "tall" } })).toThrow("its default, tall, isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/lowLevel"), { type: { type: "integer", minimum: 1, maximum: 9, default: 0 }, derived: true })).toThrow("isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/floor"), { type: { type: "number", precision: 1, default: -Infinity } })).not.toThrow(); + }); it("rejects a schema whose GPU layout can't hold it", () => { expect(() => register(Symbol("typesComponentTest/storageText"), { type: { type: "array", items: { type: "string" }, layout: "storage" } })).toThrow("layout needs numbers"); }); diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index bea3ef26..ec6b21b6 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -5,7 +5,7 @@ import { declarationsName } from './ids.js'; import { layoutOf } from './layout.js'; import { claimKey, declarationOf, failRegistration, firstKeyOf, installCatchUp, kindOf, labelOf, recordFailure, requireKey, sameDeclaration } from './names.js'; import { isReduction } from './reduction.js'; -import { allowsNull, hasBoolean, isEntity, isString, isStruct } from './schemas.js'; +import { allowsNull, hasBoolean, isEntity, isString, isStruct, matchesSchema } from './schemas.js'; import { addTrait, isTrait } from './traits.js'; /** @@ -54,6 +54,9 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl if ("default" in declaration || (type !== undefined && "default" in type)) fail("it starts fresh, so it has no default."); if (type !== undefined && "type" in type && (type.type === "set" || type.type === "map") && (type.type === "set") !== (fresh === "Set")) fail(`a ${type.type} starts as a fresh ${type.type === "set" ? "Set" : "Map"}.`); } + // A default however given, a literal, its schema's or a namespaced schema's, is one of its type's values. + const start: unknown = "default" in declaration ? declaration.default : type !== undefined && "default" in type ? type.default : undefined; + if (type !== undefined && ("default" in declaration || "default" in type) && !matchesSchema(type, start)) fail(`its default, ${labelOf(start)}, isn't one of its type's values.`); try { layoutOf(type); } diff --git a/packages/ecs/src/interface/registry/schemas.ts b/packages/ecs/src/interface/registry/schemas.ts index 428a67a9..f357a1aa 100644 --- a/packages/ecs/src/interface/registry/schemas.ts +++ b/packages/ecs/src/interface/registry/schemas.ts @@ -72,3 +72,27 @@ export const isEntity = (schema: Schema): boolean => "format" in schema && schem export const conditional = (schema: Schema): boolean => allowsNull(schema) || ("type" in schema && schema.type === "boolean") || ("const" in schema && schema.const === false) || ("anyOf" in schema && schema.anyOf.some(conditional)); + +/** Whether `value` is one of `schema`'s values, as a default must be: its type, length, range and fields. */ +export const matchesSchema = (schema: Schema, value: unknown): boolean => { + if ("anyOf" in schema) return schema.anyOf.some(member => matchesSchema(member, value)); + if ("allOf" in schema) return schema.allOf.every(member => matchesSchema(member, value)); + if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value); + switch (schema.type) { + case "null": return value === null; + case "boolean": return typeof value === "boolean"; + case "string": return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? Infinity); + case "number": return typeof value === "number" && (Number.isNaN(value) || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); + case "integer": return typeof value === "number" && Number.isInteger(value) && ("format" in schema || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); + case "array": return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? Infinity) && value.every(item => matchesSchema(schema.items, item)); + case "set": return value instanceof Set; + case "map": return value instanceof Map; + case "object": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const required = schema.required ?? Object.keys(schema.properties); + return Object.entries(value).every(([field, item]) => field in schema.properties && matchesSchema(schema.properties[field] ?? { allOf: [] }, item)) + && required.every(field => field in value); + } + default: return false; + } +}; From 730fc42fd22ae95fd9e8f9536c35fa1092450302 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:20:05 -0700 Subject: [PATCH 096/127] ecs: lists of symbols take their index where they're made, whenever their keys register, and lists of a namespace's keys too Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/archetypes.test.ts | 18 +++++++++++++++++- .../src/implementation/compiler/archetypes.ts | 17 ++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/archetypes.test.ts b/packages/ecs/src/implementation/compiler/archetypes.test.ts index c2faedb5..016e9b03 100644 --- a/packages/ecs/src/implementation/compiler/archetypes.test.ts +++ b/packages/ecs/src/implementation/compiler/archetypes.test.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, test } from 'vitest'; import { parseAst } from 'vite'; -import { archetypeInsertions } from './archetypes.js'; +import { archetypeFunction, archetypeInsertions } from './archetypes.js'; +import { archetypeIndexKey } from '../../interface/registry/archetypes.js'; import { insertInto } from './source-map.js'; import { moduleOf } from './type-schemas.js'; @@ -39,6 +40,21 @@ describe("archetypeInsertions", () => { test("leaves a module binding a global the appended function uses", () => { expect(passed(`${keys}const Object = 1;\nconst Todo = [todo, done] as const;`)).toBe(null); }); + test("gives an index to a list of a namespace's keys", () => { + expect(passed(`import { Report } from "@adobe/ecs";\nconst Reported = [Report.run, Report.errors] as const;`)) + .toBe(`import { Report } from "@adobe/ecs";\nconst Reported = archetype$([Report.run, Report.errors] as const);`); + }); + test("appends a function indexing a list of symbols each listed once, whether or not their declarations registered yet", () => { + const made: unknown = new Function(`${archetypeFunction("index")}\nreturn index;`)(); + const index = (list: readonly unknown[]): unknown => typeof made === "function" ? Reflect.apply(made, undefined, [list]) : null; + const [first, second] = [Symbol(), Symbol()]; + const list = [first, second]; + expect(index(list)).toBe(list); + expect(typeof Reflect.get(list, archetypeIndexKey)).toBe("number"); + const twice = [first, first]; + index(twice); + expect(Reflect.get(twice, archetypeIndexKey)).toBe(undefined); + }); test("appends a function giving each list its index once", () => { const code = `${keys}const Todo = [todo, done] as const;`; const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts"))); diff --git a/packages/ecs/src/implementation/compiler/archetypes.ts b/packages/ecs/src/implementation/compiler/archetypes.ts index db2f0f80..00602378 100644 --- a/packages/ecs/src/implementation/compiler/archetypes.ts +++ b/packages/ecs/src/implementation/compiler/archetypes.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { archetypeCountName, archetypeIndexName } from '../../interface/registry/archetypes.js'; -import { declarationsName } from '../../interface/registry/ids.js'; import type { PassResult } from './input-schemas.js'; import type { Insertion } from './source-map.js'; import type { Module, Node, Statement } from './type-schemas.js'; @@ -8,14 +7,15 @@ import type { Module, Node, Statement } from './type-schemas.js'; /** * The function the plugin appends, as `name`, to a module with lists of keys it can read: it gives a list a * program-wide index, by which worlds find what they resolved for it without comparing components, when every member is - * a component a module declared, each once, and returns the list. Generated code imports nothing, so it reads the - * declarations and shares the count through globals, as every copy of the package does (`registry/archetypes.ts`). + * a symbol, each once, and returns the list. It runs where the list is made, before any key's declaration may be + * registered, so whether they're components is checked where the list is used. Generated code imports nothing, so it + * shares the count through a global, as every copy of the package does (`registry/archetypes.ts`). */ export const archetypeFunction = (name: string): string => [ `function ${name}(list) {`, - ` const declared = globalThis[Symbol.for(${JSON.stringify(declarationsName)})], index = Symbol.for(${JSON.stringify(archetypeIndexName)}), count = Symbol.for(${JSON.stringify(archetypeCountName)});`, - ` if (!declared || Object.prototype.hasOwnProperty.call(list, index) || !Object.isExtensible(list)) return list;`, - ` for (let k = 0; k < list.length; k++) { const entry = declared.get(list[k]); if (!entry || entry.kind !== "component" || list.indexOf(list[k]) < k) return list; }`, + ` const index = Symbol.for(${JSON.stringify(archetypeIndexName)}), count = Symbol.for(${JSON.stringify(archetypeCountName)});`, + ` if (Object.prototype.hasOwnProperty.call(list, index) || !Object.isExtensible(list)) return list;`, + ` for (let k = 0; k < list.length; k++) if (typeof list[k] !== "symbol" || list.indexOf(list[k]) < k) return list;`, ` const next = globalThis[count] || 0; globalThis[count] = next + 1; Object.defineProperty(list, index, { value: next });`, ` return list;`, `}`, @@ -84,7 +84,7 @@ export const archetypeInsertions = (module: Module): PassResult | null => { } } } - if (keys.size === 0) return null; + if (keys.size === 0 && module.imports.size === 0) return null; /** The module's top-level constants holding lists it indexes, which other lists may spread. */ const lists = new Set(); const insertions: Insertion[] = []; @@ -96,6 +96,9 @@ export const archetypeInsertions = (module: Module): PassResult | null => { const named: string[] = []; for (const element of list.expression.elements) { if (element?.type === "Identifier" && keys.has(element.name)) named.push(element.name); + // A namespace's member, `Report.run`, is a key of the package or a module imported. + else if (element?.type === "MemberExpression" && !element.computed && element.object.type === "Identifier" && element.property.type === "Identifier" + && module.imports.get(element.object.name)?.value === true) named.push(`${element.object.name}.${element.property.name}`); else if (element?.type !== "SpreadElement" || element.argument.type !== "Identifier" || !(lists.has(element.argument.name) || module.imports.get(element.argument.name)?.value === true)) return false; } const twice = named.find((member, k) => named.indexOf(member) < k); From e04f41687e5cfd85f12e7e3453069025d5d684ca Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:21:51 -0700 Subject: [PATCH 097/127] ecs: tests of every member a key gets wrong, and of two copies of the package in one application Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/exports.test.ts | 27 +++++++++ .../implementation/compiler/plugin.test.ts | 55 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index e390651f..6624b0c6 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -149,6 +149,22 @@ declare module "@adobe/ecs" { interface Resources { [label]: { type: string; default: 1 } } }`); +/** One key with several members that don't hold: a custom trait and a thread on a string. */ +const severalTraits = creating(` +export const instanced = Symbol(); +export const name = Symbol(); +declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never } + interface Components { [name]: { type: string; [instanced]: true; thread: "main" } } +}`); + +/** One key whose default is of another type and which can't be transferred. */ +const defaultAndTransfer = creating(` +export const canvas = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [canvas]: { type: string; default: 1; transfer: true } } +}`); + describe("package exports", () => { beforeAll(() => { install(); @@ -188,6 +204,17 @@ describe("package exports", () => { } expect(compile(creating("")).messages).toEqual([]); }, 60_000); + it("name every member of a key that doesn't hold", () => { + const cases = [ + { source: severalTraits, named: [`readonly [instanced]: "isn't allowed on this key"`, `readonly thread: "isn't allowed on this key"`] }, + { source: defaultAndTransfer, named: [`readonly default: "isn't of the declared type"`, `readonly transfer: "isn't allowed on this key"`] }, + ]; + for (const { source, named } of cases) { + const { messages: reported, lines } = compile(source); + expect(lines).toEqual(linesWith(source, "World.create(")); + for (const message of reported) for (const member of named) expect(message).toContain(member); + } + }, 60_000); it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => { const reported = compile(misdeclared).messages; expect(reported.some(message => message.includes("No overload matches"))).toBe(false); diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index e2495536..47c716c4 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -606,3 +606,58 @@ test("a build fails at a system's input whose type can't be one", async () => { build: { outDir: join(directory, "dist-bad-input"), minify: false, lib: { entry, formats: ["es"], fileName: "main" } }, })).rejects.toThrow("(typeof X)[keyof typeof X]"); }); + +test("two copies of the package in one application register the keys it declares, and index lists apart", async () => { + // The application's copy, and another a library installs under its own node_modules. + const project = join(directory, "copies"); + await installCopy(project); + const library = join(project, "node_modules/other-library"); + await installCopy(library); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "copies-app", type: "module" })); + mkdirSync(join(library, "dist"), { recursive: true }); + writeFileSync(join(library, "package.json"), JSON.stringify({ name: "other-library", type: "module", exports: "./dist/index.js" })); + // Its systems are made when called, spawning a list made on every call. + writeFileSync(join(library, "dist/index.js"), `import { System, World } from "@adobe/ecs"; +export { World as OtherWorld }; +export const runOther = async (key) => { + const spawn = System.create(({ commands }) => { commands.spawn([key].slice())({ [key]: 10 }); }); + let total = 0; + const count = System.create({ observer: true, components: { with: [key] } }, (row) => { total += row[key]; }); + const world = World.create(); + await world.run(spawn); + await world.run(spawn); + await world.run(count); + await world.dispose(); + return total; +}; +`); + const application = join(project, "application.js"); + writeFileSync(application, `import { System, World } from "@adobe/ecs"; +import { OtherWorld, runOther } from "other-library"; +export const score = Symbol(); +declare$(score, "component", { type: { type: "number" } }); +${recorder} +const spawn = System.create(({ commands }) => { commands.spawn([score].slice())({ [score]: 1 }); }); +let counted = 0; +const count = System.create({ observer: true, components: { with: [score] } }, (row) => { counted += row[score]; }); +export const copies = World === OtherWorld ? 1 : 2; +export const run = async () => { + const world = World.create(); + await world.run(spawn); + await world.run(spawn); + await world.run(count); + await world.dispose(); + return [counted, await runOther(score)]; +}; +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === application); + const built: { copies: number; run: () => Promise } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(built.copies).toBe(2); + expect(await built.run()).toEqual([2, 20]); +}, 120_000); From cfc97eafd616e0b26ff10384db01c3afa46fb636 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:27:24 -0700 Subject: [PATCH 098/127] ecs: a change inside a data value fails its system's creation; one inside an object, as DOM bindings make, is read Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/body-access.ts | 22 +++++++++------- .../src/implementation/compiler/inferred.ts | 16 +++++++----- .../implementation/compiler/transform.test.ts | 10 ++++--- .../src/interface/registry/archetypes.test.ts | 6 ++--- .../ecs/src/interface/system/resolve.test.ts | 13 +++++++++- packages/ecs/src/interface/system/resolve.ts | 26 ++++++++++++++++--- 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/body-access.ts b/packages/ecs/src/implementation/compiler/body-access.ts index 836d02f7..6b9b5b81 100644 --- a/packages/ecs/src/implementation/compiler/body-access.ts +++ b/packages/ecs/src/implementation/compiler/body-access.ts @@ -3,7 +3,8 @@ import type { Node, ObjectExpression, Pattern } from 'estree'; import { children, declaredIn, patternNames } from './estree.js'; import type { ModuleScope } from './module-scope.js'; -type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; +/** Keys read and written, and those changed inside their values (`row[k].a = 1`), which registration judges by kind. */ +type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet; readonly mutate: ReadonlySet }; /** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ export type Used = { @@ -29,10 +30,11 @@ const isName = (node: Node, parent: Node | undefined): boolean => { } }; -type Use = { readonly read: boolean; readonly write: boolean }; +type Use = { readonly read: boolean; readonly write: boolean; readonly mutate?: true }; const read: Use = { read: true, write: false }; const written: Use = { read: false, write: true }; +const mutated: Use = { read: true, write: false, mutate: true }; /** * How `member`, a `name[key]` expression, is used: read, written, or both; null when it's deleted. A target of a @@ -53,9 +55,9 @@ const useOf = (member: Node, parent: Node | undefined, grandparent: Node | undef }; /** - * How `member`, a `name[key]` expression, is used where `ancestors` ends with its parent: as `useOf` says, and written - * too when something inside its value is (`row[key].a.b = 1`, `delete row[key].a`, `[row[key][0]] = pair`), which - * replaces nothing but changes the value all the same. + * How `member`, a `name[key]` expression, is used where `ancestors` ends with its parent: as `useOf` says, and changed + * in place when something inside its value is written (`row[key].a.b = 1`, `delete row[key].a`, `[row[key][0]] = pair`): + * an object changes so, while a data value, replaced whole, can't, which registration tells apart by its kind. */ const deepUseOf = (member: Node, ancestors: readonly Node[], at: number): Use | null => { let inner = member; @@ -66,7 +68,7 @@ const deepUseOf = (member: Node, ancestors: readonly Node[], at: number): Use | } const use = useOf(inner, ancestors[index], ancestors[index - 1]); if (inner === member) return use; - return use === null || use.write ? { read: true, write: true } : read; + return use === null || use.write ? mutated : read; }; /** Whether a lookup result `value`, a child of `parent`, is only tested: compared with null, or used as a condition. */ @@ -143,8 +145,8 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const lookups = lookupNames(declaration); const indexed = indexLookupNames(declaration); - const query = { read: new Set(), write: new Set() }; - const resources = { read: new Set(), write: new Set(), reduce: new Set() }; + const query = { read: new Set(), write: new Set(), mutate: new Set() }; + const resources = { read: new Set(), write: new Set(), mutate: new Set(), reduce: new Set() }; const up = new Set(); let upKnown = true; const lookupReads = new Map | null>((lookups ?? []).map(name => [name, new Set()])); @@ -332,7 +334,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module } if (group === "up") { const use = deepUseOf(parent, ancestors, at - 1); - if (use === null || use.write) upKnown = false; + if (use === null || use.write || use.mutate) upKnown = false; else up.add(key); return; } @@ -340,6 +342,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module if (use === null) return unknownGroup(group); if (use.read) resources.read.add(key); if (use.write) resources.write.add(key); + if (use.mutate) resources.mutate.add(key); }; /** Classifies a reference; `ancestors` ends with its parent. */ @@ -361,6 +364,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module else { if (use.read) query.read.add(key); if (use.write) query.write.add(key); + if (use.mutate) query.mutate.add(key); } } else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) queryKnown = false; diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts index 5e2d03e6..9a3967b5 100644 --- a/packages/ecs/src/implementation/compiler/inferred.ts +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -32,7 +32,7 @@ export const inOrder = (used: Used, declaration: ObjectExpression): Used => { const lookups = fields(valueOf(declared.get("lookup"))); return { ...used, - query: used.query === null ? null : { read: ordered(used.query.read, members), write: ordered(used.query.write, members) }, + query: used.query === null ? null : { read: ordered(used.query.read, members), write: ordered(used.query.write, members), mutate: ordered(used.query.mutate, members) }, lookups: used.lookups === null ? null : new Map([...used.lookups].map(([name, read]) => [name, read === null ? null : ordered(read, namesIn(valueOf(fields(valueOf(lookups.get(name))).get("with"))))])), }; @@ -77,8 +77,10 @@ const list = (names: Iterable): string => `[${[...names].join(", ")}]`; */ export const accessText = (used: Used, at: string): string => { const { query, resources, lookups, up } = used; - const components = query === null ? "null" : `{ read: ${list([...query.read].filter(key => !query.write.has(key)))}, write: ${list(query.write)} }`; - const resourceText = resources === null ? "null" : `{ read: ${list(resources.read)}, write: ${list(resources.write)}, reduce: ${list(resources.reduce)} }`; + // Keys changed inside their values are listed only when there are some, for registration to judge by kind. + const mutate = (keys: ReadonlySet): string => keys.size === 0 ? "" : `, mutate: ${list(keys)}`; + const components = query === null ? "null" : `{ read: ${list([...query.read].filter(key => !query.write.has(key)))}, write: ${list(query.write)}${mutate(query.mutate)} }`; + const resourceText = resources === null ? "null" : `{ read: ${list(resources.read)}, write: ${list(resources.write)}, reduce: ${list(resources.reduce)}${mutate(resources.mutate)} }`; const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`; return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}, at: ${JSON.stringify(at)} }`; }; @@ -133,10 +135,12 @@ export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): switch (original.key.name) { case "components": { if (value?.type !== "ObjectExpression" || used.query === null) break; - const { read, write } = used.query; + const { read, write, mutate } = used.query; + // A change inside a value is a write to the compiler, which rejects it for values written whole. + const written = new Set([...write, ...mutate]); properties.push(property("components", object([ - property("read", array([...read].filter(key => !write.has(key)))), - property("write", array(write)), + property("read", array([...read].filter(key => !written.has(key)))), + property("write", array(written)), ...keep(parts, ["with", "not", "added", "changed"]), ]))); continue; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index bb2dd0eb..b5ced35b 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1462,13 +1462,15 @@ describe("inferred access", () => { .toContain("components: { read: [position], write: [] }"); }); - test("a change inside a value, at any depth, as a write of it", async () => { + test("a change inside a value, at any depth, as a change in place, for registration to judge by its kind", async () => { expect(await inferred(system("components: { with: [position] }", "(row) => { row[position].a.b = 1; }"))) - .toContain("components: { read: [], write: [position] }"); + .toContain("components: { read: [position], write: [], mutate: [position] }"); expect(await inferred(system("components: { with: [position] }", "(row) => { delete row[position].a; }"))) - .toContain("components: { read: [], write: [position] }"); + .toContain("components: { read: [position], write: [], mutate: [position] }"); expect(await inferred(system("components: { with: [position] }", "(row) => { [row[position][0]] = pair; }"))) - .toContain("components: { read: [], write: [position] }"); + .toContain("components: { read: [position], write: [], mutate: [position] }"); + expect(await inferred(system("", "(context) => { context.resources[score].a = 1; }"))) + .toContain("resources: { read: [score], write: [], reduce: [], mutate: [score] }"); }); test("a body mentioning eval as one it can't follow", async () => { diff --git a/packages/ecs/src/interface/registry/archetypes.test.ts b/packages/ecs/src/interface/registry/archetypes.test.ts index cc12fd35..eb033380 100644 --- a/packages/ecs/src/interface/registry/archetypes.test.ts +++ b/packages/ecs/src/interface/registry/archetypes.test.ts @@ -24,7 +24,7 @@ declare module "@adobe/ecs" { /** Lists the build gives indexes: top-level lists of components declared as const. */ const Particle = [position, velocity] as const; const Moving = [velocity] as const; -/** A list naming a resource isn't a list of components, so it has no index. */ +/** A list naming a resource takes an index too: where it's used, it's found not to be a list of components. */ const WithResource = [position, gravity] as const; describe("archetype indexes", () => { @@ -34,9 +34,9 @@ describe("archetype indexes", () => { expect(archetypeIndexOf(Moving)).not.toBe(archetypeIndexOf(Particle)); expect(Object.keys(Particle)).toEqual(["0", "1"]); }); - it("aren't given to a list made at runtime, or one naming anything but components", () => { + it("aren't given to a list made at runtime, and are to one of symbols whatever they key", () => { expect(archetypeIndexOf([position, velocity])).toBe(-1); - expect(archetypeIndexOf(WithResource)).toBe(-1); + expect(archetypeIndexOf(WithResource)).toBeGreaterThanOrEqual(0); }); it("are given to the package's own lists once, and never twice", () => { const list = [position]; diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index 14466425..70d2a8b4 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -1,13 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { componentLists, lookupsLists, type Access } from "./resolve.js"; +import "../registry/declarations.js"; +import { componentLists, lookupsLists, resourceUses, type Access } from "./resolve.js"; const mass = Symbol("resolveTest/mass"); +const pool = Symbol("resolveTest/pool"); const position = Symbol("resolveTest/position"); declare module "@adobe/ecs" { interface Components { [mass]: { type: number }; [position]: { type: number }; + [pool]: { type: Map; derived: true }; } } const access = (lookup: Access["lookup"]): Access => ({ components: null, resources: null, lookup, up: null, at: "a test" }); @@ -27,3 +30,11 @@ describe("componentLists", () => { expect(componentLists({ with: [mass], changed: [position] }, null, false)).toEqual({ write: [mass, position], changed: [position] }); }); }); + +describe("resourceUses", () => { + const changing = (key: symbol): Access => ({ components: { read: [key], write: [], mutate: [key] }, resources: { read: [], write: [], reduce: [] }, lookup: null, up: null, at: "a test" }); + it("rejects a change inside a data value, given as a copy, and takes one inside an object as a read", () => { + expect(() => resourceUses("System \"s\"", changing(mass), undefined)).toThrow(`changes something inside its row's "resolveTest/mass", a value given as a copy`); + expect(() => resourceUses("System \"s\"", changing(pool), undefined)).not.toThrow(); + }); +}); diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts index 2aaf9ca4..c49e914a 100644 --- a/packages/ecs/src/interface/system/resolve.ts +++ b/packages/ecs/src/interface/system/resolve.ts @@ -1,13 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { takeAccessOf } from '../registry/ids.js'; import { indexDefinitionOf } from '../registry/indexes.js'; -import { declarationOf } from '../registry/names.js'; +import { declarationOf, labelOf } from '../registry/names.js'; +import { isString, isStruct } from '../registry/schemas.js'; import type { ComponentAccess, ComponentLists, Lookup, SystemLookup, SystemLookupLists } from './access.js'; type Key = symbol; -/** What a body reads and writes of a group of keys. */ -type Used = { readonly read: readonly Key[]; readonly write: readonly Key[] }; +/** What a body reads and writes of a group of keys, and those it changes inside their values, read as well. */ +type Used = { readonly read: readonly Key[]; readonly write: readonly Key[]; readonly mutate?: readonly Key[] }; /** * What the build infers a body uses, which the plugin gives the declaration or body a constructor is called with: what @@ -26,7 +27,8 @@ export type Access = { const isKeys = (value: unknown): value is readonly Key[] => Array.isArray(value) && value.every(key => typeof key === "symbol"); const isUsed = (value: unknown): value is Used => typeof value === "object" && value !== null - && isKeys(Reflect.get(value, "read")) && isKeys(Reflect.get(value, "write")); + && isKeys(Reflect.get(value, "read")) && isKeys(Reflect.get(value, "write")) + && (Reflect.get(value, "mutate") === undefined || isKeys(Reflect.get(value, "mutate"))); const isAccess = (value: unknown): value is Access => { if (typeof value !== "object" || value === null) return false; @@ -50,6 +52,21 @@ export const accessOf = (what: string, made: object): Access => { return access; }; +/** + * Throws for a body changing something inside a data value, a struct or a string, which a row or the resources give + * as a copy and take only whole, so the change would be lost. An object, changed in place, is read. + */ +const checkChanges = (what: string, access: Access): void => { + for (const [group, keys] of [["its row's", access.components?.mutate], ["resources'", access.resources?.mutate]] as const) { + for (const key of keys ?? []) { + const type = declarationOf(key)?.type; + if (type !== undefined && (isStruct(type) || isString(type))) { + throw new Error(`${what}'s body (${access.at}) changes something inside ${group} "${labelOf(key)}", a value given as a copy and taken only whole: assign it a new value instead.`); + } + } + } +}; + /** Throws for a group the build couldn't follow, naming what the body did instead. */ const followed = (what: string, used: T | null, group: string, access: Access): T => { if (used !== null) return used; @@ -116,6 +133,7 @@ export const lookupsLists = (lookups: { readonl * into stays, for registration to reject. */ export const resourceUses = (what: string, access: Access, is: readonly Key[] | undefined): { readonly read: Key[]; readonly write: readonly Key[]; readonly reduce: readonly Key[] } => { + checkChanges(what, access); const used = followed(what, access.resources, "its resources", access); return { read: without(used.read, used.write, is), write: used.write, reduce: used.reduce }; }; From 4fbb3f2ee3906480b41201d7fc2a650068cfb05d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:29:15 -0700 Subject: [PATCH 099/127] ecs: the guide explains lookups and indexes before their code; design notes on lazy failures, list indexes and changes inside values Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 6 ++-- packages/ecs/src/guide/12-lookups.test.ts | 4 +-- packages/ecs/src/guide/13-indexes.test.ts | 2 +- packages/ecs/src/guide/guide.md | 36 ++++++++++++++++++----- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 71d2fe13..132c1797 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -40,7 +40,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **A system's read/write sets, which the build infers from its body, drive both ordering and parallelism.** - **Systems that touch main-thread-only components are pinned to the main thread.** - **Structural changes become visible only at sync points,** whether they come from the main thread or from workers. -- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. +- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. A body changing something inside a value through its row or resources (`row[k].a.b = 1`, `delete row[k].a`) fails the system's creation when the value is data, a struct or a string, which rows and resources give as copies, so the change would be lost; inside an object, such as a DOM node a binding sets properties of, it's a change in place, read and tracked by nothing, as a method changing one is (`resources[map].set(…)`): declare objects that change with readonly types (`ReadonlyMap`) and replace them whole where other systems must see the change. A start shared by every row and by a world's resource, such as one empty `Map` per world, is never changed for the same reason. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. - **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written (as read in an observer), and where it can't follow a lookup's rows, all it requires is taken as read, each with a build warning; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. @@ -73,7 +73,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. - **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Rule`, as `Component`, `Resource` and `Gpu`, names only a namespace. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype is a list of components declared `as const`. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails then is thrown once, from that read, and given as the reason the key's uses fail. A library's shipped build records its keys the same way. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails is recorded for its own key alone: a read of another key never throws for it, and every use of its key fails, each time, giving it as the reason, so a system using it fails in each run's report. A library's shipped build records its keys the same way. The check of declarations at `World.create` holds only where it's called as the package's `World.create`: assigned first to a variable typed `() => World`, it's no longer checked, a limit of checking through `this`; registration still checks what values show. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. @@ -90,7 +90,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, whatever other keys have, so a label never changes as modules load; an error about an id names the id too. Labels are for display; nothing looks a key up by its label. -- **Archetypes:** an archetype is a list of components declared `as const`, a readonly tuple, which goes wherever component lists do (spawns, adds and removes, systems, indexes, rules, finalizers). There's no constructor: the build gives every list it can see an index. Before TypeScript is stripped it wraps each list declared `as const` that the module evaluates as it loads, outside functions, whose members are its `Symbol()` keys, imports or spreads of such lists, as `archetype$([...] as const)` (`compiler/archetypes.ts`). After, it hoists each array literal of keys a main-thread body passes its commands into a module variable made once, `(list$0 ??= archetype$([a, b]))`, so the call allocates nothing (`compiler/spawns.ts`). The appended function, which imports nothing, gives a list a program-wide index in the property `Symbol.for("@adobe/ecs/archetype@1")`, counted by a global every copy of the package shares, when every member is a component a module declared, each once, and the list is extensible; worlds find what they resolved for it by that index without comparing components. A list naming one of its module's keys twice fails the build. Without `as const`, a list's keys widen to `symbol`, which no list of components takes, so the types require it. A list made at runtime is resolved by its keys among the eight recent ones, and once resolved keeps a record of its keys and an index in the same property, which holds while its keys stay the same, since such a list may change. The package's own lists, built without the plugin, take their indexes from `indexArchetype`. A resource or string in a list is a type error. The kernel compiler reads an array literal, or a constant holding one, indexed or not, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. +- **Archetypes:** an archetype is a list of components declared `as const`, a readonly tuple, which goes wherever component lists do (spawns, adds and removes, systems, indexes, rules, finalizers). There's no constructor: the build gives every list it can see an index. Before TypeScript is stripped it wraps each list declared `as const` that the module evaluates as it loads, outside functions, whose members are its `Symbol()` keys, imports, a namespace's members (`Report.run`) or spreads of such lists, as `archetype$([...] as const)` (`compiler/archetypes.ts`). After, it hoists each array literal of keys a main-thread body passes its commands into a module variable made once, `(list$0 ??= archetype$([a, b]))`, so the call allocates nothing (`compiler/spawns.ts`), only when no name declared around the body, nor its parameters, shadows a key it names, since the hoisted list is made once. The appended function, which imports nothing, gives a list a program-wide index in the property `Symbol.for("@adobe/ecs/archetype@1")`, counted by a global every copy of the package shares, when every member is a symbol, each once, and the list is extensible: it runs where the list is made, perhaps before its keys' declarations register, so whether they're components is checked where the list is used; worlds find what they resolved for it by that index without comparing components. A list naming one of its module's keys twice fails the build. Without `as const`, a list's keys widen to `symbol`, which no list of components takes, so the types require it. A list made at runtime is resolved by its keys among the eight recent ones; else its keys, in order, find the one index every list of them shares (`internArchetype`), given the first time, so lists made on every call take no index of their own and allocate nothing once seen. The list keeps that record of its keys and index in the same property, which holds while its keys stay the same, since such a list may change. The package's own lists, built without the plugin, take their indexes from `indexArchetype`. A resource or string in a list is a type error. The kernel compiler reads an array literal, or a constant holding one, indexed or not, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts export const follow = System.create({ diff --git a/packages/ecs/src/guide/12-lookups.test.ts b/packages/ecs/src/guide/12-lookups.test.ts index 9df84bb5..79b146f9 100644 --- a/packages/ecs/src/guide/12-lookups.test.ts +++ b/packages/ecs/src/guide/12-lookups.test.ts @@ -16,8 +16,8 @@ declare module "@adobe/ecs" { } } -// A lookup reads another entity's components by its id: its row, or null when the entity doesn't have every component -// in with, or has one in not. +// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what +// with lists. const track = System.create({ components: { with: [target, aim] }, lookup: { enemy: { with: [health], not: [frozen] } }, diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts index fafc73d0..adaac90d 100644 --- a/packages/ecs/src/guide/13-indexes.test.ts +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -18,7 +18,7 @@ declare module "@adobe/ecs" { } } -// An index finds entities by their components' values: here, those with a crowded count, by their cell. +// Here, entities with a crowded count, by their cell. const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index e2eec1e4..d1b26b02 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -533,9 +533,12 @@ check, typeof transform | typeof ### 12. Lookups +A lookup reads other entities from a system: by id, as here, or by value through an index (next section). It's named in +the declaration and called from the body, it's read-only, and it doesn't change which entities the system runs on. + ```ts -// A lookup reads another entity's components by its id: its row, or null when the entity doesn't have every component -// in with, or has one in not. +// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what +// with lists. const track = System.create({ components: { with: [target, aim] }, lookup: { enemy: { with: [health], not: [frozen] } }, @@ -566,8 +569,16 @@ To find entities by their values rather than their ids, look up through an index ### 13. Indexes +An index is a declared map from component values to the entities holding them, which every world keeps current. A +lookup through it finds entities by value, as a lookup by entity finds one by id. + +- A hash index, the default, gives the rows with a key. +- `sorted: true` also takes ranges, and orders a system's rows with `order`. +- `unique: true` gives one row, or null. +- `key: []` holds every entity it matches. + ```ts -// An index finds entities by their components' values: here, those with a crowded count, by their cell. +// Here, entities with a crowded count, by their cell. const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. @@ -621,9 +632,10 @@ const countPlayers = System.create({ observer: true, lookup: { players: { index: [`13-indexes.test.ts`](13-indexes.test.ts#L52-L58) -A list stays valid until its call site runs again. Rows hold what every entity in the index has: its key and its -`with`. `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A -lookup's `changed: true` runs its system only when that happened anywhere in the index. +A list has `length`, `changed` and `at(i)`. It's reused, valid until its call site runs again, each row as long as its +list, and costs only what the body reads. Rows hold what every entity in the index has: its key and its `with`. +`list.changed` says whether an entity entered, left or moved in the list since the system last ran; a lookup's +`changed: true` runs its system only when that happened anywhere in the index. ### 14. Kernels @@ -793,6 +805,16 @@ const listen = System.create({ observer: true }, ({ resources }) => { [`19-threads.test.ts`](19-threads.test.ts#L8-L31) ```ts +const spawn = System.create(({ commands }) => { + commands.spawn([volume])({ [volume]: 0.8 }); + commands.spawn([volume])({ [volume]: 0.3 }); +}); + +/** Waits a turn of the event loop at a time until `done`. */ +const until = async (done: () => boolean): Promise => { + while (!done()) await new Promise(resolve => setTimeout(resolve, 1)); +}; + it("shares a world with another thread", async () => { // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other. const channel = new MessageChannel(); @@ -808,4 +830,4 @@ it("shares a world with another thread", async () => { }); ``` -[`19-threads.test.ts`](19-threads.test.ts#L43-L55) +[`19-threads.test.ts`](19-threads.test.ts#L33-L55) From 7aa2476fcd66109a9538c6d68f94b5393eb722e7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:29:59 -0700 Subject: [PATCH 100/127] ecs: follow-ups queue a generated API reference, published without naming packages Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 58646911..8a074def 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -4,6 +4,11 @@ Open items awaiting a decision or work. Remove an item once it's decided or done ## Queued (decided, not started) +- **Generated API reference, once the API work is done.** typedoc over the public entry points (`src/index.ts` and + `@adobe/ecs/compiler`), from a uniform `docs` script, linked from the README beside the guide. Publishing: today + `.github/workflows/deploy-docs.yml` names each package; make it run `pnpm -r run docs` and collect every package's + output into Pages instead of adding another named step (a monorepo change, `packages/data` included). + ## Decisions for the maintainers - **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every From 3751b8ce26157144a9c5753b075bebdd692f4e5c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 23 Oct 2026 23:44:41 -0700 Subject: [PATCH 101/127] ecs: build warnings name modules by their path in their package; no warnings for rows taken as read An observer's row the build can't follow is read whole, and a lookup's rows hold what its declaration says, so neither warns; only a row taken as written whole does, at path:line:column. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../implementation/compiler/identify.test.ts | 11 ++++++- .../src/implementation/compiler/identify.ts | 31 +++++++++++++------ .../ecs/src/implementation/compiler/plugin.ts | 4 +-- .../implementation/compiler/transform.test.ts | 16 ++++++++++ .../src/implementation/compiler/transform.ts | 14 ++++++--- .../ecs/src/interface/system/resolve.test.ts | 3 ++ 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 132c1797..e2dd913e 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -43,7 +43,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. A body changing something inside a value through its row or resources (`row[k].a.b = 1`, `delete row[k].a`) fails the system's creation when the value is data, a struct or a string, which rows and resources give as copies, so the change would be lost; inside an object, such as a DOM node a binding sets properties of, it's a change in place, read and tracked by nothing, as a method changing one is (`resources[map].set(…)`): declare objects that change with readonly types (`ReadonlyMap`) and replace them whole where other systems must see the change. A start shared by every row and by a world's resource, such as one empty `Map` per world, is never changed for the same reason. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. - **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. - It finds systems by resolving the import binding of the system factory. - - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written (as read in an observer), and where it can't follow a lookup's rows, all it requires is taken as read, each with a build warning; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. + - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written, with a build warning naming the module by its path in its package, line and column; an observer's is taken as read, and where it can't follow a lookup's rows, all it requires is taken as read, without a warning, since that is what their declarations say; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index 73630ef8..6d8a2dda 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { parseAst } from 'vite'; import { idOfKey, takeIdOfDeclaration } from '../../interface/registry/ids.js'; import { estreeOf } from './estree.js'; -import { identify as identifyModule, moduleIdOf } from './identify.js'; +import { identify as identifyModule, moduleIdOf, moduleLabelOf } from './identify.js'; import { ownCopy } from './paths.js'; import { decodeMappings } from './source-map.js'; @@ -159,6 +159,15 @@ describe("identify, for systems, rules and finalizers", () => { }); }); +describe("moduleLabelOf", () => { + test("is the module's path in its package, else a dependency's package and subpath, else its file's name", () => { + const app = (directory: string) => directory === "/repo/app" ? { name: "app", version: "1.0.0" } : undefined; + expect(moduleLabelOf("/repo/app/src/features/a/model.ts?t=1", app)).toBe("src/features/a/model.ts"); + expect(moduleLabelOf("/x/node_modules/keys/index.js", () => undefined)).toBe("keys/index.js"); + expect(moduleLabelOf("/loose/model.ts", () => undefined)).toBe("model.ts"); + }); +}); + describe("moduleIdOf", () => { /** Packages by directory, as their package.json files name and version them. */ const packages = (named: { readonly [directory: string]: { readonly name: string; readonly version?: string } | null }) => (directory: string) => { diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index b147b92c..88f2f53a 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -19,6 +19,21 @@ const dependency = /\/node_modules\/(?!.*\/node_modules\/)(.+)$/; /** What a directory's package.json gives: undefined without one, null for one without a name. */ export type PackageOf = (directory: string) => { readonly name: string; readonly version?: string } | null | undefined; +/** A module's file without a query, with forward slashes. */ +const pathOf = (file: string): string => (file.split("?")[0] ?? file).replace(/\\/g, "/"); + +/** The nearest package.json above a module that has a name, with the module's path in it. */ +const packageAbove = (file: string, packageOf: PackageOf): { readonly name: string; readonly version?: string; readonly path: string } | null => { + const path = pathOf(file); + for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { + const found = packageOf(directory); + if (found !== null && found !== undefined) return { ...found, path: posix.relative(directory, path) }; + // A filesystem's root, `/` or a drive's `C:`, whose parent posix paths give as itself or as `.`. + const parent = posix.dirname(directory); + if (parent === directory || parent === ".") return null; + } +}; + /** * A module's id: the name and version of the nearest package.json above it that has a name, and its path in that * package, else for a dependency its package name and subpath; null outside both. Without a query and with forward @@ -26,17 +41,15 @@ export type PackageOf = (directory: string) => { readonly name: string; readonly * different versions have different ids. */ export const moduleIdOf = (file: string, packageOf: PackageOf): string | null => { - const path = (file.split("?")[0] ?? file).replace(/\\/g, "/"); - for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { - const found = packageOf(directory); - if (found !== null && found !== undefined) return `${found.name}${found.version === undefined ? "" : `@${found.version}`}/${posix.relative(directory, path)}`; - // A filesystem's root, `/` or a drive's `C:`, whose parent posix paths give as itself or as `.`. - const parent = posix.dirname(directory); - if (parent === directory || parent === ".") break; - } - return dependency.exec(path)?.[1] ?? null; + const found = packageAbove(file, packageOf); + if (found !== null) return `${found.name}${found.version === undefined ? "" : `@${found.version}`}/${found.path}`; + return dependency.exec(pathOf(file))?.[1] ?? null; }; +/** How messages name a module: its path in its package, as its id has it, else the file's name. */ +export const moduleLabelOf = (file: string, packageOf: PackageOf): string => + packageAbove(file, packageOf)?.path ?? dependency.exec(pathOf(file))?.[1] ?? posix.basename(pathOf(file)); + /** Whether a binding named `name` is declared in the module's scope: at top level, or as a `var` in any of its blocks. */ const declaresInModule = (program: Program, name: string): boolean => { if (moduleScope(program).has(name)) return true; diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 7dd3fa56..9ebac921 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -6,7 +6,7 @@ import { isAbsolute } from 'node:path'; import type { Plugin, Rollup, ViteDevServer } from 'vite'; import { estreeOf } from './estree.js'; import { describeFailure } from './failure.js'; -import { identify as identifyModule, moduleIdOf, type PackageOf } from './identify.js'; +import { identify as identifyModule, moduleIdOf, moduleLabelOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; import { archetypeInsertions } from './archetypes.js'; @@ -340,7 +340,7 @@ const compilePlugin = (automatic: boolean): Plugin => { if (!serve) return `import.meta.ROLLUP_FILE_URL_${emitChunk(this, buildId(id, index), hash)}`; // Served to node, a kernel's module is its url, so a pool's workers, which import it outside the server, read it too. return JSON.stringify(ssr ? inlinePlaceholder(index, hash) : serveUrl(id, index, hash)); - }, linker, automatic); + }, linker, automatic, moduleLabelOf(id, packageOf)); const result = ssr && transformed !== null && !("failure" in transformed) ? { ...transformed, code: inlineKernels(transformed.code, transformed.kernels, hashes) } : transformed; const parent = id.split("?")[0] ?? id; if (result === null) { diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index b5ced35b..950f27c9 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1395,6 +1395,22 @@ describe("inferred access", () => { expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings.length] : null).toEqual([true, 1]); }); + test("an observer's row it can't follow as unknown without a warning, since the world takes an observer's as read", async () => { + const result = await transform(system("observer: true, components: { with: [position] }", "(row) => { helper(row); }")); + expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings] : null).toEqual([true, []]); + }); + + test("a lookup's rows it can't follow without a warning, since what they hold is what the declaration says", async () => { + const result = await transform(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { helper(lookup.targets(row[target])); }")); + expect(result !== null && "code" in result ? [result.code.includes(`"targets": null`), result.warnings] : null).toEqual([true, []]); + }); + + test("warnings name the module by the label given, with line and column", async () => { + const result = await transformKernels(`${header}${system("components: { with: [position] }", "(row) => { helper(row); }")}`, "/repo/app/src/features/a/model.ts", parse, + async specifier => specifier === "ecs" ? ownCopy : null, () => "", createLinker(host({}, reductions), parse), true, "src/features/a/model.ts"); + expect(result !== null && "code" in result ? result.warnings.map(warning => /^[^ ]*:/.exec(warning)?.[0]) : null).toEqual([`src/features/a/model.ts:${header.split("\n").length}:${"const system = System.create({ components: { with: [position] } }, ".length + 1}:`]); + }); + test("resources read, written and reduced, however its context reaches them", async () => { expect(await inferred(system("", "(context) => { context.resources[score] = context.resources[delta]; }"))) .toContain("resources: { read: [delta], write: [score], reduce: [] }"); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 2a1c22f9..ef442e3f 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -205,6 +205,8 @@ export const transformKernels = async ( code: string, id: string, parse: Parse, resolveSystemImport: ResolveSystemImport, kernelUrl: KernelUrl, linker?: Linker, /** Whether unflagged systems are tried as kernels; tests turn it off to run their bodies as written. */ automatic = true, + /** How messages name the module: its path in its package; the file's name without one. */ + label?: string, ): Promise => { if ((!code.includes("System") && !code.includes("Finalizer")) || id.includes("\0")) return null; const program = parse(code); @@ -214,7 +216,10 @@ export const transformKernels = async ( const module = createModule(file, program, locateIn(code)); linker?.add(module); const dependencies = new Set(); - const at = (node: Node): string => `${posix.basename(file)}:${module.locate(node.start).line}`; + const at = (node: Node): string => { + const { line, column } = module.locate(node.start); + return `${label ?? posix.basename(file)}:${line}:${column}`; + }; const warnings: string[] = []; const accessName = uniqueName(code, "access$"); let accessing = false; @@ -282,10 +287,9 @@ export const transformKernels = async ( const used = kernel?.used ?? inOrder(usedBy(declared, body, module.bindings, positions), declared); requireRequired(used, declared, body); const where = at(body); - if (used.query === null) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); - if (used.lookups !== null && [...used.lookups.values()].some(read => read === null) || used.lookups === null && declares(declared, "lookup")) { - warnings.push(`${where}: the build can't follow how this body uses a lookup's rows, so it takes everything they hold as read.`); - } + // An observer's row, and a lookup's rows, it can't follow are taken as read whole, which is what their + // declarations already say; only a row taken as written whole costs what the author should know of. + if (used.query === null && !declares(declared, "observer")) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); accessing = true; const access = accessText(used, where); if (kernel !== undefined) { diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index 70d2a8b4..39bf6634 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -29,6 +29,9 @@ describe("componentLists", () => { it("takes a row the build didn't follow as writing every member, its added and changed filters' included", () => { expect(componentLists({ with: [mass], changed: [position] }, null, false)).toEqual({ write: [mass, position], changed: [position] }); }); + it("takes an observer's row the build didn't follow as reading every member, writing none", () => { + expect(componentLists({ with: [mass], changed: [position] }, null, true)).toEqual({ read: [mass, position], changed: [position] }); + }); }); describe("resourceUses", () => { From 600f08e99e70c984c70748863d8e7c9892919641 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 00:08:02 -0700 Subject: [PATCH 102/127] ecs: follow-ups grouped by priority: API changes, correctness, optimization, external utilities, docs, decisions Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 144 ++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 8a074def..a93658e2 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -1,15 +1,86 @@ # ecs follow-ups -Open items awaiting a decision or work. Remove an item once it's decided or done. +Open items awaiting a decision or work, by priority: the API is the focus while it's being finalized; then +correctness, then optimization. Remove an item once it's decided or done. -## Queued (decided, not started) +## API changes + +- **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run + before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section + runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), + or `world.share` returning a promise of the claim. Smallest form to be decided. +- **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with + no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and + array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. +- **`watch` by trait on changes finalizers:** `Finalizer.changes({ watch: "persist" }, …)`, `watch` taking a trait name as + well as a list of keys, typed as `Component.With<…>`, computed once per component id as `watches` already is. The + hook external persistence would build on. +- **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities + don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over + the entity a reference names. (from the todo sample) +- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer + variable. +- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a + per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image + per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU + row order. Not needed until a consumer's profile says so. +- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front + maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on + sorted indexes. Not needed until a consumer's profile or readability says so. +- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal + constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then + a world option or resource. +- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned + one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock` + is. +- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field + (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: + `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: + - how key words are laid out in shared memory; + - lookups on workers, on joined threads and in kernels (JS and WASM); + - change tracking that follows the field, not the whole component; + - whether a function form can run anywhere but the world's thread. +- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in + fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct` + (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, + narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level + `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. +- **The `Finalizer` namespace** (from earlier open work; to be restated before it's discussed). + +## Correctness + +None open. + +## Optimization + +- **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, + `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing + waits, and measure with a sampling heap profile. +- **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is + its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes + as signal. +- Index step 5 items. +- Starting the pool for large index builds. +- `order` on workers. +- Chromium timer clamps. +- The `transfer` cost. +- The perf.md "Next" items. + +## External utilities + +- **Persistence,** never part of the core: provided externally through custom traits (e.g. `persist: string`, the stable + name saved data maps through, independent of thread ids) and changes finalizers watching by trait. +- **A test helper awaiting the next tick.** Tests watch `clock` with an observer system today; a helper in + `src/observe` would do it (from the todo sample). + +## Documentation and tooling - **Generated API reference, once the API work is done.** typedoc over the public entry points (`src/index.ts` and `@adobe/ecs/compiler`), from a uniform `docs` script, linked from the README beside the guide. Publishing: today `.github/workflows/deploy-docs.yml` names each package; make it run `pnpm -r run docs` and collect every package's output into Pages instead of adding another named step (a monorepo change, `packages/data` included). -## Decisions for the maintainers +## Decisions awaiting confirmation - **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every row passes, and otherwise rows pass by the system's other change filters, so with `components.changed` it runs the @@ -33,18 +104,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **Filtering in the todo sample:** it writes `hidden` on each row (about 2 ms of script at 10K) rather than setting one attribute on the list and hiding rows with CSS. The attribute version is slower once laid out (25 ms against 17 ms at 10K) and puts the filter's meaning in the list element. -- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer - variable. -- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a - per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image - per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU - row order. Not needed until a consumer's profile says so. -- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front - maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on - sorted indexes. Not needed until a consumer's profile or readability says so. -- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal - constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then - a world option or resource. - **The cost of a report for every run:** a clean run costs about 125 ns more in node (`run idle` 333 to 458 ns, `run observer` 166 to 291), half of it the two clock reads `elapsed` takes. Decided: timing stays on. Check again if runs get cheap enough for it to matter: a `Report.timing` resource, off by default with `elapsed` NaN, measured `run idle` @@ -52,9 +111,6 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **Reports are hidden from queries that require no component,** as the resource entity is: an index or system with no `has` or `read`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the world's own. -- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned - one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock` - is. - **Errors while tails run:** an add or removal of `Report.errors` found after a run's sync point waits for the next sync point while tails run on a joined thread, an earlier run's errors emptied meanwhile. Recommended: accept; errors known before the sync point, system failures, skipped systems and warnings, are in place when the run resolves. @@ -83,55 +139,3 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept. -## Findings from the todo sample (`src/samples/todo/README.md`) - -2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities - don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over - the entity a reference names. -6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in - `src/observe`. - -## Features to discuss - -- **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run - before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section - runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), - or `world.share` returning a promise of the claim. Smallest form to be decided. -- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field - (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: - `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: - - how key words are laid out in shared memory; - - lookups on workers, on joined threads and in kernels (JS and WASM); - - change tracking that follows the field, not the whole component; - - whether a function form can run anywhere but the world's thread. -- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in - fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct` - (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, - narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level - `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. -- **Persistence.** Saved data needs ids stable across renames and versions, which thread ids (`name@version/path#binding`) - deliberately aren't. Planned shape: - - a `persist: string` trait on each persisted component, the name saved data maps through, checked unique at - registration; - - `Finalizer.changes({ watch: "persist" }, …)`: `watch` taking a trait name as well as a list of keys, typed as - `Component.With<"persist">`, computed once per component id as `watches` already is, so it costs nothing per - change. - -## Performance - -- **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, - `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing - waits, and measure with a sampling heap profile. -- **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is - its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes - as signal. - -## Earlier open work - -- Index step 5 items. -- Starting the pool for large index builds. -- `order` on workers. -- The `Finalizer` namespace. -- Chromium timer clamps. -- The `transfer` cost. -- The perf.md "Next" items. From db40dd3dd91bbc08d056a62f113105c6807ee085 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 00:08:10 -0700 Subject: [PATCH 103/127] ecs: follow-ups use with, not has or read Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index a93658e2..b18bb804 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -109,7 +109,7 @@ None open. get cheap enough for it to matter: a `Report.timing` resource, off by default with `elapsed` NaN, measured `run idle` 416 ns and `run observer` 208. - **Reports are hidden from queries that require no component,** as the resource entity is: an index or system with no - `has` or `read`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the + `with`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the world's own. - **Errors while tails run:** an add or removal of `Report.errors` found after a run's sync point waits for the next sync point while tails run on a joined thread, an earlier run's errors emptied meanwhile. Recommended: accept; errors From 12c422d2d707621d36f9568273f879315d06da78 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 00:10:18 -0700 Subject: [PATCH 104/127] ecs: schemas hold no defaults; a start is its declaration's, its type's readonly T.default, a reduction's identity, null or a zero value The package's types have defaults of their own (F32.default, Vec3.default, Quat.default, Entity.default ...). A T.default is shared, so the build takes it only when it's evidently deeply readonly. A mutable Map or Set gets no implicit start. Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 135 +++++--- .../implementation/compiler/augmentations.ts | 298 ++++++++++++++---- .../implementation/compiler/plugin.test.ts | 4 +- .../implementation/compiler/type-schemas.ts | 9 +- .../src/implementation/world/defaults.test.ts | 27 +- .../implementation/world/index-joined.test.ts | 4 +- .../src/implementation/world/move-log.test.ts | 4 +- .../world/observe-joined-cases.test.ts | 4 +- .../world/observer-joined.test.ts | 4 +- .../implementation/world/reductions.test.ts | 2 +- .../world/remote-objects.test-helper.ts | 4 +- .../src/implementation/world/remote.test.ts | 4 +- .../src/implementation/world/rules.test.ts | 2 +- .../ecs/src/implementation/world/state.ts | 3 +- .../src/interface/component/register.test.ts | 19 +- packages/ecs/src/interface/entity/index.ts | 3 + .../ecs/src/interface/math/f32/default.ts | 6 + packages/ecs/src/interface/math/f32/public.ts | 1 + .../ecs/src/interface/math/i32/default.ts | 6 + packages/ecs/src/interface/math/i32/public.ts | 1 + .../ecs/src/interface/math/mat4x4/public.ts | 1 + .../ecs/src/interface/math/quat/public.ts | 1 + .../ecs/src/interface/math/u32/default.ts | 6 + packages/ecs/src/interface/math/u32/public.ts | 1 + .../ecs/src/interface/math/vec2/f32/public.ts | 1 + .../ecs/src/interface/math/vec2/i32/public.ts | 1 + .../ecs/src/interface/math/vec2/public.ts | 1 + .../ecs/src/interface/math/vec2/u32/public.ts | 1 + .../ecs/src/interface/math/vec3/f32/public.ts | 1 + .../ecs/src/interface/math/vec3/i32/public.ts | 1 + .../ecs/src/interface/math/vec3/public.ts | 1 + .../ecs/src/interface/math/vec3/u32/public.ts | 1 + .../src/interface/math/vec4/f32/default.ts | 6 + .../ecs/src/interface/math/vec4/f32/public.ts | 1 + .../ecs/src/interface/math/vec4/i32/public.ts | 1 + .../ecs/src/interface/math/vec4/public.ts | 1 + .../ecs/src/interface/math/vec4/u32/public.ts | 1 + .../src/interface/registry/declarations.ts | 35 +- .../src/interface/registry/declared.test.ts | 4 +- .../ecs/src/interface/registry/ids.test.ts | 6 +- .../src/interface/resource/register.test.ts | 46 ++- .../ecs/src/interface/resources/always.ts | 2 +- .../ecs/src/interface/resources/cadence.ts | 2 +- packages/ecs/src/interface/resources/clock.ts | 3 +- packages/ecs/src/interface/resources/wake.ts | 2 +- .../ecs/src/interface/resources/workers.ts | 2 +- packages/ecs/src/interface/schema/schema.ts | 16 +- .../src/interface/schema/schema.type-test.ts | 9 + .../ecs/src/interface/system/resolve.test.ts | 2 +- .../src/interface/trait/trait.type-test.ts | 2 +- packages/ecs/src/perftest/log.ts | 4 +- packages/ecs/src/perftest/tails.ts | 4 +- 52 files changed, 515 insertions(+), 191 deletions(-) create mode 100644 packages/ecs/src/interface/math/f32/default.ts create mode 100644 packages/ecs/src/interface/math/i32/default.ts create mode 100644 packages/ecs/src/interface/math/u32/default.ts create mode 100644 packages/ecs/src/interface/math/vec4/f32/default.ts diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index db02c08a..1a5281e0 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -61,7 +61,7 @@ describe("augmentationInsertions", () => { expect(await passed(`${binding}\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`)).toContain("which the code recording its declarations uses"); } }); - test("gives a literal default in the schema", async () => { + test("gives a literal default beside the schema", async () => { expect(await registrations(`${keys("gravity", "mode", "origin")}declare module "@adobe/ecs" { interface Resources { [gravity]: { type: F32; default: -9.8 }; @@ -69,12 +69,12 @@ describe("augmentationInsertions", () => { [origin]: { type: Vec3; default: [1, 2, 3] }; } }`)).toEqual([ - `declare$(gravity, "resource", { type: {"type":"number","precision":1,"default":-9.8} });`, - `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}],"default":"all"} });`, - `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[1,2,3]} });`, + `declare$(gravity, "resource", { type: {"type":"number","precision":1}, default: -9.8 });`, + `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}]}, default: "all" });`, + `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [1,2,3] });`, ]); }); - test("derives a resource's or derived component's default from its type: zero values, null, or a fresh Map or Set", async () => { + test("derives a resource's or derived component's default from its type: its namespace's, null, a zero value, or a fresh ReadonlyMap or ReadonlySet", async () => { expect(await registrations(`${keys("layer", "drawn", "speed", "count", "on", "text", "at", "stats", "focus", "frame", "list", "maybe", "names", "tags", "cache", "audio")}declare module "@adobe/ecs" { interface Components { [layer]: { type: number; derived: true }; @@ -93,73 +93,119 @@ describe("augmentationInsertions", () => { [maybe]: { type: F32 | null }; [names]: { type: ReadonlyMap }; [tags]: { type: ReadonlySet }; - [cache]: { type: Map; thread: "main" }; + [cache]: { type: Map | null; thread: "main" }; [audio]: { type: AudioContext | null }; } }`)).toEqual([ - `declare$(layer, "component", { type: {"type":"number","default":0}, derived: true });`, + `declare$(layer, "component", { type: {"type":"number"}, default: 0, derived: true });`, `declare$(drawn, "component", { type: {"const":true}, derived: true });`, `declare$(speed, "component", { type: {"type":"number"} });`, - `declare$(count, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295,"default":0} });`, - `declare$(on, "resource", { type: {"type":"boolean","default":false} });`, - `declare$(text, "resource", { type: {"type":"string","default":""} });`, - `declare$(at, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":[0,0,0]} });`, - `declare$(stats, "resource", { type: {"type":"object","properties":{"sprites":{"type":"integer","minimum":0,"maximum":4294967295},"frames":{"type":"integer","minimum":0,"maximum":4294967295}},"default":{"sprites":0,"frames":0}} });`, - `declare$(focus, "resource", { type: {"type":"integer","format":"entity","default":0} });`, - `declare$(frame, "resource", { type: {"anyOf":[{"const":0},{"const":1},{"const":2}],"default":0} });`, - `declare$(list, "resource", { type: {"type":"array","items":{"type":"string"},"default":[]} });`, + // U32 and Vec3 are imported as types, so their namespaces' defaults are written in; Entity is a value. + `declare$(count, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0 });`, + `declare$(on, "resource", { type: {"type":"boolean"}, default: false });`, + `declare$(text, "resource", { type: {"type":"string"}, default: "" });`, + `declare$(at, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [0,0,0] });`, + `declare$(stats, "resource", { type: {"type":"object","properties":{"sprites":{"type":"integer","minimum":0,"maximum":4294967295},"frames":{"type":"integer","minimum":0,"maximum":4294967295}}}, default: {"sprites":0,"frames":0} });`, + `declare$(focus, "resource", { type: {"type":"integer","format":"entity"}, default: Entity.default });`, + `declare$(frame, "resource", { type: {"anyOf":[{"const":0},{"const":1},{"const":2}]}, default: 0 });`, + `declare$(list, "resource", { type: {"type":"array","items":{"type":"string"}}, default: [] });`, `declare$(maybe, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, `declare$(names, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, fresh: "Map" });`, `declare$(tags, "resource", { type: {"type":"set","items":{"type":"string"}}, fresh: "Set" });`, - `declare$(cache, "resource", { thread: "main", fresh: "Map" });`, + `declare$(cache, "resource", { thread: "main" });`, `declare$(audio, "resource", {});`, ]); }); - test("takes the default of a namespaced type's schema, after the value holding it, unless a literal is given", async () => { - const files = { "/src/bounds.ts": `import { F32 } from "@adobe/ecs";\nexport type Upper = F32;\nexport const Upper = { schema: { ...F32.schema, default: Infinity } } as const;` }; + test("takes its type's namespace's default, after the value holding it, unless a literal is given", async () => { + const files = { "/src/bounds.ts": `import type { F32 } from "@adobe/ecs";\nexport type Upper = F32;\nexport const Upper = { default: Infinity } as const;` }; const code = await passed(`import { Upper } from "./bounds.js";\n${keys("low", "high", "given", "mark")}declare module "@adobe/ecs" { interface Components { [mark]: { type: Lower } } interface Resources { [low]: { type: Lower }; [high]: { type: Upper }; [given]: { type: Lower; default: 1 } } } type Lower = F32; -const Lower = { schema: { type: "number", precision: 1, default: -Infinity } } as const; +const Lower = { default: -Infinity } as const; const after = 1;`, files); expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ - `declare$(high, "resource", { type: {"type":"number","precision":1,"default":Upper.schema.default} });`, - `declare$(given, "resource", { type: {"type":"number","precision":1,"default":1} });`, - `declare$(mark, "component", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, - `declare$(low, "resource", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + `declare$(high, "resource", { type: {"type":"number","precision":1}, default: Upper.default });`, + `declare$(given, "resource", { type: {"type":"number","precision":1}, default: 1 });`, + `declare$(mark, "component", { type: {"type":"number","precision":1} });`, + `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`, ]); - expect(code?.split("\n").slice(-4)).toEqual([ - `const Lower = { schema: { type: "number", precision: 1, default: -Infinity } } as const;`, - `declare$(mark, "component", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, - `declare$(low, "resource", { type: {"type":"number","precision":1,"default":Lower.schema.default} });`, + expect(code?.split("\n").slice(-3)).toEqual([ + `const Lower = { default: -Infinity } as const;`, + `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`, `const after = 1;`, ]); }); + test("takes the package's types' namespaces' defaults: identities for a quaternion and a matrix", async () => { + expect(await registrations(`import { Quat, type Mat4x4 } from "@adobe/ecs";\n${keys("turn", "view")}declare module "@adobe/ecs" { interface Resources { [turn]: { type: Quat }; [view]: { type: Mat4x4 } } }`)).toEqual([ + `declare$(turn, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}, default: Quat.default });`, + `declare$(view, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":16,"maxItems":16}, default: [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1] });`, + ]); + }); + test("starts a reduced resource at its operator's identity, within its type's range", async () => { + expect(await registrations(`${keys("sum", "product", "least", "most", "all", "any", "bounds", "masked")}declare module "@adobe/ecs" { + interface Resources { + [sum]: { type: F32; reduce: "+" }; + [product]: { type: F32; reduce: "*" }; + [least]: { type: F32; reduce: "min" }; + [most]: { type: U32; reduce: "max" }; + [all]: { type: boolean; reduce: "&" }; + [any]: { type: boolean; reduce: "|" }; + [bounds]: { type: { readonly low: F32; readonly high: F32 }; reduce: "max" }; + [masked]: { type: U32; reduce: "&" }; + } +}`)).toEqual([ + `declare$(sum, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`, + `declare$(product, "resource", { type: {"type":"number","precision":1}, default: 1, reduce: "*" });`, + `declare$(least, "resource", { type: {"type":"number","precision":1}, default: Infinity, reduce: "min" });`, + `declare$(most, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0, reduce: "max" });`, + `declare$(all, "resource", { type: {"type":"boolean"}, default: true, reduce: "&" });`, + `declare$(any, "resource", { type: {"type":"boolean"}, default: false, reduce: "|" });`, + `declare$(bounds, "resource", { type: {"type":"object","properties":{"low":{"type":"number","precision":1},"high":{"type":"number","precision":1}}}, default: {"low":-Infinity,"high":-Infinity}, reduce: "max" });`, + `declare$(masked, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 4294967295, reduce: "&" });`, + ]); + }); + test("takes a type's namespace's default only when it's deeply readonly, as a default is shared", async () => { + const declared = (holder: string) => `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Settings } } }\ntype Settings = { readonly size: number };\n${holder}`; + const shared = "a default is shared"; + expect(await registrations(declared(`const Settings = { default: { size: 1 } } as const;`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await registrations(declared(`const start: Readonly<{ size: number }> = { size: 2 };\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await registrations(declared(`const start = { size: 2 } as const;\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await passed(declared(`const Settings = { default: { size: 1 } };`))).toContain("is shared by every row and world that starts from it"); + expect(await passed(declared(`const start = { size: 2 };\nconst Settings = { default: start } as const;`))).toContain("as const"); + const maps = `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Names } } }\ntype Names = ReadonlyMap;\n`; + expect(await passed(`${maps}const Names = { default: new Map([["a", 1]]) };`)).toContain("is shared by every row and world"); + expect(await passed(`${maps}const names: Map = new Map();\nconst Names = { default: names };`)).toContain("ReadonlyMap"); + expect(await registrations(`${maps}const names: ReadonlyMap = new Map([["a", 1]]);\nconst Names = { default: names };`)).toEqual([`declare$(key, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, default: Names.default });`]); + void shared; + }); test("writes a literal -0 as itself", async () => { expect(await registrations(`${keys("floor")}declare module "@adobe/ecs" { interface Resources { [floor]: { type: number; default: -0 } } }`)) - .toEqual([`declare$(floor, "resource", { type: {"type":"number","default":-0} });`]); + .toEqual([`declare$(floor, "resource", { type: {"type":"number"}, default: -0 });`]); }); test("starts a struct's fields and a tuple's items at their own types' defaults", async () => { - const code = await passed(`import type { Schema } from "@adobe/ecs";\n${keys("level", "box", "pair", "pool")}declare module "@adobe/ecs" { + const code = await passed(`import type { Schema } from "@adobe/ecs";\n${keys("level", "box", "pair")}declare module "@adobe/ecs" { interface Resources { - [level]: { type: { readonly value: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9; default: 5 }> } }; + [level]: { type: { readonly value: Level } }; [box]: { type: { readonly height: Height; readonly width: F32 } }; [pair]: { type: readonly [Height, F32] }; - [pool]: { type: Pool }; } } -type Pool = Map; +type Level = Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>; +const Level = { default: 5 } as const; type Height = F32; -const Height = { schema: { type: "number", precision: 1, default: -1 } } as const;`); +const Height = { default: -1 } as const;`); expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ - `declare$(level, "resource", { type: {"type":"object","properties":{"value":{"type":"integer","minimum":1,"maximum":9,"default":5}},"default":{"value":5}} });`, - `declare$(pool, "resource", { fresh: "Map" });`, - `declare$(box, "resource", { type: {"type":"object","properties":{"height":{"type":"number","precision":1},"width":{"type":"number","precision":1}},"default":{ "height": Height.schema.default, "width": 0 }} });`, - `declare$(pair, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":2,"maxItems":2,"default":[Height.schema.default, 0]} });`, + `declare$(level, "resource", { type: {"type":"object","properties":{"value":{"type":"integer","minimum":1,"maximum":9}}}, default: { "value": Level.default } });`, + `declare$(box, "resource", { type: {"type":"object","properties":{"height":{"type":"number","precision":1},"width":{"type":"number","precision":1}}}, default: { "height": Height.default, "width": 0 } });`, + `declare$(pair, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":2,"maxItems":2}, default: [Height.default, 0] });`, ]); }); + test("gives a mutable Map or Set no start of its own: null when its type allows it, else a failure", async () => { + expect(await registrations(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool | null } } }\ntype Pool = Map;`)).toEqual([`declare$(pool, "resource", {});`]); + expect(await passed(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool } } }\ntype Pool = Map;`)).toContain("a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't"); + }); test("gives a value that isn't data no schema, and its traits", async () => { const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}declare module "@adobe/ecs" { @@ -192,7 +238,7 @@ const Height = { schema: { type: "number", precision: 1, default: -1 } } as cons `declare$(outline, "component", { type: {"type":"number"}, within: host, [instanced]: true });`, `declare$(owner, "component", { type: {"type":"integer","format":"entity"}, reference: "owner" });`, `declare$(drawn, "component", { type: {"const":true}, derived: true });`, - `declare$(total, "resource", { type: {"type":"number","precision":1,"default":0}, reduce: "+" });`, + `declare$(total, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`, ]); }); test("registers a block's traits before the components declaring them", async () => { @@ -211,12 +257,12 @@ const Height = { schema: { type: "number", precision: 1, default: -1 } } as cons const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Start } } } const later = Symbol(); type Start = Vec3; -const Start = { schema: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [1, 2, 3] } } as const; +const Start = { default: [1, 2, 3] } as const; const after = 1;`); - expect(code?.split("\n").slice(-3)).toEqual([`const Start = { schema: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [1, 2, 3] } } as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3,"default":Start.schema.default} });`, `const after = 1;`]); + expect(code?.split("\n").slice(-3)).toEqual([`const Start = { default: [1, 2, 3] } as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: Start.default });`, `const after = 1;`]); // Code between the key and a block below uses the key registered. const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 1 } } }`); - expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1,"default":1} });`]); + expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1}, default: 1 });`]); }); test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => { const code = await passed(`declare module "@adobe/ecs" { @@ -272,11 +318,12 @@ const mark = Symbol();`); expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("let its type allow null"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: OffscreenCanvas } }`)}`)).toContain("let its type allow null"); expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)) - .toContain("give its schema one: `Schema.Type<{ ...; default: ... }>`"); - expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain("declare a type of its own with a namespace"); + .toContain("name its type and give the type a namespace with a `default`"); + expect(await passed(`${keys("key")}type Level = 1 | 2 | 4;\n${block(`interface Resources { [key]: { type: Level } }`)}`)).toContain("give Level a namespace with a `default`: `const Level = { default: ... }`"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; default: 5 }> } }`)}`)).toContain("a schema holds no default"); expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)).toContain("0 isn't one of its values"); expect(await passed(`const start = 1;\n${keys("key")}${block(`interface Resources { [key]: { type: number; default: typeof start } }`)}`)).toContain("default is a literal type, never `typeof` a value"); - expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nexport const Lower = { schema: { type: "number", default: -1 } } as const;` })).toContain("import Lower as a value"); + expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nconst start: number = Date.now();\nexport const Lower = { default: start } as const;` })).toContain("import Lower as a value"); expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`); expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index b6c72642..69fc90ec 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -160,9 +160,10 @@ const uniqueName = (code: string, base: string): string => { const allowsNullSchema = (schema: Schema): boolean => ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); -/** A value as code: JSON, but -0 as itself, which JSON writes as 0. */ +/** A value as code: JSON, but -0 and the numbers JSON can't write as themselves. */ const codeOf = (value: unknown): string => { if (Object.is(value, -0)) return "-0"; + if (typeof value === "number" && !Number.isFinite(value)) return Number.isNaN(value) ? "NaN" : value > 0 ? "Infinity" : "-Infinity"; if (Array.isArray(value)) return `[${value.map(codeOf).join(",")}]`; if (typeof value === "object" && value !== null) return `{${Object.entries(value).map(([field, item]) => `${JSON.stringify(field)}:${codeOf(item)}`).join(",")}}`; return JSON.stringify(value); @@ -178,7 +179,6 @@ type Start = { readonly value: Value } | { readonly fresh: "Map" | "Set" } | { r /** A schema's zero value: 0, false or "", each item's and field's, a union's literal that is one, or a fresh set or map. */ const zeroOf = (schema: Schema): Start => { - if ("default" in schema && isValue(schema.default)) return { value: schema.default }; if ("const" in schema) return { value: schema.const }; if ("anyOf" in schema) { for (const member of schema.anyOf) if ("const" in member && (member.const === 0 || member.const === false || member.const === "")) return { value: member.const }; @@ -216,6 +216,41 @@ const zeroOf = (schema: Schema): Start => { } }; +/** + * What a resource reduced with `operator` starts at when it declares no start: the operator's identity, leaf by leaf, + * within the schema's range, as a fold of no contributions leaves it; null for a schema of other leaves. + */ +const identityOf = (schema: Schema, operator: unknown): Value | null => { + if ("type" in schema && schema.type === "boolean") return operator === "&"; + if ("type" in schema && (schema.type === "number" || schema.type === "integer")) { + const minimum = "minimum" in schema ? schema.minimum : undefined; + const maximum = "maximum" in schema ? schema.maximum : undefined; + switch (operator) { + case "+": case "|": case "^": return 0; + case "*": return 1; + // Every bit set: -1, or a range's largest for an unsigned one. + case "&": return minimum !== undefined && minimum >= 0 ? maximum ?? null : -1; + case "min": return maximum ?? Infinity; + case "max": return minimum ?? -Infinity; + default: return null; + } + } + if ("type" in schema && schema.type === "array" && schema.minItems !== undefined && schema.minItems === schema.maxItems) { + const item = identityOf(schema.items, operator); + return item === null ? null : Array.from({ length: schema.minItems }, () => item); + } + if ("type" in schema && schema.type === "object") { + const value: { [field: string]: Value } = {}; + for (const [field, property] of Object.entries(schema.properties)) { + const leaf = identityOf(property, operator); + if (leaf === null) return null; + value[field] = leaf; + } + return value; + } + return null; +}; + /** * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: each * component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and its other @@ -350,63 +385,211 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH return object === null ? null : [...object, expression.property.name]; }; - /** Whether a schema's value, as its module writes it, gives a `default`: in place, or through constants and spreads. */ - const holdsDefault = async (expression: Declarator["init"], at: Module, seen: Set): Promise => { + /** A value as its module writes it, and whether an `as const` around it makes what it writes in place readonly. */ + type Written = { readonly expression: NonNullable; readonly module: Module; readonly constant: boolean; readonly annotation?: TypeNode }; + + /** Whether an expression is an `as const` assertion, under parentheses and other casts. */ + const isConstAssertion = (expression: Declarator["init"]): boolean => { + let inner = expression; + while (inner !== null && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) { + if (inner.type === "TSAsExpression" && inner.typeAnnotation.type === "TSTypeReference" && inner.typeAnnotation.typeName.type === "Identifier" && inner.typeAnnotation.typeName.name === "const") return true; + inner = inner.expression; + } + return false; + }; + + /** The `default` property's value of an object a module writes: in place, or through constants and spreads. */ + const defaultProperty = async (expression: Declarator["init"], at: Module, constant: boolean, seen: Set): Promise => { const value = unwrapped(expression); - if (value === null || seen.has(value)) return false; + if (value === null || seen.has(value)) return null; seen.add(value); + const inConst = constant || isConstAssertion(expression); if (value.type === "Identifier" || value.type === "MemberExpression") { const path = pathOf(value); const found = path === null ? null : await converter.constantNamed(path, at); - return found !== null && await holdsDefault(found.declaration.init, found.module, seen); + return found === null ? null : await defaultProperty(found.declaration.init, found.module, false, seen); } - if (value.type !== "ObjectExpression") return false; + if (value.type !== "ObjectExpression") return null; for (const property of value.properties) { - if (property.type === "SpreadElement" ? await holdsDefault(property.argument, at, seen) : !property.computed && property.key.type === "Identifier" && property.key.name === "default") return true; + if (property.type === "SpreadElement") { + const found = await defaultProperty(property.argument, at, inConst, seen); + if (found !== null) return found; + } + else if (!property.computed && property.key.type === "Identifier" && property.key.name === "default") { + return { expression: property.value, module: at, constant: inConst }; + } } - return false; + return null; }; - /** Whether the value a type's name names, as `Vec3` names its namespace beside its type, has a schema with a `default`. */ - const declaresSchemaDefault = async (path: readonly string[]): Promise => { - const member = await converter.constantNamed([...path, "schema"], module); - if (member !== null) return await holdsDefault(member.declaration.init, member.module, new Set()); + /** The value of a type's namespace's `default`, as `Vec3` names its namespace beside its type: a namespace's export, or a constant object's property. */ + const defaultWritten = async (path: readonly string[]): Promise => { + const member = await converter.constantNamed([...path, "default"], module); + if (member !== null && member.declaration.init !== null) { + const annotation = member.declaration.id.type === "Identifier" ? member.declaration.id.typeAnnotation?.typeAnnotation : undefined; + return { expression: member.declaration.init, module: member.module, constant: false, annotation }; + } const holder = await converter.constantNamed(path, module); - const value = unwrapped(holder?.declaration.init ?? null); - if (holder === null || value?.type !== "ObjectExpression") return false; - for (const property of value.properties) { - if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "schema") { - return await holdsDefault(property.value, holder.module, new Set()); + return holder === null ? null : await defaultProperty(holder.declaration.init, holder.module, false, new Set()); + }; + + /** + * Whether a type is deeply readonly as written: no array, field, map or set of it can change in place. `fields` + * takes an object's fields as readonly, as `Readonly<...>` makes them. + */ + const readonlyType = async (type: TypeNode, at: Module, seen: Set, fields = false): Promise => { + switch (type.type) { + case "TSNumberKeyword": case "TSStringKeyword": case "TSBooleanKeyword": case "TSNullKeyword": case "TSUndefinedKeyword": case "TSLiteralType": return true; + case "TSParenthesizedType": return await readonlyType(type.typeAnnotation, at, seen); + case "TSUnionType": return (await Promise.all(type.types.map(member => readonlyType(member, at, seen)))).every(Boolean); + case "TSTypeOperator": { + if (type.operator !== "readonly") return false; + const inner = type.typeAnnotation.type === "TSParenthesizedType" ? type.typeAnnotation.typeAnnotation : type.typeAnnotation; + if (inner.type === "TSArrayType") return await readonlyType(inner.elementType, at, seen); + if (inner.type === "TSTupleType") { + return (await Promise.all(inner.elementTypes.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? false : readonlyType(element, at, seen)))).every(Boolean); + } + return false; + } + case "TSTypeLiteral": { + for (const member of type.members) { + if (member.type !== "TSPropertySignature" || (member.readonly !== true && !fields) || member.typeAnnotation === null || !await readonlyType(member.typeAnnotation.typeAnnotation, at, seen)) return false; + } + return true; } + case "TSTypeReference": { + const parts = typeNameParts(type.typeName); + const parameters = type.typeArguments?.params ?? []; + const [only] = parts; + if (parts.length === 1 && only !== undefined && !at.types.has(only) && !at.imports.has(only) && (only === "ReadonlyMap" || only === "ReadonlySet" || only === "ReadonlyArray")) { + return (await Promise.all(parameters.map(parameter => readonlyType(parameter, at, seen)))).every(Boolean); + } + const [argument] = parameters; + if (parts.length === 1 && only === "Readonly" && !at.types.has(only) && !at.imports.has(only) && argument !== undefined) return await readonlyType(argument, at, seen, true); + const found = await converter.typeNamed(parts, at); + if (found === null || typeof found === "string" || seen.has(found.declaration)) return false; + // A type a schema names, as the package's math types are, is a readonly value. + if (host.packagePath(found.module.file) === "interface/schema/schema" && found.declaration.id?.name === "Type") return true; + if (host.packagePath(found.module.file) === "interface/entity/entity" && found.declaration.id?.name === "Entity") return true; + seen.add(found.declaration); + if (found.declaration.type === "TSTypeAliasDeclaration") return found.declaration.typeParameters === null && await readonlyType(found.declaration.typeAnnotation, found.module, seen, fields); + if (found.declaration.type !== "TSInterfaceDeclaration" || found.declaration.extends.length > 0) return false; + for (const member of found.declaration.body.body) { + if (member.type !== "TSPropertySignature" || (member.readonly !== true && !fields) || member.typeAnnotation === null || !await readonlyType(member.typeAnnotation.typeAnnotation, found.module, seen)) return false; + } + return true; + } + default: return false; + } + }; + + /** Whether a value a module writes is deeply readonly by its type: a literal, `as const` data, or a binding typed readonly. */ + const readonlyValue = async (written: Written, seen: Set): Promise => { + const { module: at, annotation } = written; + if (annotation !== undefined) return await readonlyType(annotation, at, seen); + const constant = written.constant || isConstAssertion(written.expression); + const value = unwrapped(written.expression); + if (value === null || seen.has(value)) return false; + seen.add(value); + switch (value.type) { + case "Literal": return true; + case "TemplateLiteral": return value.expressions.length === 0; + case "UnaryExpression": return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && value.argument.name === "Infinity")); + case "ArrayExpression": + return constant && (await Promise.all(value.elements.map(element => element === null || element.type === "SpreadElement" ? false : readonlyValue({ expression: element, module: at, constant }, seen)))).every(Boolean); + case "ObjectExpression": + return constant && (await Promise.all(value.properties.map(property => property.type === "SpreadElement" || property.computed + ? false : readonlyValue({ expression: property.value, module: at, constant }, seen)))).every(Boolean); + case "Identifier": case "MemberExpression": { + if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN" || value.name === "undefined")) return true; + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + if (found === null || found.declaration.init === null) return false; + const declared = found.declaration.id.type === "Identifier" ? found.declaration.id.typeAnnotation?.typeAnnotation : undefined; + return await readonlyValue({ expression: found.declaration.init, module: found.module, constant: false, annotation: declared }, seen); + } + default: return false; + } + }; + + /** The value a module writes, when it's data the build reads: literals, and arrays and objects of them, through constants; else undefined. */ + const staticValue = async (written: Written, seen: Set): Promise => { + const value = unwrapped(written.expression); + if (value === null || seen.has(value)) return undefined; + seen.add(value); + const at = written.module; + switch (value.type) { + case "Literal": return typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean" || value.value === null ? value.value : undefined; + case "UnaryExpression": + if (value.operator !== "-") return undefined; + if (value.argument.type === "Identifier" && value.argument.name === "Infinity") return -Infinity; + return value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value : undefined; + case "ArrayExpression": { + const items: Value[] = []; + for (const element of value.elements) { + const item = element === null || element.type === "SpreadElement" ? undefined : await staticValue({ expression: element, module: at, constant: written.constant }, seen); + if (item === undefined) return undefined; + items.push(item); + } + return items; + } + case "ObjectExpression": { + const fields: { [field: string]: Value } = {}; + for (const property of value.properties) { + if (property.type === "SpreadElement" || property.computed || property.key.type !== "Identifier") return undefined; + const field = await staticValue({ expression: property.value, module: at, constant: written.constant }, seen); + if (field === undefined) return undefined; + fields[property.key.name] = field; + } + return fields; + } + case "Identifier": case "MemberExpression": { + if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN")) return value.name === "Infinity" ? Infinity : NaN; + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + return found === null || found.declaration.init === null ? undefined : await staticValue({ expression: found.declaration.init, module: found.module, constant: false }, seen); + } + default: return undefined; } - return false; }; - /** A declaration's type's namespaced schema default, as code reading it, and the local binding it reads; null without one. */ - const namespacedDefault = async (type: TypeNode, name: string): Promise<{ readonly text: string; readonly local?: string } | null> => { - if (type.type !== "TSTypeReference" || type.typeArguments !== null) return null; - const path = typeNameParts(type.typeName); + /** A declaration's type's namespace's default, `T.default`, as code reading it, and the local binding it reads; null without one. */ + const namespacedDefault = async (type: TypeNode, name: string): Promise<{ readonly text: string; readonly local?: string; readonly packaged: boolean } | null> => { + const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; + if (inner.type !== "TSTypeReference" || inner.typeArguments !== null) return null; + const path = typeNameParts(inner.typeName); const [first] = path; - if (first === undefined || !await declaresSchemaDefault(path)) return null; + const written = first === undefined ? null : await defaultWritten(path); + if (first === undefined || written === null) return null; + // The package's own types' defaults are their zero values and identities, which a reduction's identity outranks. + const packaged = host.packagePath(written.module.file) !== null; + if (!await readonlyValue(written, new Set())) { + fail(written.module, written.expression, `${path.join(".")}.default is shared by every row and world that starts from it, so it's deeply readonly: \`const ${path.at(-1) ?? "T"} = { default: ... } as const\`, or a const typed readonly: ReadonlyMap, ReadonlySet, Readonly<...>, readonly arrays and fields`); + } const imported = module.imports.get(first); - if (imported !== undefined && !imported.value) fail(module, type, `${name}'s type ${path.join(".")} starts at its schema's default: import ${first} as a value, which the registration reads`); - return { text: `${path.join(".")}.schema.default`, local: imported === undefined ? first : undefined }; + if (imported !== undefined && !imported.value) { + // Imported as a type alone, the namespace can't be read here, so a default the build can read is written in. + const value = await staticValue(written, new Set()); + if (value === undefined) fail(module, inner, `${name}'s type ${path.join(".")} starts at ${path.join(".")}.default: import ${first} as a value, which the registration reads`); + return { text: codeOf(value), packaged }; + } + return { text: `${path.join(".")}.default`, local: imported === undefined ? first : undefined, packaged }; }; /** * What a value of `type`, of `schema`, starts at as code: as `zeroOf` says, but each field of a struct and item of - * a tuple written here starting at its own type's default, its namespaced schema's included, by the same rules; + * a tuple written here starting at its own type's default, its namespace's included, by the same rules; * and the local bindings that code reads. */ const startOf = async (type: TypeNode, schema: Schema): Promise<{ readonly text: string; readonly locals: readonly string[] } | { readonly fresh: "Map" | "Set" } | { readonly none: string }> => { const unwrapped = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; const inner = unwrapped.type === "TSTypeOperator" && unwrapped.operator === "readonly" && unwrapped.typeAnnotation !== undefined ? unwrapped.typeAnnotation : unwrapped; - const namespaced = "default" in schema ? null : await namespacedDefault(inner, "it"); + const namespaced = await namespacedDefault(inner, "it"); if (namespaced !== null) return { text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local] }; - const parts = inner.type === "TSTypeLiteral" && "type" in schema && schema.type === "object" && !("default" in schema) + const parts = inner.type === "TSTypeLiteral" && "type" in schema && schema.type === "object" ? inner.members.flatMap(field => field.type === "TSPropertySignature" && !field.computed && field.key.type === "Identifier" && field.typeAnnotation !== null ? [{ name: field.key.name, type: field.typeAnnotation.typeAnnotation, schema: schema.properties[field.key.name] }] : []) - : inner.type === "TSTupleType" && "type" in schema && schema.type === "array" && !("default" in schema) + : inner.type === "TSTupleType" && "type" in schema && schema.type === "array" ? inner.elementTypes.flatMap((element, k) => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? [] : [{ name: String(k), type: element, schema: schema.items }]) : null; if (parts === null || parts.length === 0) { @@ -421,7 +604,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH texts.push(inner.type === "TSTupleType" ? started.text : `${JSON.stringify(part.name)}: ${started.text}`); locals.push(...started.locals); } - // Without a namespaced default inside, the schema's zero value says the same, as data. + // Without a namespace's default inside, the schema's zero value says the same, as data. if (locals.length === 0) { const zero = zeroOf(schema); if ("value" in zero) return { text: codeOf(zero.value), locals }; @@ -429,34 +612,18 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH return { text: inner.type === "TSTupleType" ? `[${texts.join(", ")}]` : `{ ${texts.join(", ")} }`, locals }; }; - /** The platform class a value that isn't data starts as a fresh, empty instance of, per world: `Map` or `Set`, through the aliases it names. */ - const freshOf = async (type: TypeNode, at: Module, seen: Set): Promise<"Map" | "Set" | null> => { - const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; - if (inner.type !== "TSTypeReference") return null; - const parts = typeNameParts(inner.typeName); - const found = await converter.typeNamed(parts, at); - if (found === "global") { - const [name] = parts; - return parts.length === 1 && (name === "Map" || name === "Set") ? name : null; - } - if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return null; - seen.add(found.declaration); - return await freshOf(found.declaration.typeAnnotation, found.module, seen); - }; - /** A declared default: a literal type, never `typeof` a value. */ const literalDefault = async (type: TypeNode, name: string): Promise => { - if (type.type === "TSTypeQuery") fail(module, type, `${name}'s default is a literal type, never \`typeof\` a value: without one, its type's zero value, null, or the default of its type's namespaced schema is what it starts at`); + if (type.type === "TSTypeQuery") fail(module, type, `${name}'s default is a literal type, never \`typeof\` a value: without one, it starts at its type's namespace's \`default\`, its reduction's identity, null, or its type's zero value`); return await converter.valueOfType(type, module, new Set()); }; - /** How to give a type's values a start no zero value gives: by a literal, its schema, or null for a value that isn't data. */ - const workaround = (type: TypeNode, data: boolean): string => { - if (!data) return "let its type allow null, and give it a value once there is one"; - if (type.type === "TSTypeReference" && type.typeArguments !== null) return "declare `default:` as a literal, or give its schema one: `Schema.Type<{ ...; default: ... }>`"; - const name = type.type === "TSTypeReference" ? typeNameParts(type.typeName).join(".") : null; - const named = name === null ? "a type of its own" : `\`${name}\``; - return `declare \`default:\` as a literal, or declare ${named} with a namespace whose schema holds its default: \`type ${name ?? "Start"} = ...; const ${name ?? "Start"} = { schema: { ...; default: ... } }\``; + /** How to give a type's values a start no zero value gives: a literal, or its type's namespace's `default`. */ + const workaround = (type: TypeNode): string => { + const name = type.type === "TSTypeReference" && type.typeArguments === null ? typeNameParts(type.typeName).join(".") : null; + return name === null + ? "declare `default:` as a literal, or name its type and give the type a namespace with a `default`: `type Start = ...; const Start = { default: ... }`" + : `declare \`default:\` as a literal, or give ${name} a namespace with a \`default\`: \`const ${name} = { default: ... }\``; }; /** Where a trait a block of this module declares is registered, which what declares it comes after. */ @@ -549,24 +716,29 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); let start = given === undefined ? undefined : codeOf(given); let fresh: "Map" | "Set" | null = null; - // First a literal, then the type's schema's default; only resources and derived components need a start. - const namespaced = given !== undefined || (schema !== undefined && "default" in schema) ? null : await namespacedDefault(typeNode, key.name); - if (namespaced !== null) { + // First a literal, then its type's namespace's default; only resources and derived components need a + // start otherwise, and a component holding an object, whose start a joined thread knows by identity. + const started = kind === "resource" || builtIns.has("derived") || schema === undefined || !isStruct(schema); + const namespaced = given !== undefined || !started ? null : await namespacedDefault(typeNode, key.name); + const reduced = builtIns.get("reduce"); + const identity = given !== undefined || (namespaced !== null && !namespaced.packaged) || kind !== "resource" || reduced === undefined || schema === undefined + ? null : identityOf(schema, JSON.parse(reduced.text)); + if (identity !== null) start = codeOf(identity); + else if (namespaced !== null) { start = namespaced.text; if (namespaced.local !== undefined) at = Math.max(at, endOfLocal(namespaced.local)); } - else if (given === undefined && (kind === "resource" || builtIns.has("derived")) && !(schema !== undefined && "default" in schema)) { + else if (given === undefined && (kind === "resource" || builtIns.has("derived"))) { if (schema === undefined ? await allowsNullType(typeNode, module, new Set()) : allowsNullSchema(schema)) { // It starts at null, as the world reads a value with neither. } else if (schema === undefined) { - fresh = await freshOf(typeNode, module, new Set()); - if (fresh === null) fail(module, member, `${kind} ${key.name} isn't data, so it has no zero value: ${workaround(typeNode, false)}`); + fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`); } else { const zero = await startOf(typeNode, schema); if ("fresh" in zero) fresh = zero.fresh; - else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode, true)}`); + else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode)}`); // A constant is the one value it can start at, which the world reads from its schema. else if (!("const" in schema)) { start = zero.text; @@ -574,8 +746,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH } } } - const parts = schema === undefined ? [] : [`type: ${start === undefined ? codeOf(schema) : `${codeOf(schema).slice(0, -1)},"default":${start}}`}`]; - if (schema === undefined && start !== undefined) parts.push(`default: ${start}`); + const parts = schema === undefined ? [] : [`type: ${codeOf(schema)}`]; + if (start !== undefined) parts.push(`default: ${start}`); parts.push(...traits); if (fresh !== null) parts.push(`fresh: "${fresh}"`); insertions.push({ at, text: `\n${record}(${key.name}, "${kind}", ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 47c716c4..753bfb3a 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -364,7 +364,7 @@ test("an application's build compiles a library's reducing kernel, with the oper const score = Symbol(); declare$(score, "component", { type: { type: "number" } }); const largest = Symbol(); -declare$(largest, "resource", { type: { type: "number", default: 0 }, reduce: "max" }); +declare$(largest, "resource", { type: { type: "number" }, default: 0, reduce: "max" }); ${recorder} export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { reduce }) => { reduce[largest](row[score]); }); `); @@ -434,7 +434,7 @@ declare module "@adobe/ecs" { // The registry of the installed copy, loaded as an application loads the package, reads it. await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/index.js")).href); const { declarationOf }: { declarationOf: (key: unknown) => unknown } = await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/interface/registry/names.js")).href); - expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1, default: 2 } }); + expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1 }, default: 2 }); }); test("a library's build in library mode ships its registrations and input schemas, its kernels as source and no ids", async () => { diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts index 3a503474..65e35750 100644 --- a/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -197,6 +197,8 @@ export const createConverter = (host: TypeHost, root: string) => { if (statement.source !== null) return await fromSource(module, statement.source.value, localName, kind, seen); const imported = module.imports.get(localName); if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, kind, seen); + const local = kind(module).get(localName); + if (local !== undefined) return { module, declaration: local }; } } else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { @@ -511,7 +513,8 @@ export const createConverter = (host: TypeHost, root: string) => { const [argument] = parameters; if (found !== null && intrinsic(found) === "schema" && parameters.length === 1 && argument !== undefined) { const schema = await valueOfType(argument, module, new Set()); - return isSchema(schema) ? schema : fail(module, argument, "Schema.Type takes a schema"); + if (!isSchema(schema)) return fail(module, argument, "Schema.Type takes a schema"); + return holdsDefault(schema) ? fail(module, argument, `a schema holds no default: give ${path.join(".")}'s alias a namespace with one, \`const T = { default: ... }\``) : schema; } return fail(module, type, `${use === "input" ? "input" : "declared"} type ${path.join(".")} takes type arguments, which ${words(use).a} doesn't, except Schema.Type`); }; @@ -651,4 +654,8 @@ export const pathOf = (expression: Node): readonly string[] | null => { const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number" && typeof Reflect.get(value, "type") === "string"; // `Schema.Type`'s argument is constrained to `Schema` where it's written, so TypeScript checked an object's form. +/** Whether a value a schema type writes holds a `default` anywhere. */ +const holdsDefault = (value: unknown): boolean => + typeof value === "object" && value !== null && (Array.isArray(value) ? value.some(holdsDefault) : "default" in value || Object.values(value).some(holdsDefault)); + const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/packages/ecs/src/implementation/world/defaults.test.ts b/packages/ecs/src/implementation/world/defaults.test.ts index 1894a58b..8b6c4c9a 100644 --- a/packages/ecs/src/implementation/world/defaults.test.ts +++ b/packages/ecs/src/implementation/world/defaults.test.ts @@ -4,15 +4,19 @@ import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "../index.js"; import { readResource } from "./reads.test-helper.js"; +import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../index.js"; const pool = Symbol(); const names = Symbol(); const count = Symbol(); const tagged = Symbol(); const index = Symbol(); +const turn = Symbol(); +const least = Symbol(); +const all = Symbol(); -/** A pool declared through an alias of Map. */ -type Pool = Map; +/** A pool declared through an alias of ReadonlyMap. */ +type Pool = ReadonlyMap; declare module "@adobe/ecs" { export interface Components { @@ -23,13 +27,16 @@ declare module "@adobe/ecs" { [pool]: { type: Pool }; [names]: { type: ReadonlyMap }; [count]: { type: number }; + [turn]: { type: Quat }; + [least]: { type: F32; reduce: "min" }; + [all]: { type: boolean; reduce: "&" }; } } Rule.create({ components: { with: [tagged] }, derive: [index] }); describe("a resource declared without a default", () => { - it("starts at its type's zero value, and a Map or Set at an empty one of its world's own", async () => { + it("starts at its type's zero value, and a ReadonlyMap or ReadonlySet at an empty one of its world's own", async () => { await using first = createWorld(); await using second = createWorld(); const [firstPool, secondPool] = [await readResource(first, pool), await readResource(second, pool)]; @@ -63,3 +70,17 @@ describe("a derived component declared without a default", () => { expect(seen[2]).not.toBe(seen[0]); }); }); + +describe("the package's types' defaults", () => { + it("are zeros, identities, and none for an entity", () => { + expect([F32.default, I32.default, U32.default, Vec2.default, Vec3.default, Vec4.default, Vec3.I32.default, Vec4.U32.default, Entity.default]) + .toEqual([0, 0, 0, [0, 0], [0, 0, 0], [0, 0, 0, 0], [0, 0, 0], [0, 0, 0, 0], Entity.none]); + expect([Quat.default, Mat4x4.default]).toEqual([Quat.identity, Mat4x4.identity]); + }); + it("start a world's resources, and a reduction its operator's identity", async () => { + await using world = createWorld(); + expect(await readResource(world, turn)).toEqual([0, 0, 0, 1]); + expect(await readResource(world, least)).toBe(Infinity); + expect(await readResource(world, all)).toBe(true); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 97b673bc..75dc9335 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -50,10 +50,10 @@ declare module "@adobe/ecs" { } const text = { type: "object", properties: { text: { type: "string" } } } as const; /** A label's start, which a command here gives it by this value. */ -const blank = { text: "" }; +const blank = { text: "" } as const; /** A label, which starts as `blank`, the value commands on other threads give it by identity. */ type Label = { readonly text: string }; -const Label = { schema: { default: blank } }; +const Label = { default: blank }; const grid = Index.create({ key: [cell], components: { with: [weight] } }); const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index 1ea558cb..469ef580 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -42,10 +42,10 @@ declare module "@adobe/ecs" { /** A value that isn't data. */ type Note = { readonly text: string; readonly read: () => string }; /** The label's start, which a command on another thread than its own gives it by this value. */ -const none = { text: "none" }; +const none = { text: "none" } as const; /** A label, which starts as `none`, the value commands on other threads give it by identity. */ type Label = { readonly text: string } | null; -const Label = { schema: { default: none } }; +const Label = { default: none }; const remoteKeys = [label, note] as const; diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index 9049c5e2..21c78a66 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -39,10 +39,10 @@ declare module "@adobe/ecs" { } } /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; +const none = { text: "none" } as const; /** A caption, which starts as `none`, the value commands on other threads give it by identity. */ type Caption = { readonly text: string } | null; -const Caption = { schema: { default: none } }; +const Caption = { default: none }; const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 0c9dc869..58a31926 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -32,10 +32,10 @@ declare module "@adobe/ecs" { } } /** The label's start, which a command here gives it by this value. */ -const blank = { text: "" }; +const blank = { text: "" } as const; /** A label, which starts as `blank`, the value commands on other threads give it by identity. */ type Label = { readonly text: string }; -const Label = { schema: { default: blank } }; +const Label = { default: blank }; const byCell = Index.create({ key: [cell], components: { with: [amount] } }); const allTagged = Index.create({ key: [], components: { with: [tagged] } }); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index 1074cb56..672ac165 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -235,7 +235,7 @@ describe("reductions", () => { it("reject what can't be reduced", async () => { // Booleans reduce bitwise. - expect(() => registerResource(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); + expect(() => registerResource(summed, { type: { type: "boolean" }, default: false, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error only a resource with a reduce trait is reduced expect(() => System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index bdcd4858..9ae6a561 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -23,10 +23,10 @@ declare module "@adobe/ecs" { } } /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; +const none = { text: "none" } as const; /** A caption, which starts as `none`, the value commands on other threads give it by identity. */ type Caption = { readonly text: string } | null; -const Caption = { schema: { default: none } }; +const Caption = { default: none }; /** A caption's text, null for none, as the watchers record it. */ const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 3e62c7d9..92eadac1 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -34,10 +34,10 @@ declare module "@adobe/ecs" { /** A value that isn't data. */ type Widget = { readonly draw: () => string }; /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; +const none = { text: "none" } as const; /** A caption, which starts as `none`, the value commands on other threads give it by identity. */ type Caption = { readonly text: string } | null; -const Caption = { schema: { default: none } }; +const Caption = { default: none }; /** A widget, or the number a kernel gives one, which only the world's thread could hold. */ const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index b4ce135d..5ae31eb5 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -75,7 +75,7 @@ describe("rules", () => { expect(() => Rule.create(identify({ components: { with: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); expect(programRules()).toHaveLength(rules); }); - it("derive components where they match, from the schema default, and remove them where they stop", async () => { + it("derive components where they match, from their start, and remove them where they stop", async () => { const world = createWorld({ [workers]: 0 }); const step = at(world); let a = Entity.none, b = Entity.none, c = Entity.none; diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 2ac3b08e..88ef8ac1 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -424,7 +424,7 @@ export const idOf = (state: State, key: Key): number => { /** * What a resource or derived component starts at: a new empty `Map` or `Set` when it starts fresh, so each world has - * its own; its declaration's default or its schema's; or `null` when its type allows leaving it out. + * its own; its declaration's default, which the build gives; or `null` when its type allows leaving it out. */ export const defaultOf = (key: Key): unknown => { const declaration = declarationOf(key); @@ -434,7 +434,6 @@ export const defaultOf = (key: Key): unknown => { if ("default" in declaration) return declaration.default; const { type } = declaration; if (type === undefined) return null; - if ("default" in type) return type.default; // A union of number constants starts at its first, so its value is always one of them. return numberConstants(type)?.[0] ?? null; }; diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index ce8b4299..83a76e37 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -70,19 +70,19 @@ describe("registering a component", () => { it("rejects a declaration whose traits can't hold, recording nothing", () => { expect(() => register(Symbol("typesComponentTest/strayTrait"), { type: { type: "number" }, unknown: true })).toThrow("unknown isn't a registered trait"); expect(() => register(Symbol("typesComponentTest/badReference"), { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); - expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component's type has a default"); + expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component has a default"); expect(() => register(Symbol("typesComponentTest/reduced"), { type: { type: "number" }, reduce: "+" })).toThrow("only a resource is reduced"); - expect(() => register(Symbol("typesComponentTest/derivedWithin"), { type: { type: "number", default: 0 }, derived: true, within: count })).toThrow("a derived component's rule covers within"); - expect(() => register(Symbol("typesComponentTest/derivedReference"), { type: { ...Entity.schema, default: Entity.none }, derived: true, reference: "owner" })).toThrow("a rule can't set a derived component's target"); + expect(() => register(Symbol("typesComponentTest/derivedWithin"), { type: { type: "number" }, default: 0, derived: true, within: count })).toThrow("a derived component's rule covers within"); + expect(() => register(Symbol("typesComponentTest/derivedReference"), { type: { ...Entity.schema }, default: Entity.none, derived: true, reference: "owner" })).toThrow("a rule can't set a derived component's target"); const badReference = Symbol("typesComponentTest/badReference"); expect(() => register(badReference, { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); expect(declarationOf(badReference)).toBe(undefined); expect(kindOf(badReference)).toBe(undefined); }); it("rejects a default that isn't one of its type's values, however it's given", () => { - expect(() => register(Symbol("typesComponentTest/tallHeight"), { type: { type: "number", precision: 1, default: "tall" } })).toThrow("its default, tall, isn't one of its type's values"); - expect(() => register(Symbol("typesComponentTest/lowLevel"), { type: { type: "integer", minimum: 1, maximum: 9, default: 0 }, derived: true })).toThrow("isn't one of its type's values"); - expect(() => register(Symbol("typesComponentTest/floor"), { type: { type: "number", precision: 1, default: -Infinity } })).not.toThrow(); + expect(() => register(Symbol("typesComponentTest/tallHeight"), { type: { type: "number", precision: 1 }, default: "tall" })).toThrow("its default, tall, isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/lowLevel"), { type: { type: "integer", minimum: 1, maximum: 9 }, default: 0, derived: true })).toThrow("isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/floor"), { type: { type: "number", precision: 1 }, default: -Infinity })).not.toThrow(); }); it("rejects a schema whose GPU layout can't hold it", () => { expect(() => register(Symbol("typesComponentTest/storageText"), { type: { type: "array", items: { type: "string" }, layout: "storage" } })).toThrow("layout needs numbers"); @@ -101,8 +101,9 @@ describe("registering a component", () => { expect(Reflect.has(declarationOf(bitmap) ?? {}, "type")).toBe(false); expect(() => register(bitmap, { transfer: true })).not.toThrow(); }); - it("rejects a default beside a component's schema, which holds it", () => { - expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).toThrow("its schema holds its default"); + it("takes a component's default beside its schema, never in it", () => { + expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register(Symbol("typesComponentTest/inSchema"), { type: { type: "number", default: 0 } })).toThrow("its schema holds a default, which a schema never does"); }); it("takes traits other packages register, in the namespace components share", () => { const early = Symbol("typesComponentTest/early"); @@ -111,6 +112,6 @@ describe("registering a component", () => { expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); expect(() => addTrait(mark)).not.toThrow(); - expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); + expect(declarationOf(slot)).toEqual({ type: { type: "number" }, default: 0, derived: true }); }); }); diff --git a/packages/ecs/src/interface/entity/index.ts b/packages/ecs/src/interface/entity/index.ts index da4641d4..acfdaed3 100644 --- a/packages/ecs/src/interface/entity/index.ts +++ b/packages/ecs/src/interface/entity/index.ts @@ -7,3 +7,6 @@ export const schema = { type: "integer", format: "entity" } as const satisfies S /** No entity: index 0 is never one, so zeroed memory and an entity-valued default mean none, and a lookup of it returns null. */ export const none = 0 as Entity; + +/** What an entity-typed value starts at: none. */ +export { none as default }; diff --git a/packages/ecs/src/interface/math/f32/default.ts b/packages/ecs/src/interface/math/f32/default.ts new file mode 100644 index 00000000..a1866beb --- /dev/null +++ b/packages/ecs/src/interface/math/f32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { F32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: F32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/f32/public.ts b/packages/ecs/src/interface/math/f32/public.ts index f525dd49..251ce21f 100644 --- a/packages/ecs/src/interface/math/f32/public.ts +++ b/packages/ecs/src/interface/math/f32/public.ts @@ -1,2 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./schema.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/i32/default.ts b/packages/ecs/src/interface/math/i32/default.ts new file mode 100644 index 00000000..d1282247 --- /dev/null +++ b/packages/ecs/src/interface/math/i32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { I32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: I32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/i32/public.ts b/packages/ecs/src/interface/math/i32/public.ts index ecfb1e4c..ef2a77f9 100644 --- a/packages/ecs/src/interface/math/i32/public.ts +++ b/packages/ecs/src/interface/math/i32/public.ts @@ -1,3 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; export { shiftRight } from "./shift-right.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/mat4x4/public.ts b/packages/ecs/src/interface/math/mat4x4/public.ts index 3de5992b..6c7615ee 100644 --- a/packages/ecs/src/interface/math/mat4x4/public.ts +++ b/packages/ecs/src/interface/math/mat4x4/public.ts @@ -19,3 +19,4 @@ export * from "./translation.js"; export * from "./transpose.js"; export * from "./zero.js"; export * from "./schema.js"; +export { identity as default } from "./identity.js"; diff --git a/packages/ecs/src/interface/math/quat/public.ts b/packages/ecs/src/interface/math/quat/public.ts index 3bb6df16..46a04ede 100644 --- a/packages/ecs/src/interface/math/quat/public.ts +++ b/packages/ecs/src/interface/math/quat/public.ts @@ -23,3 +23,4 @@ export * from "./to-axis-angle.js"; export * from "./to-euler.js"; export * from "./to-mat4.js"; export * from "./schema.js"; +export { identity as default } from "./identity.js"; diff --git a/packages/ecs/src/interface/math/u32/default.ts b/packages/ecs/src/interface/math/u32/default.ts new file mode 100644 index 00000000..3e7a3188 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { U32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: U32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/u32/public.ts b/packages/ecs/src/interface/math/u32/public.ts index c9c88f49..8328ba9f 100644 --- a/packages/ecs/src/interface/math/u32/public.ts +++ b/packages/ecs/src/interface/math/u32/public.ts @@ -5,3 +5,4 @@ export { countTrailingZeros } from "./count-trailing-zeros.js"; export { countOneBits } from "./count-one-bits.js"; export { reverseBits } from "./reverse-bits.js"; export { shiftRight } from "./shift-right.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/public.ts b/packages/ecs/src/interface/math/vec2/f32/public.ts index cab939cc..a6598708 100644 --- a/packages/ecs/src/interface/math/vec2/f32/public.ts +++ b/packages/ecs/src/interface/math/vec2/f32/public.ts @@ -46,3 +46,4 @@ export * from "./tanh.js"; export * from "./trunc.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/public.ts b/packages/ecs/src/interface/math/vec2/i32/public.ts index e4fe1fce..930056ad 100644 --- a/packages/ecs/src/interface/math/vec2/i32/public.ts +++ b/packages/ecs/src/interface/math/vec2/i32/public.ts @@ -20,3 +20,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec2/public.ts b/packages/ecs/src/interface/math/vec2/public.ts index 93fe90ca..14cf0270 100644 --- a/packages/ecs/src/interface/math/vec2/public.ts +++ b/packages/ecs/src/interface/math/vec2/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/public.ts b/packages/ecs/src/interface/math/vec2/u32/public.ts index 640d9450..ff234cb1 100644 --- a/packages/ecs/src/interface/math/vec2/u32/public.ts +++ b/packages/ecs/src/interface/math/vec2/u32/public.ts @@ -19,3 +19,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/public.ts b/packages/ecs/src/interface/math/vec3/f32/public.ts index 1cac2de0..0bd8c720 100644 --- a/packages/ecs/src/interface/math/vec3/f32/public.ts +++ b/packages/ecs/src/interface/math/vec3/f32/public.ts @@ -52,3 +52,4 @@ export * from "./tanh.js"; export * from "./trunc.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/public.ts b/packages/ecs/src/interface/math/vec3/i32/public.ts index 104673aa..13158cb8 100644 --- a/packages/ecs/src/interface/math/vec3/i32/public.ts +++ b/packages/ecs/src/interface/math/vec3/i32/public.ts @@ -22,3 +22,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/public.ts b/packages/ecs/src/interface/math/vec3/public.ts index 93fe90ca..14cf0270 100644 --- a/packages/ecs/src/interface/math/vec3/public.ts +++ b/packages/ecs/src/interface/math/vec3/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/public.ts b/packages/ecs/src/interface/math/vec3/u32/public.ts index e0900aef..1ed0dc7e 100644 --- a/packages/ecs/src/interface/math/vec3/u32/public.ts +++ b/packages/ecs/src/interface/math/vec3/u32/public.ts @@ -22,3 +22,4 @@ export * from "./subtract.js"; export * from "./volume.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/default.ts b/packages/ecs/src/interface/math/vec4/f32/default.ts new file mode 100644 index 00000000..665b9971 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { F32 } from "./f32.js"; + +/** What a value of this type starts at. */ +const start: F32 = [0, 0, 0, 0]; +export { start as default }; diff --git a/packages/ecs/src/interface/math/vec4/f32/public.ts b/packages/ecs/src/interface/math/vec4/f32/public.ts index e81496a7..0d0ba497 100644 --- a/packages/ecs/src/interface/math/vec4/f32/public.ts +++ b/packages/ecs/src/interface/math/vec4/f32/public.ts @@ -45,3 +45,4 @@ export * from "./tan.js"; export * from "./tanh.js"; export * from "./trunc.js"; export * from "./schema.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/public.ts b/packages/ecs/src/interface/math/vec4/i32/public.ts index 104673aa..13158cb8 100644 --- a/packages/ecs/src/interface/math/vec4/i32/public.ts +++ b/packages/ecs/src/interface/math/vec4/i32/public.ts @@ -22,3 +22,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec4/public.ts b/packages/ecs/src/interface/math/vec4/public.ts index 93fe90ca..14cf0270 100644 --- a/packages/ecs/src/interface/math/vec4/public.ts +++ b/packages/ecs/src/interface/math/vec4/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/public.ts b/packages/ecs/src/interface/math/vec4/u32/public.ts index 48624073..aa7f5467 100644 --- a/packages/ecs/src/interface/math/vec4/u32/public.ts +++ b/packages/ecs/src/interface/math/vec4/u32/public.ts @@ -21,3 +21,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index ec6b21b6..65026ec8 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -10,10 +10,10 @@ import { addTrait, isTrait } from './traits.js'; /** * A registered declaration, as any consumer iterating components or resources sees it: its `type` and other traits. - * Without `type`, the value isn't data. A resource's `default`, or a derived object's, is what it starts at, when its - * schema has none; `fresh` starts it as an empty `Map` or `Set` of each world's own. + * Without `type`, the value isn't data. `default` is what it starts at, never its schema's, which holds none; `fresh` + * starts it as an empty `Map` or `Set` of each world's own. */ -export type AnyDeclaration = { readonly type?: Schema & { readonly default?: unknown }; readonly default?: unknown; readonly fresh?: unknown } & { readonly [T in TraitKey]?: unknown }; +export type AnyDeclaration = { readonly type?: Schema; readonly default?: unknown; readonly fresh?: unknown } & { readonly [T in TraitKey]?: unknown }; /** The value schema of any registered component or resource, or undefined for a value that isn't data. */ export const schemaOf = (key: unknown): Schema | undefined => declarationOf(key)?.type; @@ -39,6 +39,20 @@ export const threadOfDeclaration = (declaration: AnyDeclaration): string | null return typeof declaration.thread === "string" ? declaration.thread : "world"; }; +/** Whether a schema, or any schema inside it, holds a `default`. */ +const holdsDefault = (schema: Schema): boolean => { + if ("default" in schema) return true; + if ("anyOf" in schema) return schema.anyOf.some(holdsDefault); + if ("allOf" in schema) return schema.allOf.some(holdsDefault); + if ("const" in schema) return false; + switch (schema.type) { + case "object": return Object.values(schema.properties).some(holdsDefault); + case "array": case "set": return holdsDefault(schema.items); + case "map": return holdsDefault(schema.keys) || holdsDefault(schema.values); + default: return false; + } +}; + /** Throws for a declaration whose traits can't hold, so far as values tell: types check the rest. */ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDeclaration): void => { const fail = (message: string): never => failRegistration(kind, key, message); @@ -46,17 +60,14 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl if (trait !== "default" && trait !== "fresh" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); } const { type, fresh } = declaration; - if (type !== undefined && "default" in declaration && (kind === "component" || "default" in type)) { - fail(kind === "component" ? "its schema holds its default." : "its default is given twice, by its schema and its declaration."); - } + if (type !== undefined && holdsDefault(type)) fail("its schema holds a default, which a schema never does: its start is its declaration's default."); if (fresh !== undefined) { if (fresh !== "Map" && fresh !== "Set") fail(`fresh is "Map" or "Set".`); - if ("default" in declaration || (type !== undefined && "default" in type)) fail("it starts fresh, so it has no default."); + if ("default" in declaration) fail("it starts fresh, so it has no default."); if (type !== undefined && "type" in type && (type.type === "set" || type.type === "map") && (type.type === "set") !== (fresh === "Set")) fail(`a ${type.type} starts as a fresh ${type.type === "set" ? "Set" : "Map"}.`); } - // A default however given, a literal, its schema's or a namespaced schema's, is one of its type's values. - const start: unknown = "default" in declaration ? declaration.default : type !== undefined && "default" in type ? type.default : undefined; - if (type !== undefined && ("default" in declaration || "default" in type) && !matchesSchema(type, start)) fail(`its default, ${labelOf(start)}, isn't one of its type's values.`); + // A start however the build found it, a literal, its type's namespace's or an identity, is one of its type's values. + if (type !== undefined && "default" in declaration && !matchesSchema(type, declaration.default)) fail(`its default, ${labelOf(declaration.default)}, isn't one of its type's values.`); try { layoutOf(type); } @@ -75,8 +86,8 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl if (declaration.derived !== undefined && kind !== "component") fail("only a component is derived."); if (declaration.derived === true && declaration.within !== undefined) fail("a derived component's rule covers within."); if (declaration.derived === true && declaration.reference !== undefined) fail("a rule can't set a derived component's target, so it's no reference."); - if (declaration.derived === true && type !== undefined && fresh === undefined && !("default" in type || "const" in type || allowsNull(type))) { - fail("a derived component's type has a default, which it starts at."); + if (declaration.derived === true && type !== undefined && fresh === undefined && !("default" in declaration || "const" in type || allowsNull(type))) { + fail("a derived component has a default, which it starts at."); } if (declaration.reduce !== undefined) { const operator = declaration.reduce; diff --git a/packages/ecs/src/interface/registry/declared.test.ts b/packages/ecs/src/interface/registry/declared.test.ts index 093bdfee..35697597 100644 --- a/packages/ecs/src/interface/registry/declared.test.ts +++ b/packages/ecs/src/interface/registry/declared.test.ts @@ -8,10 +8,10 @@ describe("declarations the build records", () => { it("registers a key at its first use, as its kind says", () => { const speed = Symbol("declaredTest/speed"); const tag = Symbol("declaredTest/tag"); - recordDeclaration(speed, "resource", { type: { type: "number", default: 1 } }); + recordDeclaration(speed, "resource", { type: { type: "number" }, default: 1 }); recordDeclaration(tag, "trait"); expect([kindOf(speed), kindOf(tag)]).toEqual(["resource", "trait"]); - expect(declarationOf(speed)).toEqual({ type: { type: "number", default: 1 } }); + expect(declarationOf(speed)).toEqual({ type: { type: "number" }, default: 1 }); }); it("registers in the order modules recorded them, so a trait comes before what declares it", () => { const mark = Symbol("declaredTest/mark"); diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index 8972cdbf..4b1d2313 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -41,7 +41,7 @@ identify(taggedAgain, "idsTest/keys.ts#tagged"); // registers a key as soon as it's made. registerComponent(score, { type: { type: "number" } }); registerComponent(shown, { type: { const: true }, derived: true }); -registerResource(level, { type: { type: "number", default: 0 } }); +registerResource(level, { type: { type: "number" }, default: 0 }); registerComponent(changed, { type: { type: "number" } }); registerComponent(crossed, { type: { type: "number" } }); addTrait(tagged); @@ -63,7 +63,7 @@ describe("keys with ids", () => { it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { registerComponent(scoreAgain, { type: { type: "number" } }); registerComponent(shownAgain, { type: { const: true }, derived: true }); - registerResource(levelAgain, { type: { type: "number", default: 0 } }); + registerResource(levelAgain, { type: { type: "number" }, default: 0 }); addTrait(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); @@ -72,7 +72,7 @@ describe("keys with ids", () => { it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { expect(() => registerComponent(changedAgain, { type: { type: "string" } })).toThrow( `Cannot register component "changed" (idsTest/keys.ts#changed): its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); - expect(() => registerResource(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( + expect(() => registerResource(crossedAgain, { type: { type: "number" }, default: 0 })).toThrow( `Cannot register resource "crossed" (idsTest/keys.ts#crossed): its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index 75319bde..21112ea0 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -17,7 +17,7 @@ declare module "@adobe/ecs" { export interface Resources { [time]: { type: number; default: 0 }; [symbol]: { type: boolean; default: false }; - [cache]: { type: Map; thread: "main" }; + [cache]: { type: ReadonlyMap; thread: "main" }; [port]: { type: MessagePort | null; transfer: true }; [total]: { type: number; default: 0; reduce: "+" }; [flags]: { type: boolean; default: false; reduce: "|" }; @@ -33,54 +33,52 @@ const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "nu describe("registering a resource", () => { it("records the declaration by its symbol, which needs no description", () => { - expect(declarationOf(time)).toEqual({ type: { type: "number", default: 0 } }); - expect(declarationOf(symbol)).toEqual({ type: { type: "boolean", default: false } }); + expect(declarationOf(time)).toEqual({ type: { type: "number" }, default: 0 }); + expect(declarationOf(symbol)).toEqual({ type: { type: "boolean" }, default: false }); expect(kindOf(symbol)).toBe("resource"); const anonymous = Symbol(); - register(anonymous, { type: { type: "boolean", default: false } }); + register(anonymous, { type: { type: "boolean" }, default: false }); expect(kindOf(anonymous)).toBe("resource"); // Another symbol with the same description is another key. - expect(() => register(Symbol("typesResourceTest/symbol"), { type: { type: "number", default: 0 } })).not.toThrow(); - expect(() => register("typesResourceTest/text", { type: { type: "number", default: 0 } })).toThrow("a key is a plain Symbol(...), not a string."); + expect(() => register(Symbol("typesResourceTest/symbol"), { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register("typesResourceTest/text", { type: { type: "number" }, default: 0 })).toThrow("a key is a plain Symbol(...), not a string."); }); it("accepts the same declaration again, and rejects a different one or one also declared as a component", () => { - expect(() => register(time, { type: { type: "number", default: 0 } })).not.toThrow(); - expect(() => register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); + expect(() => register(time, { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register(time, { type: { type: "number" }, default: 1 })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); const shared = Symbol("typesResourceTest/shared"); registerComponent(shared, { type: { const: true } }); - expect(() => register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); + expect(() => register(shared, { type: { const: true }, default: true })).toThrow("already registered as a component"); }); - it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { + it("takes a resource's default from its declaration, never its schema, and compares sets and maps by their entries", () => { expect(declarationOf(names)).toEqual({ type: namesSchema, fresh: "Map" }); expect(declarationOf(tags)).toEqual({ type: { type: "set", items: { type: "string" } }, fresh: "Set" }); const listed = Symbol("typesResourceTest/listed"); - expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); - expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 1]]) } })).not.toThrow(); - expect(() => register(listed, { type: { ...namesSchema, default: new Map([["a", 2]]) } })).toThrow("with another declaration"); - expect(() => register(Symbol("typesResourceTest/twice"), { type: { type: "number", default: 0 }, default: 1 })).toThrow("its default is given twice"); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 1]]) })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 1]]) })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 2]]) })).toThrow("with another declaration"); + expect(() => register(Symbol("typesResourceTest/inSchema"), { type: { type: "number", default: 0 } })).toThrow("its schema holds a default, which a schema never does"); + expect(() => register(Symbol("typesResourceTest/inField"), { type: { type: "object", properties: { a: { type: "number", default: 0 } } } })).toThrow("its schema holds a default"); expect(threadOfDeclaration(declarationOf(names) ?? {})).toBe("world"); }); it("declares gpuDevice on \"world\", and the loop's state as resources", () => { expect(declarationOf(gpuDevice)).toEqual({}); expect(threadOfDeclaration(declarationOf(gpuDevice) ?? {})).toBe("world"); - expect(declarationOf(workers)).toEqual({ type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); + expect(declarationOf(workers)).toEqual({ type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }] }, default: null }); // null isn't a struct, so workers is an object resource on "world". expect(threadOfDeclaration(declarationOf(workers) ?? {})).toBe("world"); - const defaults = [cadence, always, wake, clock].map(name => { - const type = declarationOf(name)?.type; - return type !== undefined && "default" in type ? type.default : undefined; - }); + const defaults = [cadence, always, wake, clock].map(name => declarationOf(name)?.default); expect(defaults).toEqual([{ kind: "frame" }, false, Infinity, { now: 0, delta: 0, tick: 0 }]); expect(declarationOf(wake)?.reduce).toBe("min"); expect([cadence, always, wake, clock].map(name => threadOfDeclaration(declarationOf(name) ?? {}))).toEqual(["world", null, null, null]); }); it("checks thread and transfer as far as values tell", () => { - expect(declarationOf(cache)).toEqual({ thread: "main", fresh: "Map" }); + expect(declarationOf(cache)).toEqual({ type: namesSchema, thread: "main", fresh: "Map" }); expect(threadOfDeclaration(declarationOf(cache) ?? {})).toBe("main"); expect(declarationOf(port)).toEqual({ transfer: true }); expect(() => register(Symbol("typesResourceTest/buffer"), { default: new ArrayBuffer(8), transfer: true })).not.toThrow(); expect(() => register(Symbol("typesResourceTest/map"), { default: new Map(), transfer: true })).toThrow("its value isn't Transferable"); - expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number", default: 0 }, thread: "main" })).toThrow("a struct lives in shared memory"); + expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number" }, default: 0, thread: "main" })).toThrow("a struct lives in shared memory"); }); it("starts a fresh value only as an empty Map or Set, and not beside a default", () => { expect(() => register(Symbol("typesResourceTest/array"), { fresh: "Array" })).toThrow(`fresh is "Map" or "Set".`); @@ -88,8 +86,8 @@ describe("registering a resource", () => { expect(() => register(Symbol("typesResourceTest/set"), { type: { type: "set", items: { type: "string" } }, fresh: "Map" })).toThrow("a set starts as a fresh Set."); }); it("checks reductions", () => { - expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number", default: 0 }, reduce: "-" })).toThrow('reduce is one of "+"'); - expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean", default: false }, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); - expect(() => register(Symbol("typesResourceTest/text"), { type: { type: "string", default: "" }, reduce: "max" })).toThrow("a reduced resource is a struct"); + expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number" }, default: 0, reduce: "-" })).toThrow('reduce is one of "+"'); + expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean" }, default: false, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); + expect(() => register(Symbol("typesResourceTest/text"), { type: { type: "string" }, default: "", reduce: "max" })).toThrow("a reduced resource is a struct"); }); }); diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index 3d06f05b..ad1ca7fc 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -registerResource(always, { type: { type: "boolean", default: false } }); +registerResource(always, { type: { type: "boolean" }, default: false }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index b0e010b3..94279103 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -23,6 +23,6 @@ registerResource(cadence, { { type: "object", properties: { kind: { const: "frame" } } }, { type: "object", properties: { kind: { const: "fixed" }, step: { type: "number", minimum: 0 }, maxSteps: { type: "integer", minimum: 1 } }, required: ["kind", "step"] }, ], - default: { kind: "frame" }, }, + default: { kind: "frame" }, }); diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index 184fa5b8..e1595140 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -14,5 +14,6 @@ declare module '@adobe/ecs' { } registerResource(clock, { - type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, + type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } } }, + default: { now: 0, delta: 0, tick: 0 }, }); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index 188830f2..b0f5a8f4 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -13,4 +13,4 @@ declare module '@adobe/ecs' { } } -registerResource(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +registerResource(wake, { type: { type: "number" }, reduce: "min", default: Infinity }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index 65afd664..68b1cdd8 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -registerResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +registerResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }] }, default: null }); diff --git a/packages/ecs/src/interface/schema/schema.ts b/packages/ecs/src/interface/schema/schema.ts index 24c02d24..ed8ac6f1 100644 --- a/packages/ecs/src/interface/schema/schema.ts +++ b/packages/ecs/src/interface/schema/schema.ts @@ -17,12 +17,14 @@ interface ObjectSchema { readonly properties: { readonly [P in K]: Schema }; readonly required?: readonly K[]; readonly layout?: GpuLayout; + readonly default?: never; } interface StringSchema { readonly type: 'string'; readonly minLength?: number; readonly maxLength?: number; + readonly default?: never; } interface ArraySchema { @@ -31,6 +33,7 @@ interface ArraySchema { readonly layout?: GpuLayout; readonly minItems?: number; readonly maxItems?: number; + readonly default?: never; } interface NumberSchema { @@ -38,12 +41,14 @@ interface NumberSchema { readonly minimum?: number; readonly maximum?: number; readonly precision?: 1 | 2; + readonly default?: never; } /** An entity reference, typed `Entity`. Every entity-typed value uses it, so references can be found by schema. */ interface EntitySchema { readonly type: 'integer'; readonly format: 'entity'; + readonly default?: never; } interface IntegerSchema { @@ -51,20 +56,24 @@ interface IntegerSchema { readonly format?: never; readonly minimum?: number; readonly maximum?: number; + readonly default?: never; } interface BooleanSchema { readonly type: 'boolean'; + readonly default?: never; } interface NullSchema { readonly type: 'null'; + readonly default?: never; } /** A `ReadonlySet` of `items`' values. JSON has no set, so `'set'` isn't a JSON Schema type. */ interface SetSchema { readonly type: 'set'; readonly items: T; + readonly default?: never; } /** A `ReadonlyMap` from `keys`' values to `values`' values. JSON has no map, so `'map'` isn't a JSON Schema type. */ @@ -72,6 +81,7 @@ interface MapSchema { readonly type: 'map'; readonly keys: K; readonly values: V; + readonly default?: never; } /** Matches exactly one JSON value of any shape. */ @@ -79,19 +89,23 @@ interface ConstSchema { /** The value alone fully describes the schema; combining it with `type` is redundant and disallowed. */ readonly type?: never; readonly const: JSONValue; + readonly default?: never; } interface AndSchema { readonly allOf: readonly Schema[]; + readonly default?: never; } interface OrSchema { readonly anyOf: readonly Schema[]; + readonly default?: never; } /** * A JSON Schema of the forms the ECS reads, and two forms JSON lacks: `{ type: 'set', items }` and - * `{ type: 'map', keys, values }`. + * `{ type: 'map', keys, values }`. A schema describes values and holds no `default`: a value's start comes from its + * declaration or its type's namespace, `T.default`. */ export type Schema = EntitySchema | ObjectSchema | StringSchema | ArraySchema | NumberSchema | IntegerSchema | BooleanSchema | NullSchema | SetSchema | MapSchema | ConstSchema | AndSchema | OrSchema; diff --git a/packages/ecs/src/interface/schema/schema.type-test.ts b/packages/ecs/src/interface/schema/schema.type-test.ts index f2b63ef2..1769737a 100644 --- a/packages/ecs/src/interface/schema/schema.type-test.ts +++ b/packages/ecs/src/interface/schema/schema.type-test.ts @@ -144,3 +144,12 @@ check>(); check, readonly [number, number, number]>>(); // @ts-expect-error its argument is a schema type NotSchema = Schema.Type<{ type: "integer"; minimum: "zero" }>; + +// A schema holds no default, anywhere: a value's start comes from its declaration or its type's namespace. +// @ts-expect-error a default isn't part of a schema +const badDefault: Schema = { type: 'number', default: 0 }; +// @ts-expect-error nor of a field's schema +const badFieldDefault: Schema = { type: 'object', properties: { x: { type: 'number', default: 0 } } }; +// @ts-expect-error Schema.Type takes a schema, so a default fails there too +type BadType = Schema.Type<{ type: 'integer'; minimum: 1; default: 5 }>; +void [badDefault, badFieldDefault]; diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index 39bf6634..6943c796 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -10,7 +10,7 @@ declare module "@adobe/ecs" { interface Components { [mass]: { type: number }; [position]: { type: number }; - [pool]: { type: Map; derived: true }; + [pool]: { type: ReadonlyMap; derived: true }; } } const access = (lookup: Access["lookup"]): Access => ({ components: null, resources: null, lookup, up: null, at: "a test" }); diff --git a/packages/ecs/src/interface/trait/trait.type-test.ts b/packages/ecs/src/interface/trait/trait.type-test.ts index d95442e4..0ed58cfb 100644 --- a/packages/ecs/src/interface/trait/trait.type-test.ts +++ b/packages/ecs/src/interface/trait/trait.type-test.ts @@ -45,7 +45,7 @@ declare module '@adobe/ecs' { } export interface Resources { [time]: { type: number; default: 0 }; - [sum]: { type: number; default: 0; reduce: "+" }; + [sum]: { type: number; reduce: "+" }; } } diff --git a/packages/ecs/src/perftest/log.ts b/packages/ecs/src/perftest/log.ts index 49c02a2d..37ab2d3d 100644 --- a/packages/ecs/src/perftest/log.ts +++ b/packages/ecs/src/perftest/log.ts @@ -25,10 +25,10 @@ declare module "@adobe/ecs" { [moved]: { type: true }; } } -const none = { text: "none" }; +const none = { text: "none" } as const; /** A row's label, which starts as `none`, so the mirror's copies are told apart by identity. */ type Label = { readonly text: string } | null; -const Label = { schema: { default: none } }; +const Label = { default: none }; /** Entities moved, and swapped out of the rows they leave, per sync point of `log replay`. */ export const replayMoves = 10_000; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index 0b246823..f922a18a 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -37,10 +37,10 @@ declare module "@adobe/ecs" { } } /** An object of the joined thread, as a canvas or the DOM is, which places the systems reading it there. */ -const surfaceStart = { name: "surface" }; +const surfaceStart = { name: "surface" } as const; /** The surface tails draw to, which starts as `surfaceStart`, so they can tell it apart by identity. */ type Surface = { readonly name: string }; -const Surface = { schema: { default: surfaceStart } }; +const Surface = { default: surfaceStart }; /** What the tails read, so their reads aren't dead. */ export const drawn = { sum: 0 }; From 3024fb2458404c65b178572e69d88191396879d7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 00:10:18 -0700 Subject: [PATCH 105/127] ecs: docs give starts from declarations and types' namespaces, never schemas Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 17 +++++++++-------- packages/ecs/src/guide/01-resources.test.ts | 10 +++++----- packages/ecs/src/guide/guide.md | 10 +++++----- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index e2dd913e..bb88b8ae 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -77,13 +77,14 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - - **A resource always has a value,** and so does a derived component. The first of these applies: + - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies: 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value; - 2. its type's schema's default: a `Schema.Type<{ …; default }>`, or a namespaced type's, the value beside the type alias with the same name holding a `schema` with a `default` (`type Height = F32; const Height = { schema: { ...F32.schema, default: -Infinity } }`), read as `Height.schema.default`, the one way to give a value no literal type writes; - 3. `null`, when its type allows it; - 4. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, `Entity.none` for an entity, and a union's literal that is one of those; - 5. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap`, `ReadonlySet`, `Map` or `Set`. - Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, or a class that isn't null. A component's literal or schema default is its start the same way, which commands that give it no value add. The registration puts a data value's default in its schema and gives another's beside it. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespaced schema's. + 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const`, around it or the whole namespace, or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one); anything else fails the build at the namespace. Imported as a type alone, a namespace's default the build can read as data is written into the registration; + 3. for a reduced resource, its operator's identity, leaf by leaf within its type's range (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it; this outranks the package's own types' defaults, never a namespace of yours; + 4. `null`, when its type allows it; + 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those; + 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`; a `Map` or `Set` type gets none. + Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's. - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. @@ -177,7 +178,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, its `id`, the plugin's or null, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label and id. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. - Each system keeps its own `lastRun`, so it sees every change since it last ran. - `dispose()` and `[Symbol.asyncDispose]` wait for pending runs, then stop ticking, terminate workers and release memory, so `await using world = ...` works. - - **Initial state** comes from the resources' schema defaults. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. + - **Initial state** comes from the resources' starts. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. - **A sync point runs in three phases:** 1. **Plan.** Merge command buffers in the run's sorted system order, then chunk index, never worker ID, so conflicting commands resolve the same way every run. Expand the declared cascades and the rule closure. The result is a net plan of `fromArch → toArch` for each entity, covering the whole flush, with nothing applied yet. Only the net first→last archetype matters, so an entity that passes A→B→C produces no events for B. 2. **Finalize, once,** against the final plan, while every row is still live (see Finalizers). @@ -331,7 +332,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - One dispatch over several archetypes, and indirect draws; heap segments past `maxBufferSize`; automatic placement. - Finalizers on components ahead on the GPU: until then, the sync point reads them back first. - Read back only the rows a sync point moves, rather than whole disturbed archetypes, or move them on the GPU. -- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at the schema default. +- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at their start. - **The use case:** GPU state with history that nothing on the CPU reads, such as a simulation integrated only on the GPU, previous-frame transforms for motion vectors, or accumulators. Data recomputed every frame (culled lists, indirect draw arguments, sort keys) isn't state: render systems own those as scratch buffers. Cached derived data (world matrices, skinning palettes) is an optimization, not a need. - **What it adds over an ordinary component written only on the GPU,** which already stays GPU-resident and is never read back unless observed: the CPU column's memory, and a type-level guarantee that CPU systems and observers can't name it. - **What it requires:** structural changes on the GPU (the move kernel above), since there's no CPU copy to move. diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index f753f2ce..4ac2f96b 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -10,19 +10,19 @@ export const names = Symbol(); export const audio = Symbol(); export const focused = Symbol(); -// A value no literal writes starts at the default its type's namespaced schema holds. +// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly. export type Height = F32; -export const Height = { schema: { ...F32.schema, default: -Infinity } } as const; +export const Height = { default: -Infinity } as const; // It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, -// it starts at its type's: its schema's default, null, its zero value (0, false, "", item by item), or an empty Map or -// Set of each world's own. The build registers what's declared. +// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by +// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { [gravity]: { type: number; default: -9.8 }; [floor]: { type: Height }; [names]: { type: ReadonlyMap }; - // A value that isn't data, such as a class, is stored as it is. + // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back. [audio]: { type: AudioContext | null }; // Entity.none, 0, rather than null, so an entity stays data every thread reads. [focused]: { type: Entity }; diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index d1b26b02..fd77d15b 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -69,19 +69,19 @@ export const names = Symbol(); export const audio = Symbol(); export const focused = Symbol(); -// A value no literal writes starts at the default its type's namespaced schema holds. +// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly. export type Height = F32; -export const Height = { schema: { ...F32.schema, default: -Infinity } } as const; +export const Height = { default: -Infinity } as const; // It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, -// it starts at its type's: its schema's default, null, its zero value (0, false, "", item by item), or an empty Map or -// Set of each world's own. The build registers what's declared. +// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by +// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { [gravity]: { type: number; default: -9.8 }; [floor]: { type: Height }; [names]: { type: ReadonlyMap }; - // A value that isn't data, such as a class, is stored as it is. + // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back. [audio]: { type: AudioContext | null }; // Entity.none, 0, rather than null, so an entity stays data every thread reads. [focused]: { type: Entity }; From fe6689b0338a6657154f82aa7a57f504953a1a02 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 00:52:15 -0700 Subject: [PATCH 106/127] ecs: follow-ups record run results resolving to the body's value, and explicit transactions Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index b18bb804..ef997b9e 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -5,6 +5,13 @@ correctness, then optimization. Remove an item once it's decided or done. ## API changes +- **Run results: a run resolves to its body's value; reports leave the public API** (agreed in design, not started). + `run(system, input): Promise`, R the per-run body's return type (`void` for row systems, composites and bodies + returning nothing), so one-off reads are observer systems returning a value. A run whose systems failed rejects + after its sync point applies, with a useful error naming each failure; its exact shape is no contract. Ticks' + failures are rethrown out of band. `Report`, its archetype and types, and run numbers leave the public API; the + implementation keeps or drops reports as performance dictates. A later `World.create` options parameter may take + instrumentation (timing, failure logs, debugging); nothing until it's designed. - **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), @@ -18,8 +25,6 @@ correctness, then optimization. Remove an item once it's decided or done. - **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over the entity a reference names. (from the todo sample) -- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer - variable. - **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU @@ -49,7 +54,8 @@ correctness, then optimization. Remove an item once it's decided or done. ## Correctness -None open. +- **Transactions, future:** never by default, since they would cost every run. An explicit declarative option on a + one-off system, making its run all or nothing. ## Optimization From e54a84e1334383a601263fd29c688c7ff54ada47 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:03:12 -0700 Subject: [PATCH 107/127] ecs: follow-ups track optional components Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index ef997b9e..3cf48ca2 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -16,6 +16,12 @@ correctness, then optimization. Remove an item once it's decided or done. before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), or `world.share` returning a promise of the claim. Smallest form to be decided. +- **Optional components:** `components: { with, optional, not, … }`, rows typed `T | undefined` for each optional key, + `undefined` when the archetype lacks it; never narrows matching. Presence is per archetype, so runners bind it once + per archetype and kernels specialize per archetype or chunk: no per-row cost. Access inferred as for `with`; writes + only where present (narrowed by `!== undefined`), an absent write failing the system; `changed` on one passes rows + that have it and changed. Possibly on lookups by entity too. Replaces two systems, self-lookups or nullable + components. - **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. From 06ecd96c74608a55c98c1778076bc69ce50a6b89 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:07:44 -0700 Subject: [PATCH 108/127] ecs: starts take imported namespaces' defaults at every depth, identities only when they're values, readonly defaults without mutable casts Namespaces of yours outrank a reduction's identity at any depth; an identity outside its type falls through to null and zero values; & is every bit of its column. A namespace's default is checked against its type when the build can read it, and an object start a joined thread knows by identity is never copied from a type-only import. A schema's default is found only at keyword positions, and its error names the alias. Co-Authored-By: Claude Opus 5.5 --- .../compiler/augmentations.test.ts | 48 ++++++ .../implementation/compiler/augmentations.ts | 148 +++++++++++------- .../implementation/compiler/type-schemas.ts | 25 ++- 3 files changed, 163 insertions(+), 58 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index 1a5281e0..fafb696f 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -303,6 +303,54 @@ const mark = Symbol();`); // A Transferable class, through an alias and with null. expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declare$(key, "component", { transfer: true });`); }); + test("starts a struct's fields and a tuple's items at imported namespaces' defaults, imported as values or types", async () => { + const height = { "/src/height.ts": `import type { F32 } from "@adobe/ecs";\nexport type Height = F32;\nexport const Height = { default: 7 } as const;` }; + expect(await registrations(`import { Quat as Q } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Q } } } }`)) + .toEqual([`declare$(e, "resource", { type: {"type":"object","properties":{"q":{"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}}}, default: { "q": Q.default } });`]); + expect(await registrations(`import type { Quat } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Quat } } } }`)) + .toEqual([expect.stringContaining(`default: {"q":[0,0,0,1]}`)]); + expect(await registrations(`import type { Height } from "./height.js";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: readonly [Height, F32] } } }`, height)) + .toEqual([expect.stringContaining(`default: [7,0]`)]); + }); + test("starts a reduced resource at its operator's identity only when it's one of its type's values", async () => { + const reduced = (type: string, operator: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type}; reduce: "${operator}" } } }`); + expect(await reduced("Entity", "min")).toEqual([expect.stringContaining("default: Entity.default")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0 }>`, "min")).toEqual([expect.stringContaining("default: 0")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0; maximum: 100 }>`, "&")).toEqual([expect.stringContaining("default: 0")]); + expect(await reduced("U32", "&")).toEqual([expect.stringContaining("default: 4294967295")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>`, "+")).toEqual(expect.stringMatching(/^failure: .*default/)); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 2; maximum: 9 }>`, "*")).toEqual(expect.stringMatching(/^failure: .*default/)); + }); + test("starts a reduced struct's fields at their own types' namespaces' defaults before the identity", async () => { + expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: { readonly h: Height; readonly w: F32 }; reduce: "max" } } }\ntype Height = F32;\nconst Height = { default: 7 } as const;`)) + .toEqual([expect.stringContaining(`default: { "h": Height.default, "w": -Infinity }`)]); + }); + test("fails for an object component whose namespace is imported as a type only, since its start is one object", async () => { + expect(await registrations(`import type { Label } from "./label.js";\n${keys("k")}declare module "@adobe/ecs" { interface Components { [k]: { type: Label; thread: "main" } } }`, + { "/src/label.ts": `export type Label = { readonly text: string };\nexport const Label = { default: { text: "x" } } as const;` })).toEqual(expect.stringMatching(/^failure: .*import Label as a value/)); + }); + test("fails for a namespace's default a cast makes mutable, wherever the cast is", async () => { + const named = (namespace: string) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: L } } }\ntype L = readonly number[];\n${namespace}`); + expect(await named("const L = { default: [] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: [1, 2] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: ([1, 2] as const) as unknown as number[] };")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: [1, 2] as const satisfies readonly number[] };")).toEqual([expect.stringContaining("default: L.default")]); + }); + test("finds a default only where a schema's keywords are, and names the alias holding one", async () => { + const typed = (type: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type} } } }`); + expect(await typed(`Schema.Type<{ type: "object"; properties: { default: { type: "number" } }; required: ["default"] }>`)).toEqual([expect.stringContaining(`default: {"default":0}`)]); + expect(await typed(`Schema.Type<{ const: { default: 1 } }>`)).toEqual([expect.stringContaining(`"const":{"default":1}`)]); + expect(await registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Bad } } }\ntype Bad = Schema.Type<{ type: "number"; default: 1 }>;`)) + .toEqual(expect.stringMatching(/^failure: .*Bad/)); + }); + test("checks a namespace's default against its type when it can read it", async () => { + expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Mode } } }\ntype Mode = "a" | "b";\nconst Mode = { default: "c" } as const;`)) + .toEqual(expect.stringMatching(/^failure: Mode\.default/)); + }); + test("writes in a namespace's -NaN imported as a type only", async () => { + expect(await registrations(`import type { Z } from "./z.js";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Z } } }`, + { "/src/z.ts": `export type Z = number;\nexport const Z = { default: -NaN } as const;` })).toEqual([expect.stringContaining("default: NaN")]); + }); test("passes over modules without a declaration of the package", async () => { expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null); }); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index 69fc90ec..9846e89b 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -218,7 +218,8 @@ const zeroOf = (schema: Schema): Start => { /** * What a resource reduced with `operator` starts at when it declares no start: the operator's identity, leaf by leaf, - * within the schema's range, as a fold of no contributions leaves it; null for a schema of other leaves. + * as a fold of no contributions leaves it; null for a schema of other leaves. Used only when it's one of the + * schema's values. */ const identityOf = (schema: Schema, operator: unknown): Value | null => { if ("type" in schema && schema.type === "boolean") return operator === "&"; @@ -228,8 +229,8 @@ const identityOf = (schema: Schema, operator: unknown): Value | null => { switch (operator) { case "+": case "|": case "^": return 0; case "*": return 1; - // Every bit set: -1, or a range's largest for an unsigned one. - case "&": return minimum !== undefined && minimum >= 0 ? maximum ?? null : -1; + // Every bit of its column set: u32's all ones, else -1. + case "&": return minimum !== undefined && minimum >= 0 && maximum !== undefined && maximum <= 0xFFFFFFFF ? 0xFFFFFFFF : -1; case "min": return maximum ?? Infinity; case "max": return minimum ?? -Infinity; default: return null; @@ -388,16 +389,21 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH /** A value as its module writes it, and whether an `as const` around it makes what it writes in place readonly. */ type Written = { readonly expression: NonNullable; readonly module: Module; readonly constant: boolean; readonly annotation?: TypeNode }; - /** Whether an expression is an `as const` assertion, under parentheses and other casts. */ - const isConstAssertion = (expression: Declarator["init"]): boolean => { + /** + * The outermost assertion on an expression, under parentheses, `satisfies` and `!`: "const" for `as const`, the + * type of any other `as`, or null without one. + */ + const outerAssertion = (expression: Declarator["init"]): "const" | TypeNode | null => { let inner = expression; - while (inner !== null && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) { - if (inner.type === "TSAsExpression" && inner.typeAnnotation.type === "TSTypeReference" && inner.typeAnnotation.typeName.type === "Identifier" && inner.typeAnnotation.typeName.name === "const") return true; - inner = inner.expression; - } - return false; + while (inner !== null && (inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) inner = inner.expression; + if (inner?.type !== "TSAsExpression") return null; + const { typeAnnotation } = inner; + return typeAnnotation.type === "TSTypeReference" && typeAnnotation.typeName.type === "Identifier" && typeAnnotation.typeName.name === "const" ? "const" : typeAnnotation; }; + /** Whether an expression's outermost assertion is `as const`. */ + const isConstAssertion = (expression: Declarator["init"]): boolean => outerAssertion(expression) === "const"; + /** The `default` property's value of an object a module writes: in place, or through constants and spreads. */ const defaultProperty = async (expression: Declarator["init"], at: Module, constant: boolean, seen: Set): Promise => { const value = unwrapped(expression); @@ -487,14 +493,17 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH const readonlyValue = async (written: Written, seen: Set): Promise => { const { module: at, annotation } = written; if (annotation !== undefined) return await readonlyType(annotation, at, seen); - const constant = written.constant || isConstAssertion(written.expression); + // A cast to a type is that type, whatever `as const` around it says. + const assertion = outerAssertion(written.expression); + if (assertion !== null && assertion !== "const") return await readonlyType(assertion, at, seen); + const constant = written.constant || assertion === "const"; const value = unwrapped(written.expression); if (value === null || seen.has(value)) return false; seen.add(value); switch (value.type) { case "Literal": return true; case "TemplateLiteral": return value.expressions.length === 0; - case "UnaryExpression": return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && value.argument.name === "Infinity")); + case "UnaryExpression": return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN"))); case "ArrayExpression": return constant && (await Promise.all(value.elements.map(element => element === null || element.type === "SpreadElement" ? false : readonlyValue({ expression: element, module: at, constant }, seen)))).every(Boolean); case "ObjectExpression": @@ -522,7 +531,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH case "Literal": return typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean" || value.value === null ? value.value : undefined; case "UnaryExpression": if (value.operator !== "-") return undefined; - if (value.argument.type === "Identifier" && value.argument.name === "Infinity") return -Infinity; + if (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN")) return -(value.argument.name === "Infinity" ? Infinity : NaN); return value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value : undefined; case "ArrayExpression": { const items: Value[] = []; @@ -553,8 +562,12 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH } }; - /** A declaration's type's namespace's default, `T.default`, as code reading it, and the local binding it reads; null without one. */ - const namespacedDefault = async (type: TypeNode, name: string): Promise<{ readonly text: string; readonly local?: string; readonly packaged: boolean } | null> => { + /** + * A declaration's type's namespace's default, `T.default`, as code reading it, and the local binding it reads; null + * without one. Checked against `schema` when the build can read it. `oneObject` is for a start a joined thread + * knows by identity, which is read, never copied. + */ + const namespacedDefault = async (type: TypeNode, name: string, schema: Schema | undefined, oneObject: boolean): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string; readonly packaged: boolean } | null> => { const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; if (inner.type !== "TSTypeReference" || inner.typeArguments !== null) return null; const path = typeNameParts(inner.typeName); @@ -566,26 +579,36 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (!await readonlyValue(written, new Set())) { fail(written.module, written.expression, `${path.join(".")}.default is shared by every row and world that starts from it, so it's deeply readonly: \`const ${path.at(-1) ?? "T"} = { default: ... } as const\`, or a const typed readonly: ReadonlyMap, ReadonlySet, Readonly<...>, readonly arrays and fields`); } + const value = await staticValue(written, new Set()); + if (schema !== undefined && value !== undefined && !matchesSchema(schema, value)) fail(written.module, written.expression, `${path.join(".")}.default, ${codeOf(value)}, isn't one of its type's values`); const imported = module.imports.get(first); if (imported !== undefined && !imported.value) { // Imported as a type alone, the namespace can't be read here, so a default the build can read is written in. - const value = await staticValue(written, new Set()); - if (value === undefined) fail(module, inner, `${name}'s type ${path.join(".")} starts at ${path.join(".")}.default: import ${first} as a value, which the registration reads`); - return { text: codeOf(value), packaged }; + if (value === undefined || (oneObject && typeof value === "object" && value !== null)) { + fail(module, inner, `${name}'s type ${path.join(".")} starts at ${path.join(".")}.default${oneObject ? ", one object a joined thread knows by identity" : ""}: import ${first} as a value, which the registration reads`); + } + return { text: codeOf(value ?? null), value: value ?? null, packaged }; } return { text: `${path.join(".")}.default`, local: imported === undefined ? first : undefined, packaged }; }; /** - * What a value of `type`, of `schema`, starts at as code: as `zeroOf` says, but each field of a struct and item of - * a tuple written here starting at its own type's default, its namespace's included, by the same rules; - * and the local bindings that code reads. + * A start as code, the local bindings it reads, whether a namespace or an identity gave it, not zero values alone, + * and its value when it's data the build wrote in. */ - const startOf = async (type: TypeNode, schema: Schema): Promise<{ readonly text: string; readonly locals: readonly string[] } | { readonly fresh: "Map" | "Set" } | { readonly none: string }> => { + type Started = { readonly text: string; readonly locals: readonly string[]; readonly given: boolean; readonly value?: Value }; + + /** + * What a value of `type`, of `schema`, starts at as code: its namespace's default of yours; else for a reduction by + * `operator`, its identity when that's one of its values; else the package's namespace's; else each field of a + * struct and item of a tuple by these rules; else its zero value, as `zeroOf` says. + */ + const startOf = async (type: TypeNode, schema: Schema, operator: unknown, oneObject: boolean): Promise => { const unwrapped = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; const inner = unwrapped.type === "TSTypeOperator" && unwrapped.operator === "readonly" && unwrapped.typeAnnotation !== undefined ? unwrapped.typeAnnotation : unwrapped; - const namespaced = await namespacedDefault(inner, "it"); - if (namespaced !== null) return { text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local] }; + const namespaced = await namespacedDefault(inner, "it", schema, oneObject); + const named = namespaced === null ? null : { text: namespaced.text, value: namespaced.value, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true }; + if (named !== null && namespaced !== null && !namespaced.packaged) return named; const parts = inner.type === "TSTypeLiteral" && "type" in schema && schema.type === "object" ? inner.members.flatMap(field => field.type === "TSPropertySignature" && !field.computed && field.key.type === "Identifier" && field.typeAnnotation !== null ? [{ name: field.key.name, type: field.typeAnnotation.typeAnnotation, schema: schema.properties[field.key.name] }] : []) @@ -593,23 +616,40 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH ? inner.elementTypes.flatMap((element, k) => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? [] : [{ name: String(k), type: element, schema: schema.items }]) : null; if (parts === null || parts.length === 0) { + const identity = operator === null ? null : identityOf(schema, operator); + if (identity !== null && matchesSchema(schema, identity)) return { text: codeOf(identity), value: identity, locals: [], given: true }; + if (named !== null) return named; const zero = zeroOf(schema); - return "value" in zero ? { text: codeOf(zero.value), locals: [] } : zero; + return "value" in zero ? { text: codeOf(zero.value), value: zero.value, locals: [], given: false } : zero; } const texts: string[] = []; const locals: string[] = []; + const values: Value[] = []; + const fields: { [field: string]: Value } = {}; + let given = false; + let data = true; for (const part of parts) { - const started = part.schema === undefined ? { none: "it isn't a property" } : await startOf(part.type, part.schema); + const started = part.schema === undefined ? { none: "it isn't a property" } : await startOf(part.type, part.schema, operator, false); if (!("text" in started)) return { none: `its ${inner.type === "TSTupleType" ? "item" : "field"} ${part.name} starts at nothing: ${"fresh" in started ? "a set or a map in a struct has no zero value" : started.none}` }; texts.push(inner.type === "TSTupleType" ? started.text : `${JSON.stringify(part.name)}: ${started.text}`); locals.push(...started.locals); + given ||= started.given; + if (started.value === undefined) data = false; + else { + values.push(started.value); + fields[part.name] = started.value; + } } - // Without a namespace's default inside, the schema's zero value says the same, as data. - if (locals.length === 0) { + // With zero values alone, the schema's zero value says the same, as data. + if (!given) { const zero = zeroOf(schema); - if ("value" in zero) return { text: codeOf(zero.value), locals }; + if ("value" in zero) return { text: codeOf(zero.value), value: zero.value, locals, given }; + } + if (data) { + const value = inner.type === "TSTupleType" ? values : fields; + return { text: codeOf(value), value, locals, given }; } - return { text: inner.type === "TSTupleType" ? `[${texts.join(", ")}]` : `{ ${texts.join(", ")} }`, locals }; + return { text: inner.type === "TSTupleType" ? `[${texts.join(", ")}]` : `{ ${texts.join(", ")} }`, locals, given }; }; /** A declared default: a literal type, never `typeof` a value. */ @@ -716,33 +756,33 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); let start = given === undefined ? undefined : codeOf(given); let fresh: "Map" | "Set" | null = null; - // First a literal, then its type's namespace's default; only resources and derived components need a - // start otherwise, and a component holding an object, whose start a joined thread knows by identity. - const started = kind === "resource" || builtIns.has("derived") || schema === undefined || !isStruct(schema); - const namespaced = given !== undefined || !started ? null : await namespacedDefault(typeNode, key.name); + // Without a literal, only resources and derived components need a start of their own, and a component + // holding an object, whose start a joined thread knows by identity. + const needed = kind === "resource" || builtIns.has("derived"); + const oneObject = kind === "component" && (schema === undefined || !isStruct(schema)); const reduced = builtIns.get("reduce"); - const identity = given !== undefined || (namespaced !== null && !namespaced.packaged) || kind !== "resource" || reduced === undefined || schema === undefined - ? null : identityOf(schema, JSON.parse(reduced.text)); - if (identity !== null) start = codeOf(identity); - else if (namespaced !== null) { - start = namespaced.text; - if (namespaced.local !== undefined) at = Math.max(at, endOfLocal(namespaced.local)); - } - else if (given === undefined && (kind === "resource" || builtIns.has("derived"))) { - if (schema === undefined ? await allowsNullType(typeNode, module, new Set()) : allowsNullSchema(schema)) { - // It starts at null, as the world reads a value with neither. - } - else if (schema === undefined) { - fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`); + const operator: unknown = kind === "resource" && reduced !== undefined ? JSON.parse(reduced.text) : null; + const read = (found: Started): void => { + start = found.text; + for (const local of found.locals) at = Math.max(at, endOfLocal(local)); + }; + if (given === undefined && (needed || oneObject)) { + if (schema === undefined) { + const namespaced = await namespacedDefault(typeNode, key.name, undefined, oneObject); + if (namespaced !== null) read({ text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true }); + else if (needed && !await allowsNullType(typeNode, module, new Set())) { + fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`); + } } else { - const zero = await startOf(typeNode, schema); - if ("fresh" in zero) fresh = zero.fresh; - else if ("none" in zero) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${zero.none}; ${workaround(typeNode)}`); - // A constant is the one value it can start at, which the world reads from its schema. - else if (!("const" in schema)) { - start = zero.text; - for (const local of zero.locals) at = Math.max(at, endOfLocal(local)); + const found = await startOf(typeNode, schema, operator, oneObject); + if ("text" in found && found.given) read(found); + // Else it starts at null, as the world reads a value with neither, when its type allows it. + else if (needed && !allowsNullSchema(schema)) { + if ("fresh" in found) fresh = found.fresh; + else if ("none" in found) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${found.none}; ${workaround(typeNode)}`); + // A constant is the one value it can start at, which the world reads from its schema. + else if (!("const" in schema)) read(found); } } } diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts index 65e35750..10fe7032 100644 --- a/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -514,7 +514,11 @@ export const createConverter = (host: TypeHost, root: string) => { if (found !== null && intrinsic(found) === "schema" && parameters.length === 1 && argument !== undefined) { const schema = await valueOfType(argument, module, new Set()); if (!isSchema(schema)) return fail(module, argument, "Schema.Type takes a schema"); - return holdsDefault(schema) ? fail(module, argument, `a schema holds no default: give ${path.join(".")}'s alias a namespace with one, \`const T = { default: ... }\``) : schema; + if (!holdsDefault(schema)) return schema; + // The alias being read, the latest declaration entered, is the one to give a namespace. + const alias = [...active].at(-1); + const name = alias?.type === "TSTypeAliasDeclaration" ? alias.id.name : "its alias"; + return fail(module, argument, `a schema holds no default: give ${name} a namespace with one, \`const ${alias?.type === "TSTypeAliasDeclaration" ? name : "T"} = { default: ... }\``); } return fail(module, type, `${use === "input" ? "input" : "declared"} type ${path.join(".")} takes type arguments, which ${words(use).a} doesn't, except Schema.Type`); }; @@ -654,8 +658,21 @@ export const pathOf = (expression: Node): readonly string[] | null => { const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number" && typeof Reflect.get(value, "type") === "string"; // `Schema.Type`'s argument is constrained to `Schema` where it's written, so TypeScript checked an object's form. -/** Whether a value a schema type writes holds a `default` anywhere. */ -const holdsDefault = (value: unknown): boolean => - typeof value === "object" && value !== null && (Array.isArray(value) ? value.some(holdsDefault) : "default" in value || Object.values(value).some(holdsDefault)); +/** + * Whether a schema a schema type writes holds a `default` as a keyword: at its top or in a schema inside it, never in + * a property's name or a `const`'s value. + */ +const holdsDefault = (schema: unknown): boolean => { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return false; + if ("default" in schema) return true; + const inside = (keyword: string): unknown => Reflect.get(schema, keyword); + const listed = (keyword: string): boolean => { + const members = inside(keyword); + return Array.isArray(members) && members.some(holdsDefault); + }; + const properties = inside("properties"); + return holdsDefault(inside("items")) || holdsDefault(inside("keys")) || holdsDefault(inside("values")) || listed("anyOf") || listed("allOf") + || (typeof properties === "object" && properties !== null && Object.values(properties).some(holdsDefault)); +}; const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null && !Array.isArray(value); From 6cc499b84fc1dafa53ff3f01c28a985748b1d535 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:07:44 -0700 Subject: [PATCH 109/127] ecs: the build treats only observer: true as an observer, and labels name a module's package Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/compiler/identify.test.ts | 6 +++--- packages/ecs/src/implementation/compiler/identify.ts | 8 +++++--- .../ecs/src/implementation/compiler/transform.test.ts | 7 +++++++ packages/ecs/src/implementation/compiler/transform.ts | 8 +++++--- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index 6d8a2dda..5a2cc9de 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -160,9 +160,9 @@ describe("identify, for systems, rules and finalizers", () => { }); describe("moduleLabelOf", () => { - test("is the module's path in its package, else a dependency's package and subpath, else its file's name", () => { - const app = (directory: string) => directory === "/repo/app" ? { name: "app", version: "1.0.0" } : undefined; - expect(moduleLabelOf("/repo/app/src/features/a/model.ts?t=1", app)).toBe("src/features/a/model.ts"); + test("is the module's package and path in it, else a dependency's package and subpath, else its file's name", () => { + const app = (directory: string) => directory === "/repo/app" ? { name: "@scope/app", version: "1.0.0" } : undefined; + expect(moduleLabelOf("/repo/app/src/features/a/model.ts?t=1", app)).toBe("@scope/app/src/features/a/model.ts"); expect(moduleLabelOf("/x/node_modules/keys/index.js", () => undefined)).toBe("keys/index.js"); expect(moduleLabelOf("/loose/model.ts", () => undefined)).toBe("model.ts"); }); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index 88f2f53a..985805cf 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -46,9 +46,11 @@ export const moduleIdOf = (file: string, packageOf: PackageOf): string | null => return dependency.exec(pathOf(file))?.[1] ?? null; }; -/** How messages name a module: its path in its package, as its id has it, else the file's name. */ -export const moduleLabelOf = (file: string, packageOf: PackageOf): string => - packageAbove(file, packageOf)?.path ?? dependency.exec(pathOf(file))?.[1] ?? posix.basename(pathOf(file)); +/** How messages name a module: its package's name and its path there, as its id has them, else the file's name. */ +export const moduleLabelOf = (file: string, packageOf: PackageOf): string => { + const found = packageAbove(file, packageOf); + return found !== null ? `${found.name}/${found.path}` : dependency.exec(pathOf(file))?.[1] ?? posix.basename(pathOf(file)); +}; /** Whether a binding named `name` is declared in the module's scope: at top level, or as a `var` in any of its blocks. */ const declaresInModule = (program: Program, name: string): boolean => { diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 950f27c9..47bae79a 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -1400,6 +1400,13 @@ describe("inferred access", () => { expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings] : null).toEqual([true, []]); }); + test("a row it can't follow with a warning unless the declaration says observer: true, as the world reads it", async () => { + for (const observer of ["observer: false, ", "observer: watching, "]) { + const result = await transform(`const watching = true;\n${system(`${observer}components: { with: [position] }`, "(row) => { helper(row); }")}`); + expect(result !== null && "code" in result ? result.warnings.length : null).toBe(1); + } + }); + test("a lookup's rows it can't follow without a warning, since what they hold is what the declaration says", async () => { const result = await transform(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { helper(lookup.targets(row[target])); }")); expect(result !== null && "code" in result ? [result.code.includes(`"targets": null`), result.warnings] : null).toEqual([true, []]); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index ef442e3f..1a74ccfa 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -140,8 +140,10 @@ const functionOf = (binding: ModuleBinding | undefined): Node | undefined => { const empty: ObjectExpression = { type: "ObjectExpression", properties: [], start: 0, end: 0 }; /** Whether a declaration has property `name`, written out. */ -const declares = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => - property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name); +/** Whether a declaration gives `name` the literal `true`, as the world takes `observer`. */ +const declaresTrue = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name + && property.value.type === "Literal" && property.value.value === true); const uniqueName = (code: string, base: string): string => { let name = base; @@ -289,7 +291,7 @@ export const transformKernels = async ( const where = at(body); // An observer's row, and a lookup's rows, it can't follow are taken as read whole, which is what their // declarations already say; only a row taken as written whole costs what the author should know of. - if (used.query === null && !declares(declared, "observer")) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); + if (used.query === null && !declaresTrue(declared, "observer")) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); accessing = true; const access = accessText(used, where); if (kernel !== undefined) { From e082b121c02eac0d2f7ce8471ba41048ff7c0965 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:07:44 -0700 Subject: [PATCH 110/127] ecs: docs give the & identity by column, readonly defaults' casts, and object starts read in place Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 6 +++--- packages/ecs/src/guide/01-resources.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index bb88b8ae..e4a96d5b 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -79,12 +79,12 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies: 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value; - 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const`, around it or the whole namespace, or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one); anything else fails the build at the namespace. Imported as a type alone, a namespace's default the build can read as data is written into the registration; - 3. for a reduced resource, its operator's identity, leaf by leaf within its type's range (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it; this outranks the package's own types' defaults, never a namespace of yours; + 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const` as the outermost assertion on it or the whole namespace, with no cast inside to a mutable type (`[] as number[]` fails), or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one), whose annotation it trusts; a value it can't see as readonly fails the build at the namespace. One the build can read as data is checked against its type there. Imported as a type alone, a namespace's default the build can read as data is written into the registration, except an object a joined thread knows by identity, whose namespace is imported as a value; + 3. for a reduced resource, its operator's identity, leaf by leaf (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit of its column, all ones for a `u32` and −1 otherwise, or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it, taken only where it's one of the leaf's values, else the rules below; it outranks the package's own types' defaults, never a namespace of yours, at any depth; 4. `null`, when its type allows it; 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those; 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`; a `Map` or `Set` type gets none. - Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's. + Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's, read where it's declared, never copied. - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index 4ac2f96b..19544a5c 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Entity, F32, System, World } from "../index.js"; +import { Entity, System, World, type F32 } from "../index.js"; import { Resource } from "@adobe/ecs"; // A resource is a key whose value a world holds once. From 1dca3c1574c978708ed8770959f37b1adced86c8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:20:13 -0700 Subject: [PATCH 111/127] ecs: a readonly default may call a function whose declared return type is readonly; failures name the part that isn't Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- .../compiler/augmentations.test.ts | 9 ++ .../implementation/compiler/augmentations.ts | 85 ++++++++++++++----- .../implementation/compiler/type-schemas.ts | 40 ++++++++- 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index e4a96d5b..8af47bc6 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -79,7 +79,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies: 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value; - 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const` as the outermost assertion on it or the whole namespace, with no cast inside to a mutable type (`[] as number[]` fails), or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one), whose annotation it trusts; a value it can't see as readonly fails the build at the namespace. One the build can read as data is checked against its type there. Imported as a type alone, a namespace's default the build can read as data is written into the registration, except an object a joined thread knows by identity, whose namespace is imported as a value; + 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const` as the outermost assertion on it or the whole namespace, with no cast inside to a mutable type (`[] as number[]` fails), or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one), whose annotation it trusts, or a call of a function whose declared return type is readonly (`packColor(255, 214, 0)` returning `U32`); a value it can't see as readonly fails the build at the part that isn't, saying why. One the build can read as data is checked against its type there. Imported as a type alone, a namespace's default the build can read as data is written into the registration, except an object a joined thread knows by identity, whose namespace is imported as a value; 3. for a reduced resource, its operator's identity, leaf by leaf (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit of its column, all ones for a `u32` and −1 otherwise, or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it, taken only where it's one of the leaf's values, else the rules below; it outranks the package's own types' defaults, never a namespace of yours, at any depth; 4. `null`, when its type allows it; 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those; diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts index fafb696f..ebf725d4 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.test.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -180,6 +180,15 @@ const after = 1;`, files); expect(await registrations(`${maps}const names: ReadonlyMap = new Map([["a", 1]]);\nconst Names = { default: names };`)).toEqual([`declare$(key, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, default: Names.default });`]); void shared; }); + test("takes a call in a readonly default when its function's declared return type is readonly", async () => { + const declared = (code: string, files: { readonly [path: string]: string } = {}) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Note } } }\ntype Note = { readonly color: number; readonly tags: readonly string[] };\n${code}`, files); + const note = (call: string) => `const Note = { default: { color: ${call}, tags: [] } } as const;`; + expect(await declared(`function pack(r: number, g: number): number { return r + g; }\n${note("pack(255, 214)")}`)).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`import { pack } from "./color.js";\n${note("pack(255, 214)")}`, { "/src/color.ts": `import type { U32 } from "@adobe/ecs";\nexport const pack = (r: number, g: number): U32 => r + g;` })).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`const tagsOf = (): readonly string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`function pack(r: number, g: number) { return r + g; }\n${note("pack(255, 214)")}`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`pack\(255, 214\)` returns a type the build can't see is readonly: annotate its return type/)); + expect(await declared(`const tagsOf = (): string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`tagsOf\(\)` returns a type that isn't readonly/)); + }); test("writes a literal -0 as itself", async () => { expect(await registrations(`${keys("floor")}declare module "@adobe/ecs" { interface Resources { [floor]: { type: number; default: -0 } } }`)) .toEqual([`declare$(floor, "resource", { type: {"type":"number"}, default: -0 });`]); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts index 9846e89b..c96dd734 100644 --- a/packages/ecs/src/implementation/compiler/augmentations.ts +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -445,7 +445,7 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH */ const readonlyType = async (type: TypeNode, at: Module, seen: Set, fields = false): Promise => { switch (type.type) { - case "TSNumberKeyword": case "TSStringKeyword": case "TSBooleanKeyword": case "TSNullKeyword": case "TSUndefinedKeyword": case "TSLiteralType": return true; + case "TSNumberKeyword": case "TSStringKeyword": case "TSBooleanKeyword": case "TSBigIntKeyword": case "TSNullKeyword": case "TSUndefinedKeyword": case "TSLiteralType": return true; case "TSParenthesizedType": return await readonlyType(type.typeAnnotation, at, seen); case "TSUnionType": return (await Promise.all(type.types.map(member => readonlyType(member, at, seen)))).every(Boolean); case "TSTypeOperator": { @@ -489,35 +489,73 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH } }; - /** Whether a value a module writes is deeply readonly by its type: a literal, `as const` data, or a binding typed readonly. */ - const readonlyValue = async (written: Written, seen: Set): Promise => { - const { module: at, annotation } = written; - if (annotation !== undefined) return await readonlyType(annotation, at, seen); + /** Where a value a module writes isn't evidently readonly: the part that isn't, and why. */ + type Unreadonly = { readonly module: Module; readonly expression: NonNullable; readonly why: string }; + + /** The source text of a part of a module, for failures. */ + const textOf = (at: Module, expression: { readonly start: number; readonly end: number }): string => at.code.slice(expression.start, expression.end); + + /** + * Where a value a module writes isn't deeply readonly by its type, or null when it is: a literal, `as const` data, a + * binding typed readonly, or a call of a function whose declared return type is readonly. + */ + const unreadonly = async (written: Written, seen: Set): Promise => { + const { module: at, annotation, expression } = written; + const failing = (why: string): Unreadonly => ({ module: at, expression, why: `\`${textOf(at, expression)}\` ${why}` }); + if (annotation !== undefined) return await readonlyType(annotation, at, seen) ? null : failing("is typed with a type that isn't readonly"); // A cast to a type is that type, whatever `as const` around it says. - const assertion = outerAssertion(written.expression); - if (assertion !== null && assertion !== "const") return await readonlyType(assertion, at, seen); + const assertion = outerAssertion(expression); + if (assertion !== null && assertion !== "const") return await readonlyType(assertion, at, seen) ? null : failing("is cast to a type that isn't readonly"); const constant = written.constant || assertion === "const"; - const value = unwrapped(written.expression); - if (value === null || seen.has(value)) return false; + const value = unwrapped(expression); + if (value === null || seen.has(value)) return failing("isn't a value the build can read"); seen.add(value); + const each = async (items: readonly Written[]): Promise => { + for (const item of items) { + const found = await unreadonly(item, seen); + if (found !== null) return found; + } + return null; + }; switch (value.type) { - case "Literal": return true; - case "TemplateLiteral": return value.expressions.length === 0; - case "UnaryExpression": return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN"))); - case "ArrayExpression": - return constant && (await Promise.all(value.elements.map(element => element === null || element.type === "SpreadElement" ? false : readonlyValue({ expression: element, module: at, constant }, seen)))).every(Boolean); - case "ObjectExpression": - return constant && (await Promise.all(value.properties.map(property => property.type === "SpreadElement" || property.computed - ? false : readonlyValue({ expression: property.value, module: at, constant }, seen)))).every(Boolean); + case "Literal": return null; + case "TemplateLiteral": return value.expressions.length === 0 ? null : failing("isn't a literal"); + case "UnaryExpression": + return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN"))) ? null : failing("isn't a literal"); + case "ArrayExpression": { + if (!constant) return failing("isn't under `as const`"); + const items: Written[] = []; + for (const element of value.elements) { + if (element === null || element.type === "SpreadElement") return failing("spreads or skips items, which the build can't see are readonly"); + items.push({ expression: element, module: at, constant }); + } + return await each(items); + } + case "ObjectExpression": { + if (!constant) return failing("isn't under `as const`"); + const fields: Written[] = []; + for (const property of value.properties) { + if (property.type === "SpreadElement" || property.computed) return failing("spreads or computes a field, which the build can't see is readonly"); + fields.push({ expression: property.value, module: at, constant }); + } + return await each(fields); + } + case "CallExpression": { + const path = value.callee.type === "Super" ? null : pathOf(value.callee); + const returned = path === null ? null : await converter.returnTypeNamed(path, at); + if (returned === null) return failing("calls a function the build can't find"); + if (returned === undefined) return failing("returns a type the build can't see is readonly: annotate its return type"); + return await readonlyType(returned.type, returned.module, seen) ? null : failing("returns a type that isn't readonly"); + } case "Identifier": case "MemberExpression": { - if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN" || value.name === "undefined")) return true; + if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN" || value.name === "undefined")) return null; const path = pathOf(value); const found = path === null ? null : await converter.constantNamed(path, at); - if (found === null || found.declaration.init === null) return false; + if (found === null || found.declaration.init === null) return failing("names a value the build can't read"); const declared = found.declaration.id.type === "Identifier" ? found.declaration.id.typeAnnotation?.typeAnnotation : undefined; - return await readonlyValue({ expression: found.declaration.init, module: found.module, constant: false, annotation: declared }, seen); + return await unreadonly({ expression: found.declaration.init, module: found.module, constant: false, annotation: declared }, seen); } - default: return false; + default: return failing("isn't a literal, `as const` data, a constant typed readonly or a call returning a readonly type"); } }; @@ -576,8 +614,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH if (first === undefined || written === null) return null; // The package's own types' defaults are their zero values and identities, which a reduction's identity outranks. const packaged = host.packagePath(written.module.file) !== null; - if (!await readonlyValue(written, new Set())) { - fail(written.module, written.expression, `${path.join(".")}.default is shared by every row and world that starts from it, so it's deeply readonly: \`const ${path.at(-1) ?? "T"} = { default: ... } as const\`, or a const typed readonly: ReadonlyMap, ReadonlySet, Readonly<...>, readonly arrays and fields`); + const mutable = await unreadonly(written, new Set()); + if (mutable !== null) { + fail(mutable.module, mutable.expression, `${path.join(".")}.default is shared by every row and world that starts from it, so it's deeply readonly, but ${mutable.why}. Readonly values are \`const ${path.at(-1) ?? "T"} = { default: ... } as const\`, consts typed readonly (ReadonlyMap, ReadonlySet, Readonly<...>, readonly arrays and fields), and calls of functions whose declared return type is readonly`); } const value = await staticValue(written, new Set()); if (schema !== undefined && value !== undefined && !matchesSchema(schema, value)) fail(written.module, written.expression, `${path.join(".")}.default, ${codeOf(value)}, isn't one of its type's values`); diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts index 10fe7032..fd180698 100644 --- a/packages/ecs/src/implementation/compiler/type-schemas.ts +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -12,6 +12,8 @@ type Class = Extract; type Namespace = Extract; type Import = Extract; type Enum = Extract; +/** A function a statement declares, by the shape every function shares. */ +type FunctionDeclared = Extract; export type Declarator = Extract["declarations"][number]; export type TypeNode = Alias["typeAnnotation"]; type Member = Interface["body"]["body"][number]; @@ -66,6 +68,7 @@ export type Module = { readonly namespaces: ReadonlyMap; readonly constants: ReadonlyMap; readonly enums: ReadonlyMap; + readonly functions: ReadonlyMap; readonly imports: ReadonlyMap; /** The types its `declare global` blocks declare. */ readonly globals: { readonly types: ReadonlyMap; readonly enums: ReadonlyMap }; @@ -102,9 +105,11 @@ const scopeOf = (statements: readonly Statement[]) => { const namespaces = new Map(); const constants = new Map(); const enums = new Map(); + const functions = new Map(); for (const statement of statements) { const inner = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; if (inner?.type === "TSEnumDeclaration") enums.set(inner.id.name, inner); + if (inner?.type === "FunctionDeclaration" && inner.id !== null) functions.set(inner.id.name, inner); const declared = declarationOf(statement); if (declared?.type === "TSModuleDeclaration") { if (declared.id.type === "Identifier") namespaces.set(declared.id.name, declared); @@ -112,7 +117,7 @@ const scopeOf = (statements: readonly Statement[]) => { else if (declared !== null && declared.id !== null) types.set(declared.id.name, declared); for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); } - return { types, namespaces, constants, enums }; + return { types, namespaces, constants, enums, functions }; }; export const moduleOf = (file: string, code: string, program: TypedProgram): Module => { @@ -341,6 +346,37 @@ export const createConverter = (host: TypeHost, root: string) => { return imported === undefined || imported.imported === null ? null : await fromSource(module, imported.source, imported.imported, constantsIn, new Set()); }; + const functionsIn = (module: Module): ReadonlyMap => module.functions; + + /** + * The declared return type of the function a name calls: a function declaration's, or a constant's holding a + * function or typed as one, through imports. Undefined for a function declaring none, null for no function found. + */ + const returnTypeNamed = async (path: readonly string[], module: Module): Promise<{ readonly module: Module; readonly type: TypeNode } | undefined | null> => { + const [first] = path; + if (first === undefined) return null; + if (path.length === 1) { + const local = module.functions.get(first); + const imported = module.imports.get(first); + const declared = local !== undefined ? { module, declaration: local } + : imported === undefined || imported.imported === null ? null : await fromSource(module, imported.source, imported.imported, functionsIn, new Set()); + if (declared !== null) { + const returned = declared.declaration.returnType?.typeAnnotation; + return returned === undefined ? undefined : { module: declared.module, type: returned }; + } + } + const constant = await constantNamed(path, module); + if (constant === null) return null; + const { id, init } = constant.declaration; + const annotation = id.type === "Identifier" ? id.typeAnnotation?.typeAnnotation : undefined; + if (annotation?.type === "TSFunctionType") return { module: constant.module, type: annotation.returnType.typeAnnotation }; + if (init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression") { + const returned = init.returnType?.typeAnnotation; + return returned === undefined ? undefined : { module: constant.module, type: returned }; + } + return null; + }; + /** The package's own declaration a found type is, by its path in the package: `Schema.Type` and `Entity`. */ const intrinsic = (found: Found): "schema" | "entity" | null => { if (typeof found === "string") return null; @@ -641,7 +677,7 @@ export const createConverter = (host: TypeHost, root: string) => { return { anyOf: values.map(value => ({ const: value })) }; }; - return { convert, read, site, fail, typeNamed, constantNamed, valueOfType }; + return { convert, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType }; }; /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ From 790982534b5c69e0e8f0937a8931a0e4af65ce90 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:24:55 -0700 Subject: [PATCH 112/127] ecs: optional components in types: rows hold them or undefined, never narrowing what a system matches Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/interface/system/access.ts | 6 ++- packages/ecs/src/interface/system/body.ts | 14 ++++++- packages/ecs/src/interface/system/create.ts | 3 ++ .../interface/system/optional.type-test.ts | 42 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 packages/ecs/src/interface/system/optional.type-test.ts diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts index 45529720..546caea6 100644 --- a/packages/ecs/src/interface/system/access.ts +++ b/packages/ecs/src/interface/system/access.ts @@ -10,6 +10,8 @@ import type { Index } from '../indexes/index-type.js'; */ export type ComponentAccess = { readonly with?: readonly Component.Key[]; + /** Components its rows hold where an entity has them, `undefined` where it doesn't; they never narrow what it matches. */ + readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; /** Passes rows where any of these were added since the system last ran. */ readonly added?: readonly Component.Key[]; @@ -58,9 +60,11 @@ type Key = symbol; /** * What a system reads and writes, as the world takes it: the build's inferred access joined with its declaration. - * Matches every component in `read`, `write`, `has`, `added` and `changed`, and excludes `not`. + * Matches every component in `read`, `write`, `has`, `added` and `changed` but those in `optional`, and excludes `not`. */ export type ComponentLists = { + /** Members its rows hold only where an entity has them; those it uses are in `read` or `write` too. */ + readonly optional?: readonly Key[]; readonly read?: readonly Key[]; readonly write?: readonly Key[]; readonly has?: readonly Key[]; diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts index f119999d..a979bbfa 100644 --- a/packages/ecs/src/interface/system/body.ts +++ b/packages/ecs/src/interface/system/body.ts @@ -19,10 +19,20 @@ export type Members = /** Whether a declaration only reads: an observer or a render system. */ type Reading = D extends { readonly observer: true } | { readonly shader: object } ? true : false; -/** Its members' values, writable but for references, and in a system that only reads. */ +/** The components a declaration's rows hold only where an entity has them. */ +export type Optional = Exclude, "optional">, Members>; + +/** Each optional component's value or `undefined`, as `ComponentValues` gives members'. */ +type OptionalValues = { readonly [K in Component.Key]: Component.Value | undefined }; + +/** + * Its members' values, writable but for references, and in a system that only reads; its optional components' too, or + * `undefined` where an entity lacks one, which assigning to fails the system. + */ export type Row = { readonly entity: Entity } - & Values, Reading extends true ? never : Members & Writable, Component.Key, ComponentValues>; + & Values, Reading extends true ? never : Members & Writable, Component.Key, ComponentValues> + & Values, Reading extends true ? never : Optional & Writable, Component.Key, OptionalValues>; /** Resources systems write: every one but those reduced, which only their contributors' fold sets, and those only the world writes. */ type Writes = Exclude; diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index b291e18a..78a82a8f 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -79,6 +79,9 @@ type Validate = readonly kernel?: false | { readonly "error: a system without components runs once, so there's nothing to compile": true }; } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) + // Kernels don't read components an archetype may lack yet. + & (D extends { readonly components: { readonly optional: readonly [unknown, ...unknown[]] } } + ? { readonly kernel?: false | { readonly "error: a kernel can't have optional components yet": true } } : {}) & (D extends { readonly observer: true } ? Observer : {}) & (D extends { readonly target: string } ? Targeted : {}) & (D extends { readonly target: string } ? Property extends undefined ? { readonly target?: { readonly "error: a system on one entity runs on its row, so it needs components": true } } : {} : {}) diff --git a/packages/ecs/src/interface/system/optional.type-test.ts b/packages/ecs/src/interface/system/optional.type-test.ts new file mode 100644 index 00000000..3e27cd3b --- /dev/null +++ b/packages/ecs/src/interface/system/optional.type-test.ts @@ -0,0 +1,42 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from '../entity/entity.js'; +import { System } from './system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const position = Symbol("optionalTest/position"); +const velocity = Symbol("optionalTest/velocity"); +const pilot = Symbol("optionalTest/pilot"); + +declare module '@adobe/ecs' { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + [pilot]: { type: Entity; reference: "owner" }; + } +} + +// An optional component is its value, or undefined where an entity lacks it. +System.create({ components: { with: [position], optional: [velocity, pilot] } }, row => { + check>(); + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; + row[velocity] = 2; + // @ts-expect-error a reference is read-only in a row, optional or not + row[pilot] = Entity.none; +}); + +// Kernels don't read components an archetype may lack yet. +System.create({ + // @ts-expect-error a kernel can't have optional components yet + kernel: true, + components: { with: [position], optional: [velocity] }, +}, row => { void row[velocity]; }); + +// An observer system reads its optional components and writes nothing. +System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + void row[velocity]; + // @ts-expect-error an observer system's row is read-only + row[velocity] = 1; +}); From e666beffddb6afd749596e73c20d68a1d34c94bd Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:24:56 -0700 Subject: [PATCH 113/127] ecs: optional components bound per archetype, with no check per row; kernels, tails and joined observers leave them out for now Co-Authored-By: Claude Opus 5.5 --- .../src/implementation/compiler/inferred.ts | 7 +- .../src/implementation/compiler/signature.ts | 17 ++ .../implementation/compiler/transform.test.ts | 11 +- packages/ecs/src/implementation/world/join.ts | 2 + .../implementation/world/joined-observer.ts | 4 +- .../src/implementation/world/optional.test.ts | 153 ++++++++++++++++++ .../implementation/world/remote-systems.ts | 3 +- .../ecs/src/implementation/world/required.ts | 10 ++ packages/ecs/src/implementation/world/rows.ts | 58 +++++-- .../ecs/src/implementation/world/runner.ts | 12 +- .../ecs/src/implementation/world/tails.ts | 4 +- .../implementation/world/thread-systems.ts | 5 +- .../ecs/src/interface/system/resolve.test.ts | 6 + packages/ecs/src/interface/system/resolve.ts | 10 +- 14 files changed, 273 insertions(+), 29 deletions(-) create mode 100644 packages/ecs/src/implementation/world/optional.test.ts create mode 100644 packages/ecs/src/implementation/world/required.ts diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts index 9a3967b5..9297db5a 100644 --- a/packages/ecs/src/implementation/compiler/inferred.ts +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -28,7 +28,8 @@ const ordered = (names: ReadonlySet, first: readonly string[]): Readonly */ export const inOrder = (used: Used, declaration: ObjectExpression): Used => { const declared = fields(declaration); - const members = namesIn(valueOf(fields(valueOf(declared.get("components"))).get("with"))); + const components = fields(valueOf(declared.get("components"))); + const members = [...namesIn(valueOf(components.get("with"))), ...namesIn(valueOf(components.get("optional")))]; const lookups = fields(valueOf(declared.get("lookup"))); return { ...used, @@ -51,10 +52,10 @@ export const unrequired = (used: Used, declaration: ObjectExpression): { readonl return lists.every(node => node.type === "ArrayExpression" && node.elements.every(element => element?.type === "Identifier" || element?.type === "MemberExpression")) ? lists.flatMap(node => namesIn(node)) : null; }; - const members = literal(["with", "added", "changed"].map(name => valueOf(components.get(name)))); + const members = literal(["with", "optional", "added", "changed"].map(name => valueOf(components.get(name)))); if (used.query !== null && members !== null) { const key = [...used.query.read, ...used.query.write].find(name => !members.includes(name)); - if (key !== undefined) return { key, list: "components with" }; + if (key !== undefined) return { key, list: "components with or optional" }; } if (used.lookups !== null) { for (const [name, lookup] of fields(valueOf(declared.get("lookup")))) { diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index a3279fae..30195c96 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -86,15 +86,30 @@ export type KernelRequest = { readonly target: "cpu" | "gpu"; readonly required: const declares = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name); +/** A declaration's `components.optional` property, or null without one. */ +const hasOptional = (declaration: ObjectExpression): Property | null => { + for (const property of declaration.properties) { + if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "components") continue; + if (property.value.type !== "ObjectExpression") return null; + for (const field of property.value.properties) { + if (field.type === "Property" && !field.computed && field.key.type === "Identifier" && field.key.name === "optional") return field; + } + } + return null; +}; + /** * How a `System.create` declaration compiles to a kernel, or null when it doesn't: `kernel: true` or `"gpu"` requires * it, `false` rules it out, and without it a system with components is tried, unless it observes, runs on one entity * or spreads what the build can't read. */ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => { + const optional = hasOptional(declaration); for (const property of declaration.properties) { if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { const { value } = property; + const required = value.type === "Literal" && (value.value === true || value.value === "gpu"); + if (required && optional !== null) return fail(optional, "a kernel can't have optional components yet: list them in with, or use kernel: false to run the body as written"); if (value.type === "Literal" && value.value === true) return { target: "cpu", required: true }; if (value.type === "Literal" && value.value === "gpu") return { target: "gpu", required: true }; if (value.type === "Literal" && value.value === false) return null; @@ -102,6 +117,8 @@ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => } } if (declaration.properties.some(property => property.type === "SpreadElement")) return null; + // Kernels don't read components an archetype may lack yet, so such a system runs as written. + if (optional !== null) return null; if (!declares(declaration, "components") || declares(declaration, "observer") || declares(declaration, "target")) return null; return { target: "cpu", required: false }; }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 47bae79a..6d077f74 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -354,6 +354,13 @@ describe("kernels by default", () => { expect(await attached(`const kernel = System.create({ resources: {} }, ({ resources }) => { void resources[delta]; });`)).toBe(false); expect(await attached(`const kernel = System.create({ kernel: false, components: { with: [mass, score] } }, row => { row[score] = row[mass]; });`)).toBe(false); }); + test("runs a system with optional components as written, and fails one that requires a kernel", async () => { + expect(await attached(`const kernel = System.create({ components: { with: [score], optional: [mass] } }, row => { const found = row[mass]; if (found !== undefined) row[score] = found; });`)).toBe(false); + expect(await failure(`const kernel = System.create({ kernel: true, components: { with: [score], optional: [mass] } }, row => { row[score] = 1; });`)) + .toBe("a kernel can't have optional components yet: list them in with, or use kernel: false to run the body as written"); + expect(await failure(`const kernel = System.create({ components: { with: [score] } }, row => { row[score] = row[mass]; });`)) + .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); + }); test("fails, saying why, a system with kernel: true that can't compile", async () => { expect(await attached(`const kernel = System.create({ kernel: true }, ({ resources }) => { void resources[delta]; });`)) .toBe("a system without components runs once, so there's nothing to compile"); @@ -512,7 +519,7 @@ describe("binding identity", () => { expect(await failure(`const speed = "velocity"; const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { row[position] = row[speed]; - });`)).toBe("speed isn't in components with, so the system doesn't require it: add it there to use it"); + });`)).toBe("speed isn't in components with or optional, so the system doesn't require it: add it there to use it"); }); test("a helper parameter shadows a module binding only inside the helper", async () => { @@ -1445,7 +1452,7 @@ describe("inferred access", () => { test("fail the build for a key a body uses that its declaration doesn't require", async () => { expect(await inferred(system("components: { with: [position] }", "(row) => { console.log(row[mass]); }"))) - .toBe("mass isn't in components with, so the system doesn't require it: add it there to use it"); + .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[position]); }"))) .toBe("position isn't in lookup.targets.with, so the system doesn't require it: add it there to use it"); }); diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 913621a0..5d39b0e5 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -573,6 +573,8 @@ export const joinWorld = (port: Parameters[0], options: { readon const localOf = (leaf: Leaf): Local => { const known = localsByLeaf.get(leaf); if (known !== undefined) return known; + // Its posts copy rows across archetypes, which don't tell an absent optional component from a present one. + if ((leaf.declaration.components?.optional?.length ?? 0) > 0) throw new Error(`Observer system "${leaf.name}" has optional components, which observer systems on joined threads don't support yet: run it on the world's thread, or use two systems.`); const declaration = describe(leaf); // A number of this thread's, never posted as a name: the declaration names it. const number = numbers.size; diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts index 6c8b8fa7..83931948 100644 --- a/packages/ecs/src/implementation/world/joined-observer.ts +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -213,9 +213,11 @@ export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: Obs const unnamed = unnamedKey(leaf); if (unnamed !== null) throw new Error(unnamed); } - const base = createRunner(state, leaf, since); const { declaration } = leaf; const { components, resources } = declaration; + // Its posts copy rows across archetypes, which don't tell an absent optional component from a present one. + if ((components?.optional?.length ?? 0) > 0) throw new Error(`Observer system "${leaf.name}" has optional components, which observer systems on joined threads don't support yet: run it on the world's thread, or use two systems.`); + const base = createRunner(state, leaf, since); const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; // Strings live in shared memory, as structs do. const isStruct = (id: number): boolean => state.layouts[id].kind !== "object"; diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts new file mode 100644 index 00000000..0ac93a9f --- /dev/null +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -0,0 +1,153 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Index, System, workers } from "../../index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const position = Symbol("optionalTest/position"); +const velocity = Symbol("optionalTest/velocity"); +const label = Symbol("optionalTest/label"); +const order = Symbol("optionalTest/order"); +const marker = Symbol("optionalTest/marker"); +declare module "@adobe/ecs" { + interface Threads { + optionalTest: true; + } + interface Components { + [marker]: { type: { readonly text: string } | null; thread: "optionalTest" }; + [position]: { type: number }; + [velocity]: { type: number }; + [label]: { type: string }; + [order]: { type: number }; + } +} + +const spawn = System.create(({ commands }) => { + commands.spawn([position, order])({ [position]: 1, [order]: 2 }); + commands.spawn([position, velocity, order])({ [position]: 10, [velocity]: 5, [order]: 1 }); + commands.spawn([position, velocity, label, order])({ [position]: 100, [velocity]: 50, [label]: "c", [order]: 0 }); + commands.spawn([velocity])({ [velocity]: 7 }); +}); + +// Moves what has a velocity, and leaves the rest where they are. +const move = System.create({ components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); + +let seen: (readonly [number, number | undefined, string | undefined])[] = []; +const read = System.create({ observer: true, components: { with: [position], optional: [velocity, label] } }, row => { + seen.push([row[position], row[velocity], row[label]]); +}); +const sorted = () => [...seen].sort((a, b) => a[0] - b[0]); + +const inline = () => createWorld({ [workers]: 0 }); + +const spawnMarked = System.create(({ commands }) => { + commands.spawn([position, marker])({ [position]: 1 }); + commands.spawn([position, velocity, marker])({ [position]: 10, [velocity]: 5 }); +}); +// Placed on the markers' thread by reading them. +const there = System.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + if (row[marker] === null) row[position] = row[velocity] ?? -1; +}); + +// An observer system there posts copies of rows, which optional components aren't in yet. +const watchThere = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { void row[velocity]; }); + +/** A world shared with a thread on this one, which claims the thread markers live on. */ +const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote: ReturnType }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["optionalTest"] }); + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return { world, remote }; +}; + +describe("optional components", () => { + it("never narrow what a system matches, and read undefined where an entity lacks one", async () => { + await using world = inline(); + await world.run(spawn); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [10, 5, undefined], [100, 50, "c"]]); + await world.run(move); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [15, 5, undefined], [150, 50, "c"]]); + }); + + it("are written where an entity has one, and fail their system where it doesn't", async () => { + const write = System.create({ components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; + }); + await using world = inline(); + await world.run(spawn); + const report = await runReported(world, write); + expect(report.failures.map(failure => failure.message)).toEqual([`An entity lacks "optionalTest/velocity", optional in its system: add it with commands.add rather than assigning it.`]); + }); + + it("pass change filters on other components, and are stamped changed when written", async () => { + const changed: number[] = []; + const watch = System.create({ observer: true, components: { with: [velocity], changed: [velocity] } }, row => { changed.push(row[velocity]); }); + const speedUp = System.create({ components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined && row[position] > 50) row[velocity] = speed + 1; + }); + await using world = inline(); + await world.run(spawn); + await world.run(watch); + changed.length = 0; + await world.run(speedUp); + await world.run(watch); + expect(changed).toEqual([51]); + }); + + it("run in an index's order across archetypes that have them and don't", async () => { + const byOrder = Index.create({ key: [order], sorted: true }); + const ordered: (number | undefined)[] = []; + const list = System.create({ observer: true, components: { with: [position], optional: [velocity] }, order: byOrder }, row => { + ordered.push(row[velocity]); + }); + await using world = inline(); + await world.run(spawn); + await world.run(list); + expect(ordered).toEqual([50, 5, undefined]); + }); + + it("conflict with systems using them as members do", () => { + const writes = System.create({ components: { with: [position], optional: [velocity] } }, row => { row[velocity] = 0; }); + const reads = System.create({ components: { with: [velocity, position] } }, row => { row[position] = row[velocity]; }); + expect(() => System.create([writes, reads])).toThrow(); + }); + + it("are read on a joined thread, where a system reading the thread's objects runs", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + await world.run(there); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[-1, undefined, undefined], [5, 5, undefined]]); + await remote.dispose(); + await world.dispose(); + }); + + it("aren't supported yet in observer systems on a joined thread", async () => { + const { world, remote } = await joined(); + expect(() => remote.add(watchThere)).toThrow(`has optional components, which observer systems on joined threads don't support yet`); + await remote.dispose(); + await world.dispose(); + }); + + it("run as written, since kernels don't read components an archetype may lack yet", () => { + expect(kernelOf(move)).toBeUndefined(); + }); +}); diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts index a4bc45ad..92cebfb1 100644 --- a/packages/ecs/src/implementation/world/remote-systems.ts +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -20,6 +20,7 @@ import { logBoundedWord, logBoundWord, logSyncWord, type MoveLog } from './move- import { createStopWriter, freeStops, type StopWriter } from './stop-lists.js'; import { freeTail, stampWritten, type Tail } from './tails.js'; import { isHeld } from './transfers.js'; +import { requiredOf } from './required.js'; import { completionCapacityWord, completionMessage, completionOffsetWord, completionSystems, completionWords, executedFlag, failedFlag, payloadCapacityWord, payloadCount, payloadInput, payloadOffsetWord, payloadSequence, payloadSync, payloadSystems, payloadWords, ranFlag, @@ -551,7 +552,7 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne if (problem !== null) throw new Error(problem); const lookups = remoteIndexesOf(state, declaration.lookup, acquireLookups(state, declaration.lookup)); const index = channel.place(state, leaf, lookups.named); - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const required = requiredOf(query).map(key => idOf(state, key)); const excluded = (query?.not ?? []).map(key => idOf(state, key)); const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); diff --git a/packages/ecs/src/implementation/world/required.ts b/packages/ecs/src/implementation/world/required.ts new file mode 100644 index 00000000..3f9f07d8 --- /dev/null +++ b/packages/ecs/src/implementation/world/required.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ComponentLists } from '../../interface/system/access.js'; + +/** The components every entity a system's lists match has: what it reads, writes, has and filters on, but its optional ones. */ +export const requiredOf = (lists: ComponentLists | undefined): readonly symbol[] => { + if (lists === undefined) return []; + const { optional } = lists; + const all = new Set([...lists.read ?? [], ...lists.write ?? [], ...lists.has ?? [], ...lists.added ?? [], ...lists.changed ?? []]); + return optional === undefined ? [...all] : [...all].filter(key => !optional.includes(key)); +}; diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 0a60fb9e..24fb8dc8 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -10,6 +10,7 @@ import { readString, writeString } from './strings.js'; import { indexOf, locate } from './entities.js'; import { ownTag } from './move-log.js'; import { resourceCell } from './resources.js'; +import { labelOf } from '../../interface/registry/names.js'; import { idOf, type Key, type State } from './state.js'; import { combine, contributed, identityOf, type ReductionTarget } from './reductions.js'; import { createIndexLookup, type Since } from './index-lookups.js'; @@ -126,8 +127,18 @@ const slotOf = (archetype: RowArchetype, id: number): Slot => { return { storage, view: viewOf(storage) }; }; -export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[]): Row => { - const slots = ids.map(id => slotOf(archetype, id)); +/** An optional component's getter where the archetype lacks it, and its setter, which fails its system. */ +const absentGetter = (): undefined => undefined; +const absentSetterOf = (key: Key): (value: unknown) => void => () => { + throw new Error(`An entity lacks "${labelOf(key)}", optional in its system: add it with commands.add rather than assigning it.`); +}; + +/** + * A row of an archetype's `keys`, `absent` naming those it lacks, which an optional component may be: they read as + * `undefined`, and assigning one fails its system. + */ +export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[], absent: readonly boolean[] = []): Row => { + const slots = ids.map((id, i): Slot | undefined => absent[i] === true ? undefined : slotOf(archetype, id)); // Its own prototype gives the row its own root map. Rows built from `{}` share one, and defining an accessor with a // different getter than an existing transition's makes V8 switch the object to slow dictionary properties. const value: object = Object.create({}); @@ -138,18 +149,25 @@ export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly bind: next => { row.entities = next.entities; for (let i = 0; i < ids.length; i++) { + const slot = slots[i]; + if (slot === undefined) continue; const { storage, view } = slotOf(next, ids[i]); - slots[i].storage = storage; - slots[i].view = view; + slot.storage = storage; + slot.view = view; } }, }; Object.defineProperty(value, "entity", { get: () => row.entities[row.index], enumerable: true }); keys.forEach((key, i) => { const layout = host.layouts[ids[i]]; - Object.defineProperty(value, key, { - get: getterOf(host, layout, slots[i], row), - set: writes.includes(key) ? setterOf(host, layout, slots[i], row) : undefined, + const slot = slots[i]; + Object.defineProperty(value, key, slot === undefined ? { + get: absentGetter, + set: writes.includes(key) ? absentSetterOf(key) : undefined, + enumerable: true, + } : { + get: getterOf(host, layout, slot, row), + set: writes.includes(key) ? setterOf(host, layout, slot, row) : undefined, enumerable: true, }); }); @@ -262,11 +280,13 @@ export const parentBinder = (state: State, reads: readonly Key[]): (archetype: A * declares the same ones, so a body sees one row shape whichever system or archetype it runs over. A host's systems * run one at a time, so a binding lasts for one system's pass over one archetype. `idOfKey` gives each key's component. */ -export const rowBinderOf = (host: RowHost, idOfKey: (key: Key) => number, reads: readonly Key[], writes: readonly Key[]): (archetype: A) => Row => { +export const rowBinderOf = (host: RowHost, idOfKey: (key: Key) => number, reads: readonly Key[], writes: readonly Key[], optional: readonly Key[] = []): (archetype: A) => Row => { const keys = [...new Set([...reads, ...writes])]; const ids = keys.map(idOfKey); const signature = keys.map((key, i) => writes.includes(key) ? `${ids[i]}w` : `${ids[i]}`).join(","); - return archetype => { + /** The positions in `keys` of optional components, whose presence in an archetype picks its row. */ + const optionals = keys.flatMap((key, i) => optional.includes(key) ? [i] : []); + if (optionals.length === 0) return archetype => { let row = host.rows.get(signature); if (row === undefined) { row = createRow(host, archetype, keys, ids, writes); @@ -275,11 +295,27 @@ export const rowBinderOf = (host: RowHost, idOfKey: (key else row.bind(archetype); return row; }; + // Each pattern of which optional components an archetype has is a row of its own, so no read checks per row. + const absent: boolean[] = keys.map(() => false); + return archetype => { + let pattern = signature + "|"; + for (const i of optionals) { + absent[i] = archetype.storageById[ids[i]] === undefined; + pattern += absent[i] ? "0" : "1"; + } + let row = host.rows.get(pattern); + if (row === undefined) { + row = createRow(host, archetype, keys, ids, writes, [...absent]); + host.rows.set(pattern, row); + } + else row.bind(archetype); + return row; + }; }; /** The accessor row binder for main-thread systems of a world. */ -export const rowBinder = (state: State, reads: readonly Key[], writes: readonly Key[]): (archetype: Archetype) => Row => - rowBinderOf(state, key => idOf(state, key), reads, writes); +export const rowBinder = (state: State, reads: readonly Key[], writes: readonly Key[], optional: readonly Key[] = []): (archetype: Archetype) => Row => + rowBinderOf(state, key => idOf(state, key), reads, writes, optional); /** * A lookup's matcher, with its component ids resolved once. `find` returns the row of a live entity that matches, or -1, diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 2c54c688..527627f5 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -14,6 +14,7 @@ import { createBlock, endBlock, indexOf, locate, reserveFrom } from './entities. import { forEachPassingRange, type Filter } from './ranges.js'; import { resourceCell } from './resources.js'; import { createContext, parentBinder, rowBinder } from './rows.js'; +import { requiredOf } from './required.js'; import { scalarValue, scalarValues, type WasmKernel } from './wasm.js'; import { idOf, type State } from './state.js'; import { accessorsOf } from '../../interface/gpu/accessors.js'; @@ -188,7 +189,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const required = requiredOf(query).map(key => idOf(state, key)); const excluded = (query?.not ?? []).map(key => idOf(state, key)); const addedIds = (query?.added ?? []).map(key => idOf(state, key)); const changedIds = (query?.changed ?? []).map(key => idOf(state, key)); @@ -360,7 +361,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const partialAddress = (slot: number): number => partialCount === 0 ? -1 : slots.byteOffset + slot * partialCount * 8; const matches: Archetype[] = []; let checked = 0; - const bindRow = rowBinder(state, query?.read ?? [], query?.write ?? []); + const bindRow = rowBinder(state, query?.read ?? [], query?.write ?? [], query?.optional); + /** Whether its rows have optional components, so which an archetype has picks its row. */ + const optional = (query?.optional?.length ?? 0) > 0; const bindParent = parentBinder(state, declaration.up?.read ?? []); let lastRun = since; @@ -578,8 +581,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owner = archetype < owners.length ? owners[archetype] : -1; if (owner < 0) continue; if (owner !== current || row === null || value === null) { - // The row is found once; after, it's bound in place, as often as every row when archetypes interleave. - if (row === null) row = bindRow(matches[owner]); + // The row is found once; after, it's bound in place, as often as every row when archetypes interleave, + // unless which optional components an archetype has picks another row. + if (row === null || optional) row = bindRow(matches[owner]); else row.bind(matches[owner]); value = row.value; current = owner; diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 1931267e..0ad30172 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -33,13 +33,15 @@ const objectsOnly = (keys: readonly Key[] | undefined): boolean => /** * Whether a leaf is terminal: the plugin proved its body never reaches `commands`, and it writes only objects, which * live on its own thread, reduces into nothing, and looks nothing up, since a lookup reads the entity table the next - * sync point rewrites. Neither a kernel nor `up`. + * sync point rewrites. Neither a kernel nor `up`, nor with optional components, which a tail's snapshot of rows across + * archetypes doesn't tell apart from present ones. */ export const isTerminal = (leaf: Leaf): boolean => { const known = terminalLeaves.get(leaf); if (known !== undefined) return known; const { declaration } = leaf; const terminal = leaf.kind === "system" && kernelOf(leaf) === undefined && declaration.up === undefined && provenTerminal(leaf.body) + && (declaration.components?.optional?.length ?? 0) === 0 && Object.keys(declaration.lookup ?? {}).length === 0 && (declaration.resources?.reduce?.length ?? 0) === 0 && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 937e3268..392db61f 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -24,6 +24,7 @@ import type { WorkerError } from './protocol.js'; import { forEachPassingRange, type Filter } from './ranges.js'; import { combine, contributed, identityOf, reductionTargetsOf, type ReductionTarget } from './reductions.js'; import { changedRowOf, emptyRow, rowBinderOf, type RowHost } from './rows.js'; +import { requiredOf } from './required.js'; import type { Since } from './index-lookups.js'; import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; import { createThreadFinalizers } from './thread-finalizers.js'; @@ -634,13 +635,13 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; const numberOf = host.numberOf; - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(numberOf); + const required = requiredOf(query).map(numberOf); const excluded = (query?.not ?? []).map(numberOf); const addedIds = (query?.added ?? []).map(numberOf); const changedIds = (query?.changed ?? []).map(numberOf); const resourceChanged = (resources?.changed ?? []).map(numberOf); const filtered = addedIds.length + changedIds.length + resourceChanged.length > 0; - const bindRow = rowBinderOf(host.rows, numberOf, query?.read ?? [], query?.write ?? []); + const bindRow = rowBinderOf(host.rows, numberOf, query?.read ?? [], query?.write ?? [], query?.optional); const matches: number[] = []; let checked = 0; const refreshMatches = (): void => { diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts index 6943c796..80a5f3b8 100644 --- a/packages/ecs/src/interface/system/resolve.test.ts +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -32,6 +32,12 @@ describe("componentLists", () => { it("takes an observer's row the build didn't follow as reading every member, writing none", () => { expect(componentLists({ with: [mass], changed: [position] }, null, true)).toEqual({ read: [mass, position], changed: [position] }); }); + it("lists optional components apart, reading and writing them as the body does, or as members when unfollowed", () => { + const used = { read: [position], write: [mass] }; + expect(componentLists({ with: [mass], optional: [position] }, used, false)).toEqual({ read: [position], write: [mass], optional: [position] }); + expect(componentLists({ with: [mass], optional: [position] }, null, false)).toEqual({ write: [mass, position], optional: [position] }); + expect(componentLists({ with: [mass], optional: [mass, position] }, used, false)).toEqual({ read: [position], write: [mass], optional: [position] }); + }); }); describe("resourceUses", () => { diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts index c49e914a..06f80306 100644 --- a/packages/ecs/src/interface/system/resolve.ts +++ b/packages/ecs/src/interface/system/resolve.ts @@ -90,14 +90,16 @@ export const compact = (lists: T): T => { * reads. */ export const componentLists = (components: ComponentAccess, used: Used | null, reading: boolean): ComponentLists => { - // A row holds what its added and changed filters name too. + // A row holds what its added and changed filters name too, and its optional components where an entity has them. const members = [...new Set([...components.with ?? [], ...components.added ?? [], ...components.changed ?? []])]; + const optional = without(components.optional ?? [], members); + const held = [...members, ...optional]; const write = used === null - ? reading ? [] : members.filter(key => declarationOf(key)?.reference === undefined) + ? reading ? [] : held.filter(key => declarationOf(key)?.reference === undefined) : used.write; - const read = used === null ? without(members, write) : without(used.read, write); + const read = used === null ? without(held, write) : without(used.read, write); const has = without(members, read, write); - return compact({ read, write, has, not: components.not, added: components.added, changed: components.changed }); + return compact({ read, write, has, optional, not: components.not, added: components.added, changed: components.changed }); }; /** From d73f6f596d3ac7702d7b528ef82ffb700812cc23 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:24:57 -0700 Subject: [PATCH 114/127] ecs: benchmarks of a system with an optional component against one requiring it Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/perftest/benchmarks.ts | 2 + packages/ecs/src/perftest/optional.ts | 75 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/ecs/src/perftest/optional.ts diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index dd9b1dbe..34b90a8d 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -26,6 +26,7 @@ import { editorBenchmarks } from "./editor.js"; import { stringBenchmarks } from "./strings.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { filterBenchmarks } from "./filters.js"; +import { optionalBenchmarks } from "./optional.js"; import { createWorld } from "../implementation/index.js"; import { workers } from "../interface/resources/workers.js"; // Last, so the bundle orders the modules the benchmarks share as it did before these imports. @@ -700,4 +701,5 @@ export const benchmarks: readonly Benchmark[] = [ ...valueBenchmarks, structReplace("main", 0), ...filterBenchmarks, + ...optionalBenchmarks, ]; diff --git a/packages/ecs/src/perftest/optional.ts b/packages/ecs/src/perftest/optional.ts new file mode 100644 index 00000000..1703cec7 --- /dev/null +++ b/packages/ecs/src/perftest/optional.ts @@ -0,0 +1,75 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// A main-thread system with an optional component against the same system requiring it, over rows that all have it, +// and over rows of which half lack it: optional components bind per archetype, so a row costs what a member's does. + +const height = Symbol("optional/height"); +const lift = Symbol("optional/lift"); +declare module "@adobe/ecs" { + export interface Components { + [height]: { type: number }; + [lift]: { type: number }; + } +} + +const rows = 100_000; + +const seed = System.create({}, ({ commands }, input: { readonly lacking: boolean }) => { + for (let i = 0; i < rows; i++) { + if (input.lacking && i % 2 === 1) commands.spawn([height])({ [height]: 0 }); + else commands.spawn([height, lift])({ [height]: 0, [lift]: 1 }); + } +}); + +/** Raises each row by its lift, which every row has. */ +const raiseRequired = System.create({ kernel: false, components: { with: [height, lift] } }, row => { + row[height] += row[lift]; +}); + +/** Raises each row by its lift where it has one. */ +const raiseOptional = System.create({ kernel: false, components: { with: [height], optional: [lift] } }, row => { + const by = row[lift]; + if (by !== undefined) row[height] += by; +}); + +const seeded = async (lacking: boolean): Promise => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { lacking }); + return world; +}; + +const run = async (system: System, lacking: boolean): Promise => { + const world = await seeded(lacking); + const best = await fastest(() => world.run(system)); + await world.dispose(); + return best; +}; + +const per = { per: "rows", n: rows, typical: [1_000, 100_000], best: { bytes: 16, basis: "Each row reads its 8-byte lift and reads and writes its 8-byte height." } } as const; + +export const optionalBenchmarks: readonly Benchmark[] = [ + { + name: "with 100K", + operation: "A main-thread row system reading a component it requires and writing another, over 100K rows: the baseline for `optional 100K`.", + ...per, + run: () => run(raiseRequired, false), + }, + { + name: "optional 100K", + operation: "As `with 100K`, the component it reads optional, over rows that all have it: what reading an optional component costs a row.", + ...per, + run: () => run(raiseOptional, false), + }, + { + name: "optional half 100K", + operation: "As `optional 100K`, over rows of which half lack the optional component, in an archetype of their own.", + ...per, + run: () => run(raiseOptional, true), + }, +]; From 7304d49df84eea97550318965b090304d66b3440 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:24:57 -0700 Subject: [PATCH 115/127] ecs: the guide and design describe optional components Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 1 + packages/ecs/followups.md | 10 ++++------ packages/ecs/src/guide/03-systems.test.ts | 19 +++++++++++++++++++ packages/ecs/src/guide/guide.md | 20 +++++++++++++++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 8af47bc6..ab602419 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -114,6 +114,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - `added`/`changed` pass when **any** of the listed components was added or changed. + - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`. Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Not yet in kernels, whose compiler has no presence test: an unflagged system with optional components runs as written, and `kernel: true` or `"gpu"` with them fails the build and its types. Not in tails or in observer systems on joined threads, whose copies of rows across archetypes don't tell an absent component from a present one: a system with them never runs as a tail, and such an observer system fails to be added there. - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 3cf48ca2..0ea0753d 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -16,12 +16,10 @@ correctness, then optimization. Remove an item once it's decided or done. before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), or `world.share` returning a promise of the claim. Smallest form to be decided. -- **Optional components:** `components: { with, optional, not, … }`, rows typed `T | undefined` for each optional key, - `undefined` when the archetype lacks it; never narrows matching. Presence is per archetype, so runners bind it once - per archetype and kernels specialize per archetype or chunk: no per-row cost. Access inferred as for `with`; writes - only where present (narrowed by `!== undefined`), an absent write failing the system; `changed` on one passes rows - that have it and changed. Possibly on lookups by entity too. Replaces two systems, self-lookups or nullable - components. +- **Optional components in kernels, tails and joined observer systems:** kernels need a presence test lowered in the + JS, WASM and WGSL backends (per archetype or per chunk, never per row) and writes of absent components failing the + system; tails and observer posts on joined threads need their row copies to tell absent components from present + ones. Today those systems run as written, never run as tails, or fail to be added there. - **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index 1a306f54..17b7cf00 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -59,6 +59,25 @@ it("runs a system once per entity, or once", async () => { expect(counted).toBe(1); }); +const spawnStill = System.create(({ commands }) => { + commands.spawn([position])({ [position]: [5, 0, 0] }); + commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); +}); + +// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which +// entities a system runs on. +const speeds: (Vec3 | undefined)[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + speeds.push(row[velocity]); +}); + +it("reads optional components where entities have them", async () => { + await using world = World.create(); + await world.run(spawnStill); + await world.run(readSpeeds); + expect(speeds).toEqual([undefined, [2, 0, 0]]); +}); + const fail = System.create(() => { throw new Error("broken"); }); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index fd77d15b..5bd676ae 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -203,6 +203,24 @@ it("runs a system once per entity, or once", async () => { [`03-systems.test.ts`](03-systems.test.ts#L28-L58) +```ts +// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which +// entities a system runs on. +const speeds: (Vec3 | undefined)[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + speeds.push(row[velocity]); +}); + +it("reads optional components where entities have them", async () => { + await using world = World.create(); + await world.run(spawnStill); + await world.run(readSpeeds); + expect(speeds).toEqual([undefined, [2, 0, 0]]); +}); +``` + +[`03-systems.test.ts`](03-systems.test.ts#L67-L79) + ```ts const fail = System.create(() => { throw new Error("broken"); @@ -224,7 +242,7 @@ it("reports what failed as entities", async () => { expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L62-L79) +[`03-systems.test.ts`](03-systems.test.ts#L81-L98) ### 4. Ticks and cadence From 294b6ac3648455a5d8b0ef6ea3484d27257eb914 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:37:19 -0700 Subject: [PATCH 116/127] ecs: generated API reference with typedoc over the public entry points, the guide rendered with it, linked from the README Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + packages/ecs/README.md | 3 ++- packages/ecs/package.json | 7 +++++- packages/ecs/scripts/typedoc-guide-links.mjs | 23 +++++++++++++++++++ .../ecs/src/implementation/compiler/index.ts | 4 ++++ packages/ecs/src/index.ts | 4 ++++ packages/ecs/tsconfig.typedoc.json | 14 +++++++++++ packages/ecs/typedoc.json | 14 +++++++++++ pnpm-lock.yaml | 3 +++ 9 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 packages/ecs/scripts/typedoc-guide-links.mjs create mode 100644 packages/ecs/tsconfig.typedoc.json create mode 100644 packages/ecs/typedoc.json diff --git a/.gitignore b/.gitignore index 9e20317d..093202f5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ source.txt # Generated Docs (typedoc output) docs/api packages/data/docs/api +packages/ecs/docs packages/data/references # Playwright MCP capture artifacts diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 7b29e4dc..ec7e5e7b 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -50,4 +50,5 @@ A library builds with `Compiler.plugin({ library: true })`, so its build ships t ## Guide -See the [guide](src/guide/guide.md) for how to use it. +See the [guide](src/guide/guide.md) for how to use it, and the [API reference](https://adobe.github.io/data/ecs/) for +every type and function. diff --git a/packages/ecs/package.json b/packages/ecs/package.json index be1bf7b7..279400ed 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -6,6 +6,9 @@ "type": "module", "sideEffects": false, "private": false, + "pages": [ + { "from": "docs", "to": "ecs" } + ], "independentVersion": true, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -37,7 +40,8 @@ "perf": "node scripts/perf.mjs", "perf:browser": "node scripts/perf.mjs --browser", "perf:report": "node scripts/perf.mjs --report", - "sample": "vite --config vite.sample.config.ts" + "sample": "vite --config vite.sample.config.ts", + "docs": "typedoc" }, "devDependencies": { "@types/estree": "^1.0.5", @@ -45,6 +49,7 @@ "@vitest/browser-playwright": "^5.0.5", "@webgpu/types": "^0.1.61", "playwright": "^1.64.1", + "typedoc": "^0.28.9", "typescript": "^5.8.3", "vite": "^8.3.4", "vitest": "^5.0.5" diff --git a/packages/ecs/scripts/typedoc-guide-links.mjs b/packages/ecs/scripts/typedoc-guide-links.mjs new file mode 100644 index 00000000..6b312108 --- /dev/null +++ b/packages/ecs/scripts/typedoc-guide-links.mjs @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Points the guide's links to the tests its excerpts come from at those files on GitHub, where they render with line +// highlights, instead of at copies typedoc would serve as raw files. +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { MarkdownEvent, RendererEvent } from "typedoc"; + +const source = "https://github.com/adobe/data/blob/main/packages/ecs/src/guide/"; +const copied = /href="(?:\.\.\/)*media\/([\w.-]+\.ts)(#L\d+(?:-L\d+)?)?"/g; + +/** @param {import("typedoc").Application} app */ +export const load = app => { + const files = new Set(); + app.renderer.on(MarkdownEvent.PARSE, event => { + event.parsedText = event.parsedText.replace(copied, (_, file, lines = "") => { + files.add(file); + return `href="${source}${file}${lines}"`; + }); + }); + app.renderer.on(RendererEvent.END, event => { + for (const file of files) rmSync(join(event.outputDirectory, "media", file), { force: true }); + }); +}; diff --git a/packages/ecs/src/implementation/compiler/index.ts b/packages/ecs/src/implementation/compiler/index.ts index e14f41d6..2c4ff5a5 100644 --- a/packages/ecs/src/implementation/compiler/index.ts +++ b/packages/ecs/src/implementation/compiler/index.ts @@ -1,2 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/** + * The build plugin, which every application and library using the ECS builds with. + * @module @adobe/ecs/compiler + */ export { Compiler } from './compiler.js'; diff --git a/packages/ecs/src/index.ts b/packages/ecs/src/index.ts index f146c518..cb678fef 100644 --- a/packages/ecs/src/index.ts +++ b/packages/ecs/src/index.ts @@ -1,4 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/** + * A high-performance, multithreaded Entity Component System. + * @module @adobe/ecs + */ import { create, join } from './implementation/index.js'; import type { Create } from './interface/world/create.js'; import type { Join } from './interface/world/join.js'; diff --git a/packages/ecs/tsconfig.typedoc.json b/packages/ecs/tsconfig.typedoc.json new file mode 100644 index 00000000..de43c8b3 --- /dev/null +++ b/packages/ecs/tsconfig.typedoc.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "paths": { + "@adobe/ecs": ["./src/index.ts"], + "@adobe/ecs/*": ["./src/index.ts"] + } + }, + "include": ["src/index.ts", "src/implementation/compiler/index.ts"], + "exclude": [], + "references": [] +} diff --git a/packages/ecs/typedoc.json b/packages/ecs/typedoc.json new file mode 100644 index 00000000..f4906367 --- /dev/null +++ b/packages/ecs/typedoc.json @@ -0,0 +1,14 @@ +{ + "name": "@adobe/ecs", + "entryPoints": ["src/index.ts", "src/implementation/compiler/index.ts"], + "tsconfig": "tsconfig.typedoc.json", + "out": "docs", + "readme": "README.md", + "projectDocuments": ["src/guide/guide.md"], + "plugin": ["./scripts/typedoc-guide-links.mjs"], + "includeVersion": true, + "excludePrivate": true, + "excludeInternal": true, + "treatWarningsAsErrors": true, + "validation": { "notExported": false, "invalidLink": true, "rewrittenLink": true } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 530538f3..db28c9ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,9 @@ importers: playwright: specifier: ^1.64.1 version: 1.64.1 + typedoc: + specifier: ^0.28.9 + version: 0.28.9(typescript@5.8.3) typescript: specifier: ^5.8.3 version: 5.8.3 From 2c8d85ce40d6301f73b95d519cc223bdcb7a8a4d Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:37:24 -0700 Subject: [PATCH 117/127] docs: the Pages site is assembled from each package's pages field, so the workflow names no package; @adobe/ecs publishes at /ecs/ Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy-docs.yml | 26 +++---------- CLAUDE.md | 6 +++ packages/data-gpu-samples/package.json | 3 ++ packages/data-p2p-tictactoe/package.json | 3 ++ packages/data/docs/index.html | 1 + packages/data/package.json | 6 ++- scripts/check-workspace-conventions.mjs | 3 ++ scripts/collect-pages.mjs | 49 ++++++++++++++++++++++++ 8 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 scripts/collect-pages.mjs diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 8585882d..aab571bb 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,28 +31,12 @@ jobs: - run: pnpm install --frozen-lockfile - - run: pnpm run build - working-directory: packages/data + - run: pnpm -r run build - - run: cp -r packages/data/dist packages/data/docs/dist + - run: pnpm -r run docs - - run: pnpm --filter @adobe/data-sync build - - - run: pnpm --filter @adobe/data-lit build - - - run: pnpm --filter @adobe/data-gpu build - - - run: pnpm --filter data-p2p-tictactoe build - env: - CI: true - - - run: cp -r packages/data-p2p-tictactoe/dist packages/data/docs/p2p-tictactoe - - - run: pnpm --filter data-gpu-samples build - env: - CI: true - - - run: cp -r packages/data-gpu-samples/dist packages/data/docs/gpu-samples + # Each package's package.json `pages` field says what it publishes where. + - run: node scripts/collect-pages.mjs site - uses: actions/configure-pages@v5 with: @@ -60,7 +44,7 @@ jobs: - uses: actions/upload-pages-artifact@v3 with: - path: packages/data/docs + path: site deploy: needs: build diff --git a/CLAUDE.md b/CLAUDE.md index 6f1c3734..15ee227d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ Standard script names a package may define: that the uniform pipeline does not call. - **`lint`** — lint the package. - **`dev`** — local dev/watch. +- **`docs`** — generate the package's documentation (e.g. typedoc). What a + package publishes to the GitHub Pages site is its `pages` field: a list of + `{ "from", "to" }`, a folder of the package and its path on the site (`"."` + for the root). The docs workflow runs `pnpm -r run build` and + `pnpm -r run docs`, then `scripts/collect-pages.mjs` copies every package's + `pages`, so publishing never names a package. Do **not** add a `publish-public` (or similar) script. Publishing is governed *only* by the `private` field: `pnpm -r publish` publishes every package with diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 0eb8c95d..b6811ba7 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -4,6 +4,9 @@ "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, + "pages": [ + { "from": "dist", "to": "gpu-samples" } + ], "scripts": { "predev": "pnpm --filter @adobe/data-gpu build", "dev": "vite", diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 16196353..0c06e391 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -4,6 +4,9 @@ "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, + "pages": [ + { "from": "dist", "to": "p2p-tictactoe" } + ], "scripts": { "dev": "vite", "build": "vite build", diff --git a/packages/data/docs/index.html b/packages/data/docs/index.html index c9bc838e..f2756348 100644 --- a/packages/data/docs/index.html +++ b/packages/data/docs/index.html @@ -16,6 +16,7 @@

@adobe/data

diff --git a/packages/data/package.json b/packages/data/package.json index 3e640844..bae33f23 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -5,6 +5,10 @@ "type": "module", "sideEffects": false, "private": false, + "pages": [ + { "from": "docs", "to": "." }, + { "from": "dist", "to": "dist" } + ], "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" @@ -19,8 +23,8 @@ "scripts": { "build": "pnpm build-assembly && run-p build:*", "build:code": "tsc -b", - "build:apidocs": "typedoc", "typecheck": "pnpm build-assembly && tsc -b", + "docs": "typedoc", "build-assembly": "run-p asbuild:release", "clean": "rm -rf dist build node_modules", "clean-cache": "rm -rf node_modules/.cache", diff --git a/scripts/check-workspace-conventions.mjs b/scripts/check-workspace-conventions.mjs index 4c0898a7..34f613db 100644 --- a/scripts/check-workspace-conventions.mjs +++ b/scripts/check-workspace-conventions.mjs @@ -52,6 +52,9 @@ for (const name of readdirSync(packagesDir)) { errors.push(`${name}: publishable packages hold a LICENSE identical to the monorepo's (copy ../../LICENSE)`); } } + if (pkg.pages !== undefined && !(Array.isArray(pkg.pages) && pkg.pages.every((page) => typeof page?.from === "string" && typeof page?.to === "string"))) { + errors.push(`${name}: "pages" lists { "from", "to" } folders to publish (see scripts/collect-pages.mjs)`); + } if (pkg.scripts?.["publish-public"]) { errors.push(`${name}: remove the "publish-public" script — publishing is governed by the "private" field and \`pnpm -r publish\``); } diff --git a/scripts/collect-pages.mjs b/scripts/collect-pages.mjs new file mode 100644 index 00000000..4a00cd7c --- /dev/null +++ b/scripts/collect-pages.mjs @@ -0,0 +1,49 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Assembles the GitHub Pages site from every package that declares what it publishes, so the docs workflow never +// names a package. A package publishes with a `pages` field in its package.json: a list of `{ "from", "to" }`, each +// copying a folder of the package (built by its `build` or `docs` script) to a path of the site, "." for the root. +// +// usage: node scripts/collect-pages.mjs + +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const packagesDir = join(root, "packages"); +const site = process.argv[2]; +if (site === undefined) { + console.error("usage: node scripts/collect-pages.mjs "); + process.exit(1); +} + +const copies = []; +for (const name of readdirSync(packagesDir)) { + let pkg; + try { + pkg = JSON.parse(readFileSync(join(packagesDir, name, "package.json"), "utf8")); + } catch { + continue; // not a package directory + } + for (const { from, to } of pkg.pages ?? []) copies.push({ name, from: join(packagesDir, name, from), to: normalize(to) }); +} + +// The root first, so a package's own path is never overwritten by it. +copies.sort((a, b) => (a.to === "." ? 0 : 1) - (b.to === "." ? 0 : 1) || a.to.localeCompare(b.to)); +const taken = new Map(); +const errors = []; +for (const { name, from, to } of copies) { + if (taken.has(to)) errors.push(`${name}: publishes to "${to}", which ${taken.get(to)} already does`); + taken.set(to, name); + if (!existsSync(from)) errors.push(`${name}: "${from}" doesn't exist; its build or docs script makes it`); +} +if (errors.length > 0) { + for (const error of errors) console.error(error); + process.exit(1); +} +mkdirSync(site, { recursive: true }); +for (const { name, from, to } of copies) { + cpSync(from, join(site, to), { recursive: true }); + console.log(`${name}: ${from} → ${join(site, to)}`); +} From 74c09e58aca451e4bc2040c51291145d0644b19c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:46:55 -0700 Subject: [PATCH 118/127] ecs: kernels read optional components, testing presence once per call; loops specialized for all present and all absent JS and WASM: an archetype lacking an optional component gives its columns empty, so reads give undefined, and a write fails the system as a body run as written does. WASM runs archetypes with every optional component; JS runs the rest. GPU kernels still reject them. Co-Authored-By: Claude Opus 5.5 --- .../ecs/src/implementation/compiler/bind.ts | 7 ++- .../src/implementation/compiler/columns.ts | 17 +++++- .../implementation/compiler/compile-kernel.ts | 48 ++++++++++++---- .../src/implementation/compiler/expression.ts | 25 ++++++++- .../src/implementation/compiler/inferred.ts | 2 +- .../ecs/src/implementation/compiler/ir.ts | 2 + .../ecs/src/implementation/compiler/print.ts | 26 +++++++-- .../src/implementation/compiler/program.ts | 2 + .../src/implementation/compiler/signature.ts | 11 ++-- .../src/implementation/compiler/snapshot.ts | 6 +- .../ecs/src/implementation/compiler/stamps.ts | 14 +++-- .../implementation/compiler/transform.test.ts | 10 ++-- .../ecs/src/implementation/compiler/unit.ts | 11 +++- .../ecs/src/implementation/world/arrays.ts | 6 +- .../src/implementation/world/check-kernel.ts | 1 + .../ecs/src/implementation/world/kernel.ts | 6 +- .../ecs/src/implementation/world/kernels.ts | 20 ++++++- .../world/optional-workers.cases.ts | 19 +++++++ .../world/optional-workers.fixture.ts | 55 +++++++++++++++++++ .../src/implementation/world/optional.test.ts | 30 +++++++++- .../ecs/src/implementation/world/protocol.ts | 6 +- .../ecs/src/implementation/world/runner.ts | 4 +- .../src/implementation/world/threads.test.ts | 3 + .../ecs/src/implementation/world/worker.ts | 10 +++- packages/ecs/src/interface/system/create.ts | 4 +- .../interface/system/optional.type-test.ts | 7 ++- packages/ecs/src/perftest/optional.ts | 27 +++++++++ 27 files changed, 318 insertions(+), 61 deletions(-) create mode 100644 packages/ecs/src/implementation/world/optional-workers.cases.ts create mode 100644 packages/ecs/src/implementation/world/optional-workers.fixture.ts diff --git a/packages/ecs/src/implementation/compiler/bind.ts b/packages/ecs/src/implementation/compiler/bind.ts index 88b64480..29e37b1c 100644 --- a/packages/ecs/src/implementation/compiler/bind.ts +++ b/packages/ecs/src/implementation/compiler/bind.ts @@ -2,7 +2,7 @@ import type { Identifier, Node, Pattern } from 'estree'; import { access } from './access.js'; import { set } from './booleans.js'; -import { componentRead } from './columns.js'; +import { componentRead, presence } from './columns.js'; import { dataValue, mapLeaves, toData } from './data.js'; import { fail } from './failure.js'; import { local, type Expression } from './ir.js'; @@ -42,13 +42,14 @@ const declared = (node: Identifier, value: Value, binding: Binding, scope: Scope return dataValue(mapLeaves(toData(value, node, scope), copy)); case "component": { if (value.up !== true) requireReadable(node, scope, value.component); - const snapshot = createSnapshot(keyword, node.name, path => componentRead(unit, value, path)); + const optional = value.up !== true && value.path.length === 0 && unit.signature.optional.has(value.component); + const snapshot = createSnapshot(keyword, node.name, path => componentRead(unit, value, path), optional ? presence(unit, value.component) : null); out.push(snapshotDeclaration(snapshot)); return { kind: "snapshot", snapshot, path: [] }; } case "snapshot": { const source = value; - const snapshot = createSnapshot(keyword, node.name, path => local(snapshotLeaf(unit, source.snapshot, [...source.path, ...path]))); + const snapshot = createSnapshot(keyword, node.name, path => local(snapshotLeaf(unit, source.snapshot, [...source.path, ...path])), source.path.length === 0 ? source.snapshot.presence : null); out.push(snapshotDeclaration(snapshot)); return { kind: "snapshot", snapshot, path: [] }; } diff --git a/packages/ecs/src/implementation/compiler/columns.ts b/packages/ecs/src/implementation/compiler/columns.ts index 5deb8474..7e990d9f 100644 --- a/packages/ecs/src/implementation/compiler/columns.ts +++ b/packages/ecs/src/implementation/compiler/columns.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Path } from '../world/kernel.js'; import type { Expression } from './ir.js'; -import type { Unit } from './unit.js'; +import { keyIndex, type Unit } from './unit.js'; const column = (unit: Unit, identity: string, description: string): number => { const existing = unit.columnIndex.get(identity); @@ -14,7 +14,20 @@ const column = (unit: Unit, identity: string, description: string): number => { /** The column of a scalar leaf of a component. */ export const fieldColumn = (unit: Unit, component: string, path: Path): number => { const json = JSON.stringify(path); - return column(unit, `field ${component} ${json}`, `{ kind: "field", component: ${component}, path: ${json} }`); + const index = column(unit, `field ${component} ${json}`, `{ kind: "field", component: ${component}, path: ${json} }`); + if (unit.signature.optional.has(component) && !unit.optionalColumns.has(index)) unit.optionalColumns.set(index, keyIndex(unit, component)); + return index; +}; + +/** + * An optional component's presence column, which an archetype with it gives non-empty and one without empty, so a + * kernel tells them apart once per call whatever the component's shape. + */ +export const presence = (unit: Unit, component: string): Expression => { + const index = column(unit, `present ${component}`, `{ kind: "present", component: ${component} }`); + unit.presences.set(component, index); + keyIndex(unit, component); + return { kind: "present", column: index }; }; export const fieldRead = (unit: Unit, component: string, path: Path): Expression => ({ kind: "column", column: fieldColumn(unit, component, path) }); diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 32785300..141abd44 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -59,14 +59,23 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: statement({ type: "ExpressionStatement", expression: body.body, start: body.body.start, end: body.body.end }, scope, rows); } const statements = [...resolve(prelude), ...resolve(rows)]; - for (const [chunks, blocks] of unconditionalChunks(resolve(rows))) unit.stampedPerCall.set(chunks, blocks); + for (const [chunks, perCall] of unconditionalChunks(resolve(rows))) unit.stampedPerCall.set(chunks, perCall); const kernel = printStatements(unit, resolve(rows)); + // Which optional components a call's archetype has is the same for every row, so a kernel with them runs loops + // specialized for all present and all absent, and the general one only for archetypes with some of them. + const specialized = unit.presences.size === 0 ? null : ([true, false] as const).map(present => { + unit.presentAs = present; + const body = printStatements(unit, resolve(rows)); + unit.presentAs = null; + return body; + }); const preludeCode = printStatements(unit, resolve(prelude)); const accumulators = unit.accumulators.map(entry => entry.local); const { signature: { resources, lookups, up, ordered, filtered, reduced }, columns, booleans, keys } = unit; /** Whether it runs a list of rows, which needn't be consecutive: `up`'s depth order, or an index's. */ const listed = up !== null || ordered; - const program = toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); + // WASM doesn't read components an archetype may lack yet, so such a kernel runs as JS. + const program = unit.optionalColumns.size + unit.presences.size > 0 && listed ? null : toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); if (target === "gpu") { if (lookups.length > 0) fail(declaration, "a GPU kernel can't use lookups yet"); if (filtered) fail(declaration, "a GPU kernel can't use change filters yet"); @@ -74,9 +83,12 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: if (program === null) fail(body, "a GPU kernel can't issue commands or use values that aren't numbers or booleans"); else if (loops(program.body)) fail(body, "a GPU kernel can't use loops or labeled blocks yet"); } - const strided = columns.flatMap((_, index) => unit.chunks.has(index) || unit.blocks.has(index) ? [] : [index]); + // Chunk, block and presence columns aren't read at a row's element. + const strided = columns.flatMap((column, index) => unit.chunks.has(index) || unit.blocks.has(index) || column.startsWith("{ kind: \"present\"") ? [] : [index]); const locals = [ ...columns.map((_, index) => `const c${index} = columns[${index}];`), + // Whether this call's archetype has each optional component, whose column is empty where it doesn't. + ...[...unit.presences.values()].map(index => `const q${index} = c${index}.length !== 0;`), ...resources.map((_, index) => `const r${index} = resources[${index}];`), ...lookups.map((_, index) => `const l${index} = lookups[${index}];`), ...unit.spawns.map((_, index) => `const spawn${index} = commands.spawners[${index}];`), @@ -86,6 +98,15 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: ...unit.upColumns.map((_, index) => `const u${index} = upColumns[${index}];`), ], ]; + const presences = [...unit.presences.values()]; + /** The row loop around a body. */ + const loop = (body: string): string => [ + listed + ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, { blocks, when }]) => `${when === null ? "" : `if (q${when}) `}{ c${blocks}[i >> ${blockShift}] = tick; c${chunks}[i >> ${chunkShift}] = tick; }`)].join(" ")}` + : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, + body, + "}", + ].join("\n"); return { description: url => [ "{", @@ -112,8 +133,11 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: // Per-registration constants live in the closure. Each column's element index `i * s + o` is strength-reduced to // an induction variable in the loop update, which `continue rows` still runs. create: [ - "(booleans, strides, offsets, upBooleans, upStrides, upOffsets) => {", + "(booleans, strides, offsets, upBooleans, upStrides, upOffsets, labels) => {", ...[...booleans].map(index => `const b${index} = booleans[${index}];`), + ...unit.optionalColumns.size + unit.presences.size === 0 ? [] : [ + "const absent = k => { throw new Error(`An entity lacks \"${labels[k]}\", optional in its system: add it with commands.add rather than assigning it.`); };", + ], ...[...unit.upBooleans].map(index => `const ub${index} = upBooleans[${index}];`), ...unit.upColumns.map((_, index) => `const us${index} = upStrides[${index}], uo${index} = upOffsets[${index}];`), ...strided.map(index => `const s${index} = strides[${index}], o${index} = offsets[${index}];`), @@ -124,15 +148,15 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: preludeCode, ...accumulators.map((local, k) => `let ${localName(unit, local)} = partials[partial + ${k}];`), // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own block and chunk. - ...listed ? [] : [...unit.stampedPerCall].flatMap(([chunks, blocks]) => [ - `if (start < end) for (let k = start >> ${blockShift}, last = (end - 1) >> ${blockShift}; k <= last; k++) c${blocks}[k] = tick;`, - `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, + ...listed ? [] : [...unit.stampedPerCall].flatMap(([chunks, { blocks, when }]) => [ + `if (${when === null ? "" : `q${when} && `}start < end) for (let k = start >> ${blockShift}, last = (end - 1) >> ${blockShift}; k <= last; k++) c${blocks}[k] = tick;`, + `if (${when === null ? "" : `q${when} && `}start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, ]), - listed - ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, blocks]) => `c${blocks}[i >> ${blockShift}] = tick; c${chunks}[i >> ${chunkShift}] = tick;`)].join(" ")}` - : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, - kernel, - "}", + ...specialized === null ? [loop(kernel)] : [ + `if (${presences.map(index => `q${index}`).join(" && ")}) {`, loop(specialized[0]), + `} else if (${presences.map(index => `!q${index}`).join(" && ")}) {`, loop(specialized[1]), + "} else {", loop(kernel), "}", + ], ...accumulators.map((local, k) => `partials[partial + ${k}] = ${localName(unit, local)};`), "};", "}", diff --git a/packages/ecs/src/implementation/compiler/expression.ts b/packages/ecs/src/implementation/compiler/expression.ts index fc3852a7..2696f8f5 100644 --- a/packages/ecs/src/implementation/compiler/expression.ts +++ b/packages/ecs/src/implementation/compiler/expression.ts @@ -11,6 +11,7 @@ import { memberKey } from './key.js'; import { moduleValue } from './module-value.js'; import type { Out } from './out.js'; import { scalar } from './scalar.js'; +import { presence } from './columns.js'; import { findLocal, rootScope, type Scope } from './scope.js'; import { fresh } from './unit.js'; import { scalarValue, type Value } from './value.js'; @@ -66,6 +67,21 @@ const literal = (node: Extract): Value => { }; /** Compiles an expression to a value, emitting any statements it needs into `out`. */ +/** + * `value === undefined` and its variants, `== null` included, for an optional component's whole value: whether the + * call's archetype lacks it, or has it. Null for anything else. + */ +const presenceTest = (operator: string, value: Value, other: Value, scope: Scope): Expression | null => { + if (!["===", "!==", "==", "!="].includes(operator) || other.kind !== "scalar" || other.expression.kind !== "nullish") return null; + if (other.expression.value === "null" && (operator === "===" || operator === "!==")) return null; + const { unit } = scope; + const present = value.kind === "snapshot" && value.path.length === 0 ? value.snapshot.presence + : value.kind === "component" && value.up !== true && value.path.length === 0 && unit.signature.optional.has(value.component) ? presence(unit, value.component) + : null; + if (present === null) return null; + return operator === "!==" || operator === "!=" ? present : not(present); +}; + export const expression = (node: Node, scope: Scope, out: Out): Value => { switch (node.type) { case "Literal": @@ -96,8 +112,13 @@ export const expression = (node: Node, scope: Scope, out: Out): Value => { case "BinaryExpression": { const operator = node.operator; if (!isBinary(operator) || node.left.type === "PrivateIdentifier") return fail(node, `${node.operator} is not in the kernel subset`); - const left = scalar(expression(node.left, scope, out), node.left, scope); - const right = scalar(expression(node.right, scope, out), node.right, scope); + const leftValue = expression(node.left, scope, out); + const rightValue = expression(node.right, scope, out); + // An optional component tested for undefined is its archetype's presence, the same for every row of a call. + const tested = presenceTest(operator, leftValue, rightValue, scope) ?? presenceTest(operator, rightValue, leftValue, scope); + if (tested !== null) return scalarValue(tested); + const left = scalar(leftValue, node.left, scope); + const right = scalar(rightValue, node.right, scope); // Strict equality tells true from 1, so boolean reads are converted here. const strict = operator === "===" || operator === "!=="; return scalarValue(strict diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts index 9297db5a..1e16bcbc 100644 --- a/packages/ecs/src/implementation/compiler/inferred.ts +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -142,7 +142,7 @@ export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): properties.push(property("components", object([ property("read", array([...read].filter(key => !written.has(key)))), property("write", array(written)), - ...keep(parts, ["with", "not", "added", "changed"]), + ...keep(parts, ["with", "optional", "not", "added", "changed"]), ]))); continue; } diff --git a/packages/ecs/src/implementation/compiler/ir.ts b/packages/ecs/src/implementation/compiler/ir.ts index 3a2f53b7..d23f88e0 100644 --- a/packages/ecs/src/implementation/compiler/ir.ts +++ b/packages/ecs/src/implementation/compiler/ir.ts @@ -23,6 +23,8 @@ export type Expression = | { readonly kind: "number"; readonly value: number } | { readonly kind: "boolean"; readonly value: boolean } | { readonly kind: "column"; readonly column: number } + /** Whether this call's archetype has an optional component, by its presence column: the same for every row. */ + | { readonly kind: "present"; readonly column: number } /** A column of the row's parent, at the parent's row: `Unit.upColumns`. */ | { readonly kind: "upColumn"; readonly column: number } | { readonly kind: "resource"; readonly index: number; readonly path: Path } diff --git a/packages/ecs/src/implementation/compiler/print.ts b/packages/ecs/src/implementation/compiler/print.ts index 90f6514e..4f73d970 100644 --- a/packages/ecs/src/implementation/compiler/print.ts +++ b/packages/ecs/src/implementation/compiler/print.ts @@ -4,6 +4,7 @@ import type { Expression, Statement } from './ir.js'; import { resolve, type Out } from './out.js'; import { rowLabel } from './statement.js'; import type { Unit } from './unit.js'; +import { presence } from './columns.js'; /** A number as JS source that reads back as the same f64. */ export const printNumber = (value: number): string => @@ -11,6 +12,9 @@ export const printNumber = (value: number): string => const printPath = (path: Path): string => path.map(item => `[${JSON.stringify(item)}]`).join(""); +/** The presence of the optional component the key at `key` names. */ +const presenceOf = (unit: Unit, key: number): Expression => presence(unit, unit.keys[key] ?? ""); + export const localName = (unit: Unit, local: number): string => `${unit.names.get(local) ?? "t"}_${local}`; /** Prints an expression. `observed` converts boolean column reads. */ @@ -21,10 +25,16 @@ export const printExpression = (unit: Unit, expression: Expression, observed: bo case "boolean": return String(expression.value); case "column": { const column = expression.column; - if (!observed) return `c${column}[x${column}]`; - unit.booleans.add(column); - return `(b${column} ? c${column}[x${column}] !== 0 : c${column}[x${column}])`; + // In the loop for archetypes without every optional component, theirs read undefined without a load. + if (unit.presentAs === false && unit.optionalColumns.has(column)) return "undefined"; + // An optional component's column is empty in an archetype without it, where it reads undefined. + const read = observed + ? (unit.booleans.add(column), `(b${column} ? c${column}[x${column}] !== 0 : c${column}[x${column}])`) + : `c${column}[x${column}]`; + const key = unit.optionalColumns.get(column); + return key !== undefined && observed ? `(${print(presenceOf(unit, key))} ? ${read} : undefined)` : read; } + case "present": return unit.presentAs === null ? `q${expression.column}` : String(unit.presentAs); case "upColumn": { const column = expression.column; const read = `u${column}[p * us${column} + uo${column}]`; @@ -66,7 +76,15 @@ const printStatement = (unit: Unit, statement: Statement, labels: Labels): strin return `${declare}${localName(unit, statement.local)} = ${printExpression(unit, statement.value, unit.observed.has(statement.local))};`; } case "declare": return `let ${statement.locals.map(item => localName(unit, item)).join(", ")};`; - case "store": return `c${statement.column}[x${statement.column}] = ${value(statement.value)};`; + case "store": { + const { column } = statement; + const store = `c${column}[x${column}] = ${value(statement.value)};`; + // Assigning an optional component an entity lacks fails its system, as a body run as written does. + const key = unit.optionalColumns.get(column); + if (key === undefined) return store; + const present = printExpression(unit, presenceOf(unit, key), false); + return present === "true" ? store : present === "false" ? `absent(${key});` : `if (${present}) ${store} else absent(${key});`; + } case "stamp": { const row = `c${statement.column}[x${statement.column}] = tick;`; return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.blocks}[i >> ${blockShift}] = tick;\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; diff --git a/packages/ecs/src/implementation/compiler/program.ts b/packages/ecs/src/implementation/compiler/program.ts index 790af996..f0d566a3 100644 --- a/packages/ecs/src/implementation/compiler/program.ts +++ b/packages/ecs/src/implementation/compiler/program.ts @@ -109,6 +109,8 @@ export const toProgram = (statements: readonly Statement[], nameOf?: (local: num switch (node.kind) { case "number": case "boolean": case "column": case "resource": case "args": return node; + // WASM runs only archetypes with every optional component; the others run the JS kernel. + case "present": return { kind: "boolean", value: true }; // JS throws reading a parent that isn't there, so WASM reads one only where a test proves it is. case "upColumn": return guards.has(upHandle) ? { kind: "upLeaf", column: node.column } : undefined; case "local": return { kind: "local", local: localIndex(node.local) }; diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 30195c96..3ea6c560 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -27,6 +27,8 @@ export type Signature = { readonly write: ReadonlySet; /** What it requires of its rows, its `with`. */ readonly has: ReadonlySet; + /** Components its rows may lack, its `optional`: an archetype without one gives the kernel an empty column. */ + readonly optional: ReadonlySet; readonly resources: readonly string[]; readonly reduced: readonly Reduced[]; readonly lookups: readonly Lookup[]; @@ -108,8 +110,7 @@ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => for (const property of declaration.properties) { if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { const { value } = property; - const required = value.type === "Literal" && (value.value === true || value.value === "gpu"); - if (required && optional !== null) return fail(optional, "a kernel can't have optional components yet: list them in with, or use kernel: false to run the body as written"); + if (value.type === "Literal" && value.value === "gpu" && optional !== null) return fail(optional, "a GPU kernel can't have optional components yet: list them in with, or use kernel: true to run it on the CPU"); if (value.type === "Literal" && value.value === true) return { target: "cpu", required: true }; if (value.type === "Literal" && value.value === "gpu") return { target: "gpu", required: true }; if (value.type === "Literal" && value.value === false) return null; @@ -117,8 +118,6 @@ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => } } if (declaration.properties.some(property => property.type === "SpreadElement")) return null; - // Kernels don't read components an archetype may lack yet, so such a system runs as written. - if (optional !== null) return null; if (!declares(declaration, "components") || declares(declaration, "observer") || declares(declaration, "target")) return null; return { target: "cpu", required: false }; }; @@ -135,7 +134,7 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li for (const [name, property] of properties(declaration, "a kernel declaration")) { switch (name) { case "components": - components = keyLists(property.value, "components", ["read", "write", "with", "not", "added", "changed"], lists); + components = keyLists(property.value, "components", ["read", "write", "with", "optional", "not", "added", "changed"], lists); break; case "resources": for (const [list, value] of properties(property.value, "resources")) { @@ -197,5 +196,5 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li if (both !== undefined) fail(declaration, `${both} is in both components read and write; write already makes it readable`); const rowFiltered = (components.get("added")?.length ?? 0) + (components.get("changed")?.length ?? 0) > 0; if (up !== null && ordered) fail(declaration, "a kernel can't use both up and order, which both decide the order of its rows"); - return { read: new Set(components.get("read")), write: new Set(write), has: new Set(components.get("with")), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; + return { read: new Set(components.get("read")), write: new Set(write), has: new Set(components.get("with")), optional: new Set(components.get("optional")), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; }; diff --git a/packages/ecs/src/implementation/compiler/snapshot.ts b/packages/ecs/src/implementation/compiler/snapshot.ts index 89fc66e3..41ba9980 100644 --- a/packages/ecs/src/implementation/compiler/snapshot.ts +++ b/packages/ecs/src/implementation/compiler/snapshot.ts @@ -13,10 +13,12 @@ export type Snapshot = { readonly hint: string; readonly read: (path: Path) => Expression; readonly leaves: Map; + /** Of an optional component's whole value: whether the call's archetype has it, which tests for undefined read. */ + readonly presence: Expression | null; }; -export const createSnapshot = (keyword: "const" | "let", hint: string, read: (path: Path) => Expression): Snapshot => - ({ keyword, hint, read, leaves: new Map() }); +export const createSnapshot = (keyword: "const" | "let", hint: string, read: (path: Path) => Expression, presence: Expression | null = null): Snapshot => + ({ keyword, hint, read, leaves: new Map(), presence }); /** The local holding one leaf of `snapshot`. */ export const snapshotLeaf = (unit: Unit, snapshot: Snapshot, path: Path): number => { diff --git a/packages/ecs/src/implementation/compiler/stamps.ts b/packages/ecs/src/implementation/compiler/stamps.ts index 28b1e579..23c48dc6 100644 --- a/packages/ecs/src/implementation/compiler/stamps.ts +++ b/packages/ecs/src/implementation/compiler/stamps.ts @@ -11,15 +11,19 @@ const exits = (statement: Statement): boolean => { } }; +/** A chunk column stamped once per call: its block column, and the presence column the call's stamp depends on, if any. */ +export type PerCall = { readonly blocks: number; readonly when: number | null }; + /** - * Chunk columns stamped on every row, with their block columns: by a top-level stamp that no earlier `next` can skip. - * Every row in `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. + * Chunk columns stamped on every row, with their block columns: by a top-level stamp that no earlier `next` can skip, + * or one in a test of an optional component's presence, the same for every row of a call. Every row in + * `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. */ -export const unconditionalChunks = (rows: readonly Statement[]): ReadonlyMap => { - const chunks = new Map(); +export const unconditionalChunks = (rows: readonly Statement[], when: number | null = null, chunks = new Map()): ReadonlyMap => { for (const statement of rows) { if (exits(statement)) break; - if (statement.kind === "stamp") chunks.set(statement.chunks, statement.blocks); + if (statement.kind === "stamp" && !(chunks.get(statement.chunks)?.when === null)) chunks.set(statement.chunks, { blocks: statement.blocks, when }); + if (statement.kind === "if" && when === null && statement.test.kind === "present") unconditionalChunks(resolve(statement.consequent), statement.test.column, chunks); } return chunks; }; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 6d077f74..90fc9056 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -259,6 +259,7 @@ const run = (description: Compiled, rows: number, fields: Fields, options: Optio if (column.kind === "changed") return new Uint32Array(rows); if (column.kind === "chunks") return new Float64Array(Math.ceil(rows / 2 ** chunkShift)); if (column.kind === "blocks") return new Float64Array(Math.ceil(rows / 2 ** blockShift)); + if (column.kind === "present") return new Float64Array(rows); const component = String(column.component); const values = fields[component]?.[JSON.stringify(column.path)] ?? new Array(rows).fill(0); const stride = interleaved[component]?.stride; @@ -354,10 +355,11 @@ describe("kernels by default", () => { expect(await attached(`const kernel = System.create({ resources: {} }, ({ resources }) => { void resources[delta]; });`)).toBe(false); expect(await attached(`const kernel = System.create({ kernel: false, components: { with: [mass, score] } }, row => { row[score] = row[mass]; });`)).toBe(false); }); - test("runs a system with optional components as written, and fails one that requires a kernel", async () => { - expect(await attached(`const kernel = System.create({ components: { with: [score], optional: [mass] } }, row => { const found = row[mass]; if (found !== undefined) row[score] = found; });`)).toBe(false); - expect(await failure(`const kernel = System.create({ kernel: true, components: { with: [score], optional: [mass] } }, row => { row[score] = 1; });`)) - .toBe("a kernel can't have optional components yet: list them in with, or use kernel: false to run the body as written"); + test("compiles a system with optional components, testing their presence once per call, and fails a GPU kernel with them", async () => { + expect(await attached(`const kernel = System.create({ components: { with: [score], optional: [mass] } }, row => { const found = row[mass]; if (found !== undefined) row[score] = found; });`)).toBe(true); + expect(await attached(`const kernel = System.create({ kernel: true, components: { with: [score], optional: [mass] } }, row => { if (row[mass] !== undefined) row[mass] = row[score]; });`)).toBe(true); + expect(await failure(`const kernel = System.create({ kernel: "gpu", components: { with: [score], optional: [mass] } }, row => { row[score] = 1; });`)) + .toBe("a GPU kernel can't have optional components yet: list them in with, or use kernel: true to run it on the CPU"); expect(await failure(`const kernel = System.create({ components: { with: [score] } }, row => { row[score] = row[mass]; });`)) .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); }); diff --git a/packages/ecs/src/implementation/compiler/unit.ts b/packages/ecs/src/implementation/compiler/unit.ts index 39024c76..dba2eea4 100644 --- a/packages/ecs/src/implementation/compiler/unit.ts +++ b/packages/ecs/src/implementation/compiler/unit.ts @@ -7,6 +7,7 @@ import type { Module } from './module-scope.js'; import type { Out } from './out.js'; import type { Scope } from './scope.js'; import type { Signature } from './signature.js'; +import type { PerCall } from './stamps.js'; import type { Value } from './value.js'; export type ExpressionCompiler = (node: Node, scope: Scope, out: Out) => Value; @@ -39,9 +40,15 @@ export type Unit = { * Chunk columns stamped once per call before the row loop, with their block columns, so their per-row stamps are * dropped. */ - readonly stampedPerCall: Map; + readonly stampedPerCall: Map; /** Columns read where a boolean's identity is observable, so they need conversion. */ readonly booleans: Set; + /** Columns of optional components, each by the index in `keys` of its component, which an archetype may lack. */ + readonly optionalColumns: Map; + /** Each optional component's presence column, by its binding name. */ + readonly presences: Map; + /** While a loop specialized for every optional component present, or every one absent, is printed: which; else null. */ + presentAs: boolean | null; /** Every value assigned to each local, to propagate observation. */ readonly assignments: Map; /** Locals whose value reaches an observable position. */ @@ -87,7 +94,7 @@ export const createUnit = (signature: Signature, module: Module, lists: Lists, e const up = signature.up === null ? null : counter++; if (up !== null) names.set(up, "up"); return { - signature, module, lists, link, target, tried, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), + signature, module, lists, link, target, tried, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), optionalColumns: new Map(), presences: new Map(), presentAs: null, assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), used: { read: new Set(), write: new Set(), resources: new Set(), lookups: new Map(), up: new Set() }, up, upColumns: [], upColumnIndex: new Map(), upBooleans: new Set(), probes: [], rowLeaves: [], handles: new Set(), next: () => counter++, expression, statement, diff --git a/packages/ecs/src/implementation/world/arrays.ts b/packages/ecs/src/implementation/world/arrays.ts index 57193994..197b159d 100644 --- a/packages/ecs/src/implementation/world/arrays.ts +++ b/packages/ecs/src/implementation/world/arrays.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { viewsOf, type Views } from '../../interface/registry/layout.js'; +import { viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; import type { Memory } from './memory.js'; export type ArrayConstructor = { @@ -42,3 +42,7 @@ export const freeViews = (memory: Memory, views: Views): void => freeArray(memor /** Views of no bytes, for components with nothing to store. */ export const emptyViews: Views = viewsOf(new ArrayBuffer(0), 0, 0); + +/** A kernel's column of a component an archetype lacks, optional in its system: empty, so its reads give undefined. */ +export const absentColumn = (layout: Layout, source: { readonly kind: string; readonly leaf?: number }): TypedArray => + source.kind === "field" && layout.kind === "linear" && source.leaf !== undefined ? emptyViews[layout.leaves[source.leaf].view] : emptyViews.f64; diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts index 9e11b8b8..7b131eda 100644 --- a/packages/ecs/src/implementation/world/check-kernel.ts +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -46,6 +46,7 @@ export const kernelMismatch = (leaf: Leaf, description: KernelDescription): stri if (!written.has(column.component)) return `writes "${labelOf(column.component)}", which isn't in its components' write.`; continue; } + if (column.kind === "present") continue; const layout = layoutOfKey(column.component); if (layout !== null && layout.kind !== "linear") return `"${labelOf(column.component)}" isn't stored in linear memory.`; if (!hasLeaf(layout, column.path)) return `"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`; diff --git a/packages/ecs/src/implementation/world/kernel.ts b/packages/ecs/src/implementation/world/kernel.ts index 0941afbc..171bc2c7 100644 --- a/packages/ecs/src/implementation/world/kernel.ts +++ b/packages/ecs/src/implementation/world/kernel.ts @@ -28,7 +28,9 @@ export type Column = * Float64Array of the component's newest changed tick per block of `2 ** blockShift` rows, so change filters skip * the unchanged blocks of a chunk that changed. A stamp sets all three: the row's tick, its chunk's and its block's. */ - | { readonly kind: "blocks"; readonly component: Key }; + | { readonly kind: "blocks"; readonly component: Key } + /** Of an optional component: non-empty in an archetype with it and empty in one without, so a kernel tests it once per call. */ + | { readonly kind: "present"; readonly component: Key }; /** Rows per chunk of change ticks is `2 ** chunkShift`. */ export const chunkShift = 10; @@ -256,5 +258,5 @@ export type KernelModule = { * Called once per registration. Per column `k`: `booleans[k]` is true when it stores booleans as 0 or 1, so reads convert them; * a row's element is at `row * strides[k] + offsets[k]`, in elements of the column's array. */ - readonly create: (booleans: readonly boolean[], strides: readonly number[], offsets: readonly number[], upBooleans?: readonly boolean[], upStrides?: readonly number[], upOffsets?: readonly number[]) => Kernel; + readonly create: (booleans: readonly boolean[], strides: readonly number[], offsets: readonly number[], upBooleans?: readonly boolean[], upStrides?: readonly number[], upOffsets?: readonly number[], labels?: readonly string[]) => Kernel; }; diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index e5723a5b..0efa9198 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -11,6 +11,7 @@ import { generateWasm, instantiateWasm, type Type, type WasmColumn, type WasmKer import { createDirectory, lookupParameters, type Directory } from './directories.js'; import { generateWgsl, type WgslKernel } from './wgsl.js'; import { labelOf } from '../../interface/registry/index.js'; +import { absentColumn, emptyViews } from './arrays.js'; import type { WorldIndex } from './indexes.js'; import { kernelIndexesOf, refreshSource, writeIndexParameters, type KernelIndex } from './kernel-indexes.js'; import { createIndexReader, createIndexSource, type IndexReaderSetup } from './index-readers.js'; @@ -40,6 +41,8 @@ export type BoundKernel = { /** By lookup through an index: its lookup, how its JS reader reads it, and how many components it watches. */ readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; readonly keys: readonly number[]; + readonly labels: readonly string[]; + readonly optional: readonly number[]; readonly starts: readonly unknown[]; /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ readonly spawns: readonly (readonly number[])[]; @@ -68,6 +71,8 @@ export type BoundKernel = { wasm: WasmKernel | null; /** The compiled module and the main thread's instance, once loaded. */ readonly compiled: () => { readonly module: WebAssembly.Module; readonly run: WasmRun } | null; + /** Whether an archetype has every optional component, the only archetypes its WASM runs: JS runs the rest. */ + readonly allPresent: (archetype: Archetype) => boolean; /** For a WASM kernel with lookups: the entity table and directories, passed ahead of the scalars. */ readonly lookupParameters: () => readonly number[]; /** The kernel as a compute shader for the components observed at the last load, or why it can't run on the GPU. */ @@ -105,7 +110,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript return column; } const id = idOf(state, column.component); - if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks") { + if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks" || column.kind === "present") { dense(); return { kind: column.kind, id, leaf: 0 }; } @@ -155,6 +160,8 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript })), indexes: indexes.flatMap(bound => bound === null ? [] : [{ lookup: bound.lookup, reader: bound.reader, watched: bound.watched.length }]), keys, + labels: keys.map(id => labelOf(state.keys[id])), + optional: ids(leaf.declaration.components?.optional), starts: keys.map(id => state.starts[id]), spawns, }; @@ -177,13 +184,18 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript } return value; }; + const optional = new Set(setup.optional); const cache = new Map(); const columnsOf = (archetype: Archetype): readonly TypedArray[] => { const cached = cache.get(archetype); if (cached !== undefined && cached.version === archetype.version) return cached.columns; const columns = sources.map(source => { if (source.kind === "entity") return archetype.entities; + // An optional component's presence: any column that has rows where the archetype has it, empty where it doesn't. + if (source.kind === "present") return archetype.storageOf.has(source.id) ? archetype.entities : emptyViews.f64; const storage = archetype.storageOf.get(source.id); + // An optional component's columns are empty where the archetype lacks it: reads give undefined. + if (storage === undefined && optional.has(source.id)) return absentColumn(state.layouts[source.id], source); if (storage === undefined) return fail(`column of "${labelOf(state.keys[source.id])}" isn't in its components.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; @@ -277,6 +289,10 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript }, upColumnsOf, wasm: generate(muted), + allPresent: archetype => { + for (const id of setup.optional) if (!archetype.storageOf.has(id)) return false; + return true; + }, compiled: () => compiled, kernel: () => loaded ?? fail("its module isn't loaded."), stale: () => { @@ -296,7 +312,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript bound.setup = { ...setup, muted, up: upSetup }; loadedFor = muted.join(); const module: KernelModule = await import(/* @vite-ignore */ description.url); - loaded = module.create(booleans, strides.map((stride, k) => muted[k] ? 0 : stride), offsets.map((offset, k) => muted[k] ? 0 : offset), upSetup.booleans, upSetup.strides, upSetup.offsets); + loaded = module.create(booleans, strides.map((stride, k) => muted[k] ? 0 : stride), offsets.map((offset, k) => muted[k] ? 0 : offset), upSetup.booleans, upSetup.strides, upSetup.offsets, setup.labels); compiled = null; if (bound.wasm === null) return; try { diff --git a/packages/ecs/src/implementation/world/optional-workers.cases.ts b/packages/ecs/src/implementation/world/optional-workers.cases.ts new file mode 100644 index 00000000..1a90b931 --- /dev/null +++ b/packages/ecs/src/implementation/world/optional-workers.cases.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./optional-workers.fixture.js"; + +/** Optional components in kernels on workers: the cases, over the fixture built for node. */ +export const optionalWorkersCases = (load: () => Promise) => describe("optional components in kernels on workers", () => { + for (const wasm of [false, true]) { + it(`read undefined in archetypes that lack them, and their values where they don't (${wasm ? "WASM" : "JS"})`, async () => { + const fixture = await load(); + expect(await fixture.movedOnWorkers(wasm)).toEqual({ moved: 200 * 15, still: 200 }); + }); + it(`fail their system where a kernel assigns one an entity lacks (${wasm ? "WASM" : "JS"})`, async () => { + const fixture = await load(); + const failures = await fixture.assignedOnWorkers(wasm); + expect(failures.length).toBeGreaterThan(0); + expect(new Set(failures)).toEqual(new Set([`An entity lacks "optionalWorkersTest/velocity", optional in its system: add it with commands.add rather than assigning it.`])); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/optional-workers.fixture.ts b/packages/ecs/src/implementation/world/optional-workers.fixture.ts new file mode 100644 index 00000000..33c54691 --- /dev/null +++ b/packages/ecs/src/implementation/world/optional-workers.fixture.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +/** Kernels with an optional component on workers, over archetypes that have it and one that doesn't. */ + +const position = Symbol("optionalWorkersTest/position"); +const velocity = Symbol("optionalWorkersTest/velocity"); +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } +} + +const rows = 200; +const spawn = System.create(({ commands }) => { + for (let i = 0; i < rows; i++) { + commands.spawn([position])({ [position]: 1 }); + commands.spawn([position, velocity])({ [position]: 10, [velocity]: 5 }); + } +}); +const move = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); +const assign = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; +}); +const totals = { moved: 0, still: 0 }; +const sum = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + if (row[velocity] === undefined) totals.still += row[position]; + else totals.moved += row[position]; +}); + +const world = (wasm: boolean) => createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm }); + +/** The positions of rows with a velocity and without, once the kernel moved them on workers. */ +export const movedOnWorkers = async (wasm: boolean): Promise<{ readonly moved: number; readonly still: number }> => { + await using moving = world(wasm); + await moving.run(spawn); + await moving.run(move); + totals.moved = totals.still = 0; + await moving.run(sum); + return { ...totals }; +}; + +/** What the run of a kernel assigning the optional component on workers failed with. */ +export const assignedOnWorkers = async (wasm: boolean): Promise => { + await using assigning = world(wasm); + await assigning.run(spawn); + return (await runReported(assigning, assign)).failures.map(failure => failure.message); +}; diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts index 0ac93a9f..a06474ac 100644 --- a/packages/ecs/src/implementation/world/optional.test.ts +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -36,6 +36,15 @@ const move = System.create({ components: { with: [position], optional: [velocity if (speed !== undefined) row[position] += speed; }); +/** As `move`, asserted a kernel. */ +const moveKernel = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); +const writeKernel = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; +}); + let seen: (readonly [number, number | undefined, string | undefined])[] = []; const read = System.create({ observer: true, components: { with: [position], optional: [velocity, label] } }, row => { seen.push([row[position], row[velocity], row[label]]); @@ -147,7 +156,24 @@ describe("optional components", () => { await world.dispose(); }); - it("run as written, since kernels don't read components an archetype may lack yet", () => { - expect(kernelOf(move)).toBeUndefined(); + it("compile to kernels, which read undefined where an archetype lacks one, as a body run as written does", async () => { + expect(kernelOf(move)).toBeDefined(); + expect(kernelOf(moveKernel)).toBeDefined(); + for (const system of [move, moveKernel]) { + await using world = inline(); + await world.run(spawn); + await world.run(system); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [15, 5, undefined], [150, 50, "c"]]); + } }); + + it("fail a kernel's system where it assigns one an entity lacks", async () => { + await using world = inline(); + await world.run(spawn); + const report = await runReported(world, writeKernel); + expect(report.failures.map(failure => failure.message)).toEqual([`An entity lacks "optionalTest/velocity", optional in its system: add it with commands.add rather than assigning it.`]); + }); + }); diff --git a/packages/ecs/src/implementation/world/protocol.ts b/packages/ecs/src/implementation/world/protocol.ts index fcf38496..af4c25cb 100644 --- a/packages/ecs/src/implementation/world/protocol.ts +++ b/packages/ecs/src/implementation/world/protocol.ts @@ -5,7 +5,7 @@ import type { WasmScalar } from './wasm.js'; import type { IndexReaderSetup } from './index-readers.js'; /** Where a kernel column comes from in any archetype it matches. */ -export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks" | "blocks"; readonly id: number; readonly leaf: number }; +export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks" | "blocks" | "present"; readonly id: number; readonly leaf: number }; /** Component ids of one lookup, and for a lookup by entity with `changed`, those it watches. */ export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; @@ -24,6 +24,10 @@ export type KernelSetup = { readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; /** Component ids, used as `keys` so command values name components by id. */ readonly keys: readonly number[]; + /** By key index, each component's label, which a failing write of an optional component names. */ + readonly labels: readonly string[]; + /** Optional components' ids: an archetype without one gives its columns empty. */ + readonly optional: readonly number[]; /** By key index: what each component stored in linear memory starts at, for a spawn leaving it out; null for the rest, whose start the world resolves. */ readonly starts: readonly unknown[]; /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 527627f5..5851b980 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -483,7 +483,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runKernelLocally = (bound: BoundKernel, archetype: Archetype, since: number, values: readonly unknown[], args: unknown, tick: number): void => { const slot = partialCount === 0 ? 0 : takeSlots(1); - if (bound.compiled() !== null && bound.wasm !== null) return runWasm(bound, archetype, 0, archetype.count, since, values, args, tick, partialAddress(slot)); + if (bound.compiled() !== null && bound.wasm !== null && bound.allPresent(archetype)) return runWasm(bound, archetype, 0, archetype.count, since, values, args, tick, partialAddress(slot)); const columns = bound.columnsOf(archetype); const filters = filtersOf(archetype); const run = bound.kernel(); @@ -681,7 +681,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { pool } = workers; const bound = kernel; const scalars = bound !== null && bound.wasm !== null ? [...bound.lookupParameters(), ...indexParameters, ...scalarValues(bound.wasm.scalars, values, args)] : []; - const fast = isFast(bound); + const fast = isFast(bound) && bound !== null && bound.allPresent(archetype); const participants = Math.min(pool.size, Math.ceil(count / minimumFor(workers))); const aligned = (rows: number): number => Math.ceil(rows / chunkAlignment) * chunkAlignment; // WASM chunks go through shared memory. The main thread doesn't take one: measured, it finishes later than an diff --git a/packages/ecs/src/implementation/world/threads.test.ts b/packages/ecs/src/implementation/world/threads.test.ts index 3581f207..4faaa131 100644 --- a/packages/ecs/src/implementation/world/threads.test.ts +++ b/packages/ecs/src/implementation/world/threads.test.ts @@ -48,6 +48,8 @@ import type * as ObserverJoined from "./observer-joined.fixture.js"; import { observerJoinedCases } from "./observer-joined.cases.js"; import type * as ObserverContained from "./observer-contained.fixture.js"; import { observerContainedCases } from "./observer-contained.cases.js"; +import type * as OptionalWorkers from "./optional-workers.fixture.js"; +import { optionalWorkersCases } from "./optional-workers.cases.js"; // Real worker threads in node: the cases over fixtures `vitest.fixtures.ts` built, each typed as its source, which it's // built from. @@ -75,3 +77,4 @@ indexBuildCases((): Promise => builtFixture("index-build")); entityLookupWorkersCases((): Promise => builtFixture("entity-lookup-workers")); observerJoinedCases((): Promise => builtFixture("observer-joined")); observerContainedCases((): Promise => builtFixture("observer-contained")); +optionalWorkersCases((): Promise => builtFixture("optional-workers")); diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index 944a4afd..4541875e 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -5,7 +5,7 @@ import { createBlock, endBlock, locate, reserveFrom, tableLength, type Block } f import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; import { blockShift, chunkShift, type Kernel, type KernelCommands, type KernelModule } from './kernel.js'; import { layoutOf, viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; -import { emptyViews } from './arrays.js'; +import { absentColumn, emptyViews } from './arrays.js'; import { claimPiece } from './claims.js'; import { controlWords, idleKernel, lineWords, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; import { runWindow } from './worker-window.js'; @@ -269,7 +269,10 @@ const bind = (loaded: LoadedKernel, archetypeId: number, archetype: ArchetypeVie const columns = loaded.setup.sources.map((source, k) => { if (loaded.setup.muted[k]) return mutedColumn; if (source.kind === "entity") return archetype.entities; + if (source.kind === "present") return archetype.storages.has(source.id) ? archetype.entities : emptyViews.f64; const storage = archetype.storages.get(source.id); + // An optional component's columns are empty where the archetype lacks it: reads give undefined. + if (storage === undefined && loaded.setup.optional.includes(source.id)) return absentColumn(layouts[source.id], source); if (storage === undefined) throw new Error(`Archetype ${archetypeId} lacks component ${source.id}.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; @@ -343,7 +346,8 @@ const run = (message: RunMessage): void => { const { columns, filters, addresses } = boundOf(loaded, message.archetype); const { commands } = stagingOf(loaded); const { wasm } = loaded; - if (wasm !== null && loaded.setup.wasm !== null) { + // WASM runs only archetypes with every optional component; the JS kernel runs the rest. + if (wasm !== null && loaded.setup.wasm !== null && loaded.setup.optional.every(id => boundOf(loaded, message.archetype).views.storages.has(id))) { wasm(message.start, message.end, message.tick, message.lastRun, ...addresses, ...message.lookups, ...message.indexes, ...scalarValues(loaded.setup.wasm.scalars, message.resources, message.args), ...message.partials < 0 ? [] : [message.partials]); @@ -380,7 +384,7 @@ const load = async (setups: readonly KernelSetup[]): Promise => { const since: Since | null = setup.lookups.some(lookup => lookup.changed !== null) ? { value: 0 } : null; const lookups = setup.lookups.map((lookup, k): unknown => readers.find(found => found.lookup === k)?.reader ?? (lookup.changed === null || since === null ? createLookup(lookup) : createChangedLookup(lookup, lookup.changed, since))); - kernels.set(setup.index, { since, setup, kernel: loaded[i].module.create(setup.booleans, setup.strides.map((stride, k) => setup.muted[k] ? 0 : stride), setup.offsets.map((offset, k) => setup.muted[k] ? 0 : offset), setup.up.booleans, setup.up.strides, setup.up.offsets), wasm: loaded[i].wasm, lookups, readers, staging: null, bound: [] }); + kernels.set(setup.index, { since, setup, kernel: loaded[i].module.create(setup.booleans, setup.strides.map((stride, k) => setup.muted[k] ? 0 : stride), setup.offsets.map((offset, k) => setup.muted[k] ? 0 : offset), setup.up.booleans, setup.up.strides, setup.up.offsets, setup.labels), wasm: loaded[i].wasm, lookups, readers, staging: null, bound: [] }); }); }; diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 78a82a8f..f484801e 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -79,9 +79,9 @@ type Validate = readonly kernel?: false | { readonly "error: a system without components runs once, so there's nothing to compile": true }; } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) - // Kernels don't read components an archetype may lack yet. + // GPU kernels don't read components an archetype may lack yet. & (D extends { readonly components: { readonly optional: readonly [unknown, ...unknown[]] } } - ? { readonly kernel?: false | { readonly "error: a kernel can't have optional components yet": true } } : {}) + ? { readonly kernel?: boolean | { readonly "error: a GPU kernel can't have optional components yet": true } } : {}) & (D extends { readonly observer: true } ? Observer : {}) & (D extends { readonly target: string } ? Targeted : {}) & (D extends { readonly target: string } ? Property extends undefined ? { readonly target?: { readonly "error: a system on one entity runs on its row, so it needs components": true } } : {} : {}) diff --git a/packages/ecs/src/interface/system/optional.type-test.ts b/packages/ecs/src/interface/system/optional.type-test.ts index 3e27cd3b..2e8a9e4f 100644 --- a/packages/ecs/src/interface/system/optional.type-test.ts +++ b/packages/ecs/src/interface/system/optional.type-test.ts @@ -27,10 +27,11 @@ System.create({ components: { with: [position], optional: [velocity, pilot] } }, row[pilot] = Entity.none; }); -// Kernels don't read components an archetype may lack yet. +// A CPU kernel reads them as a body run as written does; GPU kernels don't read components an archetype may lack yet. +System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { void row[velocity]; }); System.create({ - // @ts-expect-error a kernel can't have optional components yet - kernel: true, + // @ts-expect-error a GPU kernel can't have optional components yet + kernel: "gpu", components: { with: [position], optional: [velocity] }, }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/perftest/optional.ts b/packages/ecs/src/perftest/optional.ts index 1703cec7..08bbd919 100644 --- a/packages/ecs/src/perftest/optional.ts +++ b/packages/ecs/src/perftest/optional.ts @@ -38,6 +38,15 @@ const raiseOptional = System.create({ kernel: false, components: { with: [height if (by !== undefined) row[height] += by; }); +/** As `raiseRequired` and `raiseOptional`, compiled to kernels. */ +const raiseRequiredKernel = System.create({ kernel: true, components: { with: [height, lift] } }, row => { + row[height] += row[lift]; +}); +const raiseOptionalKernel = System.create({ kernel: true, components: { with: [height], optional: [lift] } }, row => { + const by = row[lift]; + if (by !== undefined) row[height] += by; +}); + const seeded = async (lacking: boolean): Promise => { const world = createWorld({ [workers]: 0 }); await world.run(seed, { lacking }); @@ -72,4 +81,22 @@ export const optionalBenchmarks: readonly Benchmark[] = [ ...per, run: () => run(raiseOptional, true), }, + { + name: "with 100K kernel", + operation: "As `with 100K`, compiled to a kernel on the world's thread: the baseline for `optional 100K kernel`.", + ...per, + run: () => run(raiseRequiredKernel, false), + }, + { + name: "optional 100K kernel", + operation: "As `optional 100K`, compiled to a kernel: what an optional component costs a kernel's row.", + ...per, + run: () => run(raiseOptionalKernel, false), + }, + { + name: "optional half 100K kernel", + operation: "As `optional half 100K`, compiled to a kernel.", + ...per, + run: () => run(raiseOptionalKernel, true), + }, ]; From 57d38256a5966db387fbaf473a1646d3effb6d2e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:49:39 -0700 Subject: [PATCH 119/127] ecs: tails and observer systems on joined threads read optional components, rows of a snapshot bound by their archetype's Co-Authored-By: Claude Opus 5.5 --- packages/ecs/src/implementation/world/join.ts | 4 +- .../implementation/world/joined-observer.ts | 2 - .../ecs/src/implementation/world/joining.ts | 2 +- .../src/implementation/world/optional.test.ts | 32 +++++++++++++-- .../ecs/src/implementation/world/sharing.ts | 2 +- .../src/implementation/world/tails.cases.ts | 2 +- .../src/implementation/world/tails.fixture.ts | 2 + .../implementation/world/tails.test-helper.ts | 25 ++++++++--- .../ecs/src/implementation/world/tails.ts | 4 +- .../implementation/world/thread-systems.ts | 41 ++++++++++++++++--- 10 files changed, 90 insertions(+), 26 deletions(-) diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 5d39b0e5..0d338f8c 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -563,7 +563,7 @@ export const joinWorld = (port: Parameters[0], options: { readon }); } return { - components: components === undefined ? null : { read: readable(components.read), has: present(components.has), not: present(components.not), added: present(components.added), changed: readable(components.changed) }, + components: components === undefined ? null : { read: readable(components.read), has: present(components.has), optional: present(components.optional), not: present(components.not), added: present(components.added), changed: readable(components.changed) }, resources: resources === undefined ? null : { read: readable(resources.read), changed: present(resources.changed), is: readable(resources.is), not: readable(resources.not) }, lookup: lookups, }; @@ -573,8 +573,6 @@ export const joinWorld = (port: Parameters[0], options: { readon const localOf = (leaf: Leaf): Local => { const known = localsByLeaf.get(leaf); if (known !== undefined) return known; - // Its posts copy rows across archetypes, which don't tell an absent optional component from a present one. - if ((leaf.declaration.components?.optional?.length ?? 0) > 0) throw new Error(`Observer system "${leaf.name}" has optional components, which observer systems on joined threads don't support yet: run it on the world's thread, or use two systems.`); const declaration = describe(leaf); // A number of this thread's, never posted as a name: the declaration names it. const number = numbers.size; diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts index 83931948..6041509f 100644 --- a/packages/ecs/src/implementation/world/joined-observer.ts +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -215,8 +215,6 @@ export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: Obs } const { declaration } = leaf; const { components, resources } = declaration; - // Its posts copy rows across archetypes, which don't tell an absent optional component from a present one. - if ((components?.optional?.length ?? 0) > 0) throw new Error(`Observer system "${leaf.name}" has optional components, which observer systems on joined threads don't support yet: run it on the world's thread, or use two systems.`); const base = createRunner(state, leaf, since); const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; // Strings live in shared memory, as structs do. diff --git a/packages/ecs/src/implementation/world/joining.ts b/packages/ecs/src/implementation/world/joining.ts index a29fb4b3..dc2aca53 100644 --- a/packages/ecs/src/implementation/world/joining.ts +++ b/packages/ecs/src/implementation/world/joining.ts @@ -34,7 +34,7 @@ export type ObserverLookup = { /** What an observer system a joined thread declares reads, by that thread's numbers for its keys. */ export type ObserverDeclaration = { - readonly components: { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly added: readonly number[]; readonly changed: readonly number[] } | null; + readonly components: { readonly read: readonly number[]; readonly has: readonly number[]; readonly optional: readonly number[]; readonly not: readonly number[]; readonly added: readonly number[]; readonly changed: readonly number[] } | null; readonly resources: { readonly read: readonly number[]; readonly changed: readonly number[]; readonly is: readonly number[]; readonly not: readonly number[] } | null; readonly lookup: readonly ObserverLookup[]; }; diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts index a06474ac..1655541e 100644 --- a/packages/ecs/src/implementation/world/optional.test.ts +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -62,8 +62,17 @@ const there = System.create({ components: { with: [position, marker], optional: if (row[marker] === null) row[position] = row[velocity] ?? -1; }); -// An observer system there posts copies of rows, which optional components aren't in yet. -const watchThere = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { void row[velocity]; }); +// An observer system there reads copies of rows across archetypes, which tell which have the optional component. +let watched: (readonly [number, number | undefined])[] = []; +const watchThere = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + watched.push([row[position], row[velocity]]); +}); +// A tail there: it writes only its thread's objects, so it runs after the sync point, reading copies of rows. +const labelThere = System.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + row[marker] = { text: `${row[position]}:${row[velocity] ?? "none"}` }; +}); +const labels: string[] = []; +const readLabels = System.create({ components: { with: [marker] } }, row => { labels.push(row[marker]?.text ?? "null"); }); /** A world shared with a thread on this one, which claims the thread markers live on. */ const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote: ReturnType }> => { @@ -149,9 +158,24 @@ describe("optional components", () => { await world.dispose(); }); - it("aren't supported yet in observer systems on a joined thread", async () => { + it("are read by observer systems on a joined thread, undefined where an archetype lacks one", async () => { const { world, remote } = await joined(); - expect(() => remote.add(watchThere)).toThrow(`has optional components, which observer systems on joined threads don't support yet`); + await world.run(spawnMarked); + watched = []; + await remote.run(watchThere); + expect([...watched].sort((a, b) => a[0] - b[0])).toEqual([[1, undefined], [10, 5]]); + await remote.dispose(); + await world.dispose(); + }); + + it("are read by tails on a joined thread, undefined where an archetype lacks one", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + const report = await runReported(world, labelThere); + expect(report.warnings).toEqual([]); + labels.length = 0; + await world.run(readLabels); + expect(labels.sort()).toEqual(["10:5", "1:none"]); await remote.dispose(); await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index e22b4f63..5617f463 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -554,7 +554,7 @@ export const createSharing = (host: SharingHost) => { // Unnamed, so other systems' labels stay as they are; its own is the thread's. const declaration: Resolved = { observer: true, - ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, + ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["optional", components.optional], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, ...resources === null ? {} : { resources: listed([["read", resources.read], ["changed", resources.changed], ["is", resources.is], ["not", resources.not]]) }, ...declared.lookup.length === 0 ? {} : { lookup }, }; diff --git a/packages/ecs/src/implementation/world/tails.cases.ts b/packages/ecs/src/implementation/world/tails.cases.ts index 22fd7797..cde8dab7 100644 --- a/packages/ecs/src/implementation/world/tails.cases.ts +++ b/packages/ecs/src/implementation/world/tails.cases.ts @@ -7,7 +7,7 @@ export const tailsCases = (load: () => Promise) => describe("tai it("see one tick's values in every run while the world ticks on: in place, from a snapshot when late, and both across a switch at a chunk", async () => { const fixture = await load(); const seen = await fixture.continuous(); - expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000 }); + expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most, lacking: seen.lacking }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000, lacking: true }); expect(seen.late).toBeGreaterThan(0); expect(seen.switched).toBeGreaterThan(0); }, 30000); diff --git a/packages/ecs/src/implementation/world/tails.fixture.ts b/packages/ecs/src/implementation/world/tails.fixture.ts index 76ada07b..873ab255 100644 --- a/packages/ecs/src/implementation/world/tails.fixture.ts +++ b/packages/ecs/src/implementation/world/tails.fixture.ts @@ -81,6 +81,8 @@ const summary = async (worker: Worker) => { runs: seen.length, wrong: seen.reduce((sum, run) => sum + run.wrong, 0), labels: seen.reduce((sum, run) => sum + run.labels, 0), + // Rows of the archetype without `extra`, which every run reads as undefined. + lacking: seen.every(run => run.lacking * 2 === run.rows), fewest: Math.min(...seen.map(run => run.rows)), most: Math.max(...seen.map(run => run.rows)), late: reply.late, diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index 0f3e489d..b77edaa3 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -15,6 +15,8 @@ export const spawning = Symbol("tailsTest/spawning"); export const failAt = Symbol("tailsTest/failAt"); export const canvas = Symbol("tailsTest/canvas"); export const word = Symbol("tailsTest/word"); +/** On every other row: rows without it are in an archetype of their own, so a tail's optional read of it tells them apart. */ +export const extra = Symbol("tailsTest/extra"); declare module "@adobe/ecs" { export interface Threads { tailsTest: true; @@ -24,6 +26,7 @@ declare module "@adobe/ecs" { [spot]: { type: Vec3 }; [label]: { type: { readonly text: string } | null; thread: "tailsTest"; default: null }; [word]: { type: string }; + [extra]: { type: Schema.Type<{ type: "integer" }> }; } export interface Resources { [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; @@ -59,9 +62,10 @@ export const count = System.create({}, ({ resources }) => { export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: every row's stamp, spot and word take the counter. */ -export const advance = System.create({ components: { with: [stamp, spot, word] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { with: [stamp, spot, word], optional: [extra] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; + if (row[extra] !== undefined) row[extra] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); @@ -72,12 +76,18 @@ export const reword = System.create({ components: { with: [word] } }, (row, _con /** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ export const grow = System.create({ after: [count] }, ({ resources, commands }) => { const value = resources[counter]; - for (let k = 0; k < resources[spawning]; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); + for (let k = 0; k < resources[spawning]; k++) { + if (k % 2 === 0) commands.spawn([stamp, spot, label, word, extra])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value), [extra]: value }); + else commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); + } resources[spawning] = 0; }); /** Spawns `count` rows. */ export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { - for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); + for (let k = 0; k < input.count; k++) { + if (k % 2 === 0) commands.spawn([stamp, spot, label, word, extra])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0), [extra]: 0 }); + else commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); + } }); export const setStall = System.create({}, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; @@ -90,7 +100,7 @@ export const setFailAt = System.create({}, ({ resources }, input: { readonly at: }); /** What each run of the watcher saw, by the counter it read: rows, and those whose structs, word or label weren't that tick's. */ -export type Seen = { counter: number; rows: number; wrong: number; labels: number }; +export type Seen = { counter: number; rows: number; wrong: number; labels: number; lacking: number }; export const seen: Seen[] = []; /** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ @@ -103,11 +113,14 @@ export const lead = System.create({}, ({ resources }) => { * must be the last tick's where it wrote one. */ export const watch = System.create({ - components: { with: [stamp, spot, word, label] }, after: [advance, grow, lead], + components: { with: [stamp, spot, word, label], optional: [extra] }, after: [advance, grow, lead], }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); - if (last === undefined || last.counter !== value) seen.push(last = { counter: value, rows: 0, wrong: 0, labels: 0 }); + if (last === undefined || last.counter !== value) seen.push(last = { counter: value, rows: 0, wrong: 0, labels: 0, lacking: 0 }); + const more = row[extra]; + if (more === undefined) last.lacking++; + else if (more !== value) last.wrong++; if (resources[failAt] === value) throw new Error(`tailsTest failure at ${value}`); const [x, y, z] = row[spot]; if (row[stamp] !== value || x !== value || y !== -value || z !== value || row[word] !== wordOf(value)) last.wrong++; diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 0ad30172..1931267e 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -33,15 +33,13 @@ const objectsOnly = (keys: readonly Key[] | undefined): boolean => /** * Whether a leaf is terminal: the plugin proved its body never reaches `commands`, and it writes only objects, which * live on its own thread, reduces into nothing, and looks nothing up, since a lookup reads the entity table the next - * sync point rewrites. Neither a kernel nor `up`, nor with optional components, which a tail's snapshot of rows across - * archetypes doesn't tell apart from present ones. + * sync point rewrites. Neither a kernel nor `up`. */ export const isTerminal = (leaf: Leaf): boolean => { const known = terminalLeaves.get(leaf); if (known !== undefined) return known; const { declaration } = leaf; const terminal = leaf.kind === "system" && kernelOf(leaf) === undefined && declaration.up === undefined && provenTerminal(leaf.body) - && (declaration.components?.optional?.length ?? 0) === 0 && Object.keys(declaration.lookup ?? {}).length === 0 && (declaration.resources?.reduce?.length ?? 0) === 0 && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 392db61f..f317793b 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -865,6 +865,8 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon // thread's, which it reads from its mirror at the snapshot's rows. const readKeys = query?.read ?? []; const writeKeys = query?.write ?? []; + /** Its optional components, which rows from a snapshot have only where their archetype does. */ + const optionalNumbers = (query?.optional ?? []).map(numberOf); const linearReads: { readonly number: number; readonly layout: Columned }[] = []; const constantReads: { readonly number: number; readonly layout: Layout }[] = []; for (const key of readKeys) { @@ -1016,13 +1018,42 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon } let first = 0; while (first < rows && positions[first] < position) first++; - const row = bindRow({ id: -1, entities, storageById, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); - const subject = row.value; + const copied = (storages: readonly (ThreadStorage | undefined)[]): ThreadArchetype => + ({ id: -1, entities, storageById: storages, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); const ownedInput = runInput; try { - for (let index = first; index < rows && !halted; index++) { - row.index = index; - system.body(subject, tailValue, ownedInput); + if (optionalNumbers.length === 0) { + const row = bindRow(copied(storageById)); + const subject = row.value; + for (let index = first; index < rows && !halted; index++) { + row.index = index; + system.body(subject, tailValue, ownedInput); + } + } + else { + // Rows of archetypes that lack optional components run in a row without them, a run of one archetype's rows + // at a time, and every archetype with the same ones shares its copy of the snapshot's columns. + const byPattern = new Map(); + for (let index = first; index < rows && !halted;) { + const id = archetypeIds[index]; + let end = index + 1; + while (end < rows && archetypeIds[end] === id) end++; + const components = host.archetypeAt(id)?.components ?? []; + let pattern = 0; + for (let k = 0; k < optionalNumbers.length; k++) if (components.includes(optionalNumbers[k])) pattern |= 1 << k; + let archetype = byPattern.get(pattern); + if (archetype === undefined) { + const storages: (ThreadStorage | undefined)[] = [...storageById]; + optionalNumbers.forEach((number, k) => { if ((pattern & (1 << k)) === 0) storages[number] = undefined; }); + byPattern.set(pattern, archetype = copied(storages)); + } + const row = bindRow(archetype); + const subject = row.value; + for (; index < end && !halted; index++) { + row.index = index; + system.body(subject, tailValue, ownedInput); + } + } } } finally { From 30f54ba7f054eac1df326c14ab9adb51decc189c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:53:51 -0700 Subject: [PATCH 120/127] ecs: lookups by entity take optional components, read where an entity has them and never required Co-Authored-By: Claude Opus 5.5 --- .../implementation/compiler/compile-kernel.ts | 3 +- .../src/implementation/compiler/inferred.ts | 6 +- .../src/implementation/compiler/signature.ts | 6 +- packages/ecs/src/implementation/world/join.ts | 2 +- .../ecs/src/implementation/world/joining.ts | 1 + .../ecs/src/implementation/world/kernels.ts | 2 +- .../src/implementation/world/optional.test.ts | 58 ++++++++++++++++++- .../ecs/src/implementation/world/protocol.ts | 2 +- packages/ecs/src/implementation/world/rows.ts | 5 +- .../ecs/src/implementation/world/sharing.ts | 2 +- .../implementation/world/thread-systems.ts | 12 +++- .../ecs/src/implementation/world/worker.ts | 9 +-- packages/ecs/src/interface/system/access.ts | 10 ++-- .../interface/system/optional.type-test.ts | 8 +++ packages/ecs/src/interface/system/resolve.ts | 6 +- packages/ecs/src/interface/system/values.ts | 4 +- 16 files changed, 109 insertions(+), 27 deletions(-) diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 141abd44..6f47e4f7 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -75,7 +75,8 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: /** Whether it runs a list of rows, which needn't be consecutive: `up`'s depth order, or an index's. */ const listed = up !== null || ordered; // WASM doesn't read components an archetype may lack yet, so such a kernel runs as JS. - const program = unit.optionalColumns.size + unit.presences.size > 0 && listed ? null : toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); + // WASM reads a lookup's rows only where every component it reads is there. + const program = (unit.optionalColumns.size + unit.presences.size > 0 && listed) || unit.signature.lookups.some(lookup => lookup.optional) ? null : toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); if (target === "gpu") { if (lookups.length > 0) fail(declaration, "a GPU kernel can't use lookups yet"); if (filtered) fail(declaration, "a GPU kernel can't use change filters yet"); diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts index 1e16bcbc..f84ce406 100644 --- a/packages/ecs/src/implementation/compiler/inferred.ts +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -62,9 +62,9 @@ export const unrequired = (used: Used, declaration: ObjectExpression): { readonl const parts = fields(valueOf(lookup)); const read = used.lookups.get(name); if (parts.has("index") || read === null || read === undefined) continue; - const required = literal([valueOf(parts.get("with")), valueOf(parts.get("changed"))]); + const required = literal([valueOf(parts.get("with")), valueOf(parts.get("optional")), valueOf(parts.get("changed"))]); const key = required === null ? undefined : [...read].find(key => !required.includes(key)); - if (key !== undefined) return { key, list: `lookup.${name}.with` }; + if (key !== undefined) return { key, list: `lookup.${name}.with or optional` }; } } return null; @@ -167,7 +167,7 @@ export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): const withList = valueOf(lookupParts.get("with")); // Rows the build didn't follow read everything the lookup requires. const read = reads === null ? withList ?? array([]) : array(reads); - return property(name, object([property("read", read), ...keep(lookupParts, ["with", "not", "changed"])])); + return property(name, object([property("read", read), ...keep(lookupParts, ["with", "optional", "not", "changed"])])); })))); continue; } diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 3ea6c560..27bc1184 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -10,7 +10,7 @@ import type { ModuleScope } from './module-scope.js'; * A lookup by entity, or through an index (`index`), whose calls take a key and give a list of rows. A lookup by * entity that `watches` declares `changed`: its result has `changed` after what it reads. */ -export type Lookup = { readonly name: string; readonly read: readonly string[]; readonly index: boolean; readonly watches: boolean }; +export type Lookup = { readonly name: string; readonly read: readonly string[]; readonly index: boolean; readonly watches: boolean; readonly optional: boolean }; /** The reference rows run down, and what the context's `up` reads of the entity it names. */ export type Up = { readonly through: string; readonly read: readonly string[] }; @@ -154,13 +154,13 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li const index = fields.some(([field]) => field === "index"); const listed = fields.filter(([field]) => field !== "index"); const read = listed.flatMap(([field, item]) => { - if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "with" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); + if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "with" && field !== "optional" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); // An index lookup's `changed: true` filters the system's runs, which the world does before calling it. if (index && field === "changed" && item.value.type === "Literal" && item.value.value === true) return []; const names = identifiers(item.value, `lookup.${lookup}.${field}`, lists); return field === "read" ? names : []; }); - lookups.push({ name: lookup, read, index, watches: !index && listed.some(([field]) => field === "changed") }); + lookups.push({ name: lookup, read, index, watches: !index && listed.some(([field]) => field === "changed"), optional: listed.some(([field]) => field === "optional") }); } break; case "up": { diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 0d338f8c..df31cbb5 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -558,7 +558,7 @@ export const joinWorld = (port: Parameters[0], options: { readon }; } lookups.push({ - name, read, has: present("has" in declared ? declared.has : undefined), not: present("not" in declared ? declared.not : undefined), + name, read, has: present("has" in declared ? declared.has : undefined), optional: present("optional" in declared ? declared.optional : undefined), not: present("not" in declared ? declared.not : undefined), changed: "changed" in declared && declared.changed !== undefined ? readable(watchedBy(declared)) : null, index: indexed, }); } diff --git a/packages/ecs/src/implementation/world/joining.ts b/packages/ecs/src/implementation/world/joining.ts index dc2aca53..6d6c9447 100644 --- a/packages/ecs/src/implementation/world/joining.ts +++ b/packages/ecs/src/implementation/world/joining.ts @@ -26,6 +26,7 @@ export type ObserverLookup = { readonly name: string; readonly read: readonly number[]; readonly has: readonly number[]; + readonly optional: readonly number[]; readonly not: readonly number[]; /** Null where it doesn't declare `changed`. */ readonly changed: readonly number[] | null; diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index 0efa9198..31224955 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -155,7 +155,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript strides, offsets, lookups: declaredLookups.map((declared): LookupSetup => ({ - read: ids(declared.read), has: ids("has" in declared ? declared.has : undefined), not: ids("not" in declared ? declared.not : undefined), + read: ids(declared.read), has: ids("has" in declared ? declared.has : undefined), optional: ids("optional" in declared ? declared.optional : undefined), not: ids("not" in declared ? declared.not : undefined), changed: "index" in declared || declared.changed === undefined ? null : ids(declared.changed), })), indexes: indexes.flatMap(bound => bound === null ? [] : [{ lookup: bound.lookup, reader: bound.reader, watched: bound.watched.length }]), diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts index 1655541e..341ad0b9 100644 --- a/packages/ecs/src/implementation/world/optional.test.ts +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Index, System, workers } from "../../index.js"; +import { Entity, Index, System, workers } from "../../index.js"; import { kernelOf } from "./attach-kernel.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; @@ -45,6 +45,36 @@ const writeKernel = System.create({ kernel: true, components: { with: [position] row[velocity] = row[position]; }); +/** Rows naming another entity, whose optional velocity a lookup reads. */ +const other = Symbol("optionalTest/other"); +declare module "@adobe/ecs" { + interface Components { + [other]: { type: Entity }; + } +} +let pointed: Entity[] = []; +const spawnPointing = System.create(({ commands }) => { + const still = commands.spawn([position])({ [position]: 1 }); + const moving = commands.spawn([position, velocity])({ [position]: 2, [velocity]: 7 }); + pointed = [still, moving]; + commands.spawn([other, order])({ [other]: still, [order]: 0 }); + commands.spawn([other, order])({ [other]: moving, [order]: 0 }); +}); +const copySpeed = System.create({ + kernel: false, components: { with: [other, order] }, lookup: { target: { with: [position], optional: [velocity] } }, +}, (row, { lookup }) => { + const found = lookup.target(row[other]); + if (found !== null) row[order] = found[velocity] === undefined ? -found[position] : found[velocity]; +}); +const copySpeedKernel = System.create({ + kernel: true, components: { with: [other, order] }, lookup: { target: { with: [position], optional: [velocity] } }, +}, (row, { lookup }) => { + const found = lookup.target(row[other]); + if (found !== null) row[order] = found[velocity] === undefined ? -found[position] : found[velocity]; +}); +const speeds: number[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [other, order] } }, row => { speeds.push(row[order]); }); + let seen: (readonly [number, number | undefined, string | undefined])[] = []; const read = System.create({ observer: true, components: { with: [position], optional: [velocity, label] } }, row => { seen.push([row[position], row[velocity], row[label]]); @@ -62,6 +92,10 @@ const there = System.create({ components: { with: [position, marker], optional: if (row[marker] === null) row[position] = row[velocity] ?? -1; }); +// On the markers' thread too, reading the row's own entity through a lookup with an optional component. +const lookedThere = System.create({ components: { with: [position, marker] }, lookup: { self: { with: [position], optional: [velocity] } } }, (row, { lookup }) => { + if (row[marker] === null) row[position] = lookup.self(row.entity)?.[velocity] ?? -1; +}); // An observer system there reads copies of rows across archetypes, which tell which have the optional component. let watched: (readonly [number, number | undefined])[] = []; const watchThere = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { @@ -158,6 +192,17 @@ describe("optional components", () => { await world.dispose(); }); + it("are read by lookups on a joined thread, undefined where the entity lacks one", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + await world.run(lookedThere); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[-1, undefined, undefined], [5, 5, undefined]]); + await remote.dispose(); + await world.dispose(); + }); + it("are read by observer systems on a joined thread, undefined where an archetype lacks one", async () => { const { world, remote } = await joined(); await world.run(spawnMarked); @@ -193,6 +238,17 @@ describe("optional components", () => { } }); + it("are read by lookups by entity, undefined where the entity lacks one, as written and in kernels", async () => { + for (const system of [copySpeed, copySpeedKernel]) { + await using world = inline(); + await world.run(spawnPointing); + await world.run(system); + speeds.length = 0; + await world.run(readSpeeds); + expect(speeds.sort((a, b) => a - b)).toEqual([-1, 7]); + } + }); + it("fail a kernel's system where it assigns one an entity lacks", async () => { await using world = inline(); await world.run(spawn); diff --git a/packages/ecs/src/implementation/world/protocol.ts b/packages/ecs/src/implementation/world/protocol.ts index af4c25cb..86453e8d 100644 --- a/packages/ecs/src/implementation/world/protocol.ts +++ b/packages/ecs/src/implementation/world/protocol.ts @@ -8,7 +8,7 @@ import type { IndexReaderSetup } from './index-readers.js'; export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks" | "blocks" | "present"; readonly id: number; readonly leaf: number }; /** Component ids of one lookup, and for a lookup by entity with `changed`, those it watches. */ -export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; +export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly optional: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; export type KernelSetup = { readonly index: number; diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 24fb8dc8..0ca9a443 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -326,6 +326,9 @@ const createMatcher = (state: State, lookup: SystemLookupLists) => { const read = ids(lookup.read); const has = ids("has" in lookup ? lookup.has : undefined); const not = ids("not" in lookup ? lookup.not : undefined); + // What it reads but its optional components, which an entity it finds may lack. + const optional = ids("optional" in lookup ? lookup.optional : undefined); + const required = read.filter(id => !optional.includes(id)); const matcher = { read, archetype: null as Archetype | null, @@ -335,7 +338,7 @@ const createMatcher = (state: State, lookup: SystemLookupLists) => { const archetype = state.archetypes[state.entities.archetype[index]]; if (archetype.resource) return -1; const { storageById } = archetype; - for (let i = 0; i < read.length; i++) if (storageById[read[i]] === undefined) return -1; + for (let i = 0; i < required.length; i++) if (storageById[required[i]] === undefined) return -1; for (let i = 0; i < has.length; i++) if (storageById[has[i]] === undefined) return -1; for (let i = 0; i < not.length; i++) if (storageById[not[i]] !== undefined) return -1; const row = state.entities.row[index]; diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index 5617f463..e1ebe112 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -543,7 +543,7 @@ export const createSharing = (host: SharingHost) => { const lookup: { [name: string]: SystemLookupLists } = {}; for (const each of declared.lookup) { if (each.index === null) { - lookup[each.name] = listed([["read", each.read], ["has", each.has], ["not", each.not], ["changed", each.changed]]); + lookup[each.name] = listed([["read", each.read], ["has", each.has], ["optional", each.optional], ["not", each.not], ["changed", each.changed]]); continue; } const index = typeof each.index === "string" ? indexDefinitionOf(itemOfId(each.index)) : madeIndex(thread, each.index); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index f317793b..698f5056 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -1176,8 +1176,15 @@ const localStorage = (component: number, layout: Layout, views: Views, values: u }; /** A lookup returning `read` values keyed by component, from this thread's views. */ +/** What a lookup reads and requires: its reads but its optional components, which an entity it finds may lack. */ +const requiredReads = (host: RunnerHost, lookup: SystemLookupLists): readonly number[] => { + const optional = "optional" in lookup ? lookup.optional ?? [] : []; + return (lookup.read ?? []).filter(key => !optional.includes(key)).map(host.numberOf); +}; + const lookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const read = (lookup.read ?? []).map(host.numberOf); + const required = requiredReads(host, lookup); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); const keys = lookup.read ?? []; @@ -1188,7 +1195,7 @@ const lookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const archetype = host.archetypeAt(table.archetype[index]); if (archetype === undefined || archetype.resource) return null; const { components } = archetype; - for (const id of read) if (!components.includes(id)) return null; + for (const id of required) if (!components.includes(id)) return null; for (const id of has) if (!components.includes(id)) return null; for (const id of not) if (components.includes(id)) return null; const row = table.row[index]; @@ -1225,6 +1232,7 @@ const emptyLookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { */ const changedLookupOf = (host: RunnerHost, lookup: SystemLookupLists, watched: readonly Key[], since: Since) => { const read = (lookup.read ?? []).map(host.numberOf); + const required = requiredReads(host, lookup); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); const ids = watched.map(host.numberOf); @@ -1237,7 +1245,7 @@ const changedLookupOf = (host: RunnerHost, lookup: SystemLookupLists, watched: r const archetype = host.archetypeAt(table.archetype[index]); if (archetype === undefined || archetype.resource) return null; const { components, storageById } = archetype; - for (const id of read) if (!components.includes(id)) return null; + for (const id of required) if (!components.includes(id)) return null; for (const id of has) if (!components.includes(id)) return null; for (const id of not) if (components.includes(id)) return null; const row = table.row[index]; diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index 4541875e..d221a033 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -207,7 +207,7 @@ const createChangedLookup = (lookup: LookupSetup, watched: readonly number[], si if (index < 0) return null; const archetype = archetypeAt(table.archetype[index]); if (archetype === undefined) return null; - for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.read) if (!archetype.storages.has(id) && !lookup.optional.includes(id)) return null; for (const id of lookup.has) if (!archetype.storages.has(id)) return null; for (const id of lookup.not) if (archetype.storages.has(id)) return null; const row = table.row[index]; @@ -215,7 +215,7 @@ const createChangedLookup = (lookup: LookupSetup, watched: readonly number[], si for (let i = 0; i < read.length; i++) { const layout = layouts[read[i]]; const storage = archetype.storages.get(read[i]); - values[i] = layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + values[i] = storage === undefined && !archetype.storages.has(read[i]) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; } let changed = since.value < 0; for (let i = 0; i < watched.length && !changed; i++) { @@ -233,14 +233,15 @@ const createLookup = (lookup: LookupSetup) => (entity: Entity): readonly unknown if (index < 0) return null; const archetype = archetypeAt(table.archetype[index]); if (archetype === undefined) return null; - for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.read) if (!archetype.storages.has(id) && !lookup.optional.includes(id)) return null; for (const id of lookup.has) if (!archetype.storages.has(id)) return null; for (const id of lookup.not) if (archetype.storages.has(id)) return null; const row = table.row[index]; return lookup.read.map(id => { const layout = layouts[id]; const storage = archetype.storages.get(id); - return layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + // An optional component the entity lacks reads undefined. + return !archetype.storages.has(id) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; }); }; diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts index 546caea6..f76896bb 100644 --- a/packages/ecs/src/interface/system/access.ts +++ b/packages/ecs/src/interface/system/access.ts @@ -20,14 +20,14 @@ export type ComponentAccess = { }; /** - * Read-only access to other entities: by entity, matching `with` and `not`, or through an index by key, whose rows - * hold what every entity the index holds has. What the body reads of them the build infers. A lookup by entity's + * Read-only access to other entities: by entity, matching `with` and `not`, its `optional` components read where an + * entity has them, or through an index by key, whose rows hold what every entity the index holds has. What the body reads of them the build infers. A lookup by entity's * `changed` names components in `with`, whose changes its row's `changed` reports, and an index lookup's those of its * rows. Both report as a system's `changed` filter does; its system can't write a key part or what it reads. */ export type Lookup = - | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } - | { readonly index: Index; readonly changed?: readonly Component.Key[]; readonly with?: never; readonly not?: never }; + | { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } + | { readonly index: Index; readonly changed?: readonly Component.Key[]; readonly with?: never; readonly optional?: never; readonly not?: never }; /** * A system's lookup. An index lookup's `changed: true` makes the index a change filter, as a resource in `changed` is: @@ -75,7 +75,7 @@ export type ComponentLists = { /** A lookup as the world takes it, with what its body reads of its rows in `read`. */ export type LookupLists = - | { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly not?: readonly Key[]; readonly changed?: readonly Key[] } + | { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[]; readonly changed?: readonly Key[] } | { readonly index: Index; readonly read?: readonly Key[]; readonly changed?: readonly Key[] }; export type SystemLookupLists = LookupLists | { readonly index: Index; readonly read?: readonly Key[]; readonly changed: true }; diff --git a/packages/ecs/src/interface/system/optional.type-test.ts b/packages/ecs/src/interface/system/optional.type-test.ts index 2e8a9e4f..7bc51c5d 100644 --- a/packages/ecs/src/interface/system/optional.type-test.ts +++ b/packages/ecs/src/interface/system/optional.type-test.ts @@ -41,3 +41,11 @@ System.create({ observer: true, components: { with: [position], optional: [veloc // @ts-expect-error an observer system's row is read-only row[velocity] = 1; }); + +// A lookup by entity's optional component is its value, or undefined where the entity lacks it. +System.create({ components: { with: [pilot] }, lookup: { pilots: { with: [position], optional: [velocity] } } }, (row, { lookup }) => { + const found = lookup.pilots(row[pilot]); + if (found === null) return; + check>(); + check>(); +}); diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts index 06f80306..ef98f0db 100644 --- a/packages/ecs/src/interface/system/resolve.ts +++ b/packages/ecs/src/interface/system/resolve.ts @@ -113,8 +113,10 @@ export const lookupLists = (lookup: SystemLookup | Lookup, reads: readonly Key[] const { changed } = lookup; return changed === true ? { index: lookup.index, read, changed } : compact({ index: lookup.index, read, changed }); } - const read = reads ?? lookup.with ?? []; - return compact({ read, has: without(lookup.with ?? [], read), not: lookup.not, changed: lookup.changed }); + // Its optional components are read where an entity has them and never required. + const optional = without(lookup.optional ?? [], lookup.with ?? []); + const read = reads ?? [...lookup.with ?? [], ...optional]; + return compact({ read, has: without(lookup.with ?? [], read), optional, not: lookup.not, changed: lookup.changed }); }; /** Each lookup's lists, by name. */ diff --git a/packages/ecs/src/interface/system/values.ts b/packages/ecs/src/interface/system/values.ts index 4d63f2b4..cd8f2bfe 100644 --- a/packages/ecs/src/interface/system/values.ts +++ b/packages/ecs/src/interface/system/values.ts @@ -63,7 +63,9 @@ type IndexLookup = * and true on a system's first run and in finalizers. With `changed`, valid until the lookup is called again. */ type EntityRow = { - readonly [K in (Listed & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: K extends Component.Key ? Component.Value : boolean + readonly [K in (Listed & Component.Key) | (Exclude, Listed> & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: + // Its `optional` components are their values where the entity has them, `undefined` where it doesn't. + K extends Component.Key ? K extends Listed ? Component.Value : Component.Value | undefined : boolean }; export type LookupFunction = L extends { readonly index: Index } From 089f847ab6fa9c0b1f91c6be4ee9fd9f754bb2d0 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 01:59:02 -0700 Subject: [PATCH 121/127] ecs: finalizers read optional components where the entity had them; stops still depend on with and not Co-Authored-By: Claude Opus 5.5 --- .../src/implementation/world/finalizers.ts | 9 ++-- .../src/implementation/world/optional.test.ts | 32 +++++++++++++- .../implementation/world/thread-finalizers.ts | 42 ++++++++++++++++--- .../ecs/src/interface/finalizer/create.ts | 6 ++- .../src/interface/finalizer/declaration.ts | 9 ++-- 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts index 19a4e5ae..83d85fb1 100644 --- a/packages/ecs/src/implementation/world/finalizers.ts +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -35,6 +35,8 @@ export type FinalizerRunner = { readonly target: { buffer: CommandBuffer }; readonly commands: Commands; readonly reads: readonly Key[]; + /** Its optional components, which its row reads where the entity had them. */ + readonly optional: readonly Key[]; /** The indexes its lookups go through, held for the world's life. */ readonly indexes: IndexLookups; /** Where a joined thread fills its thread: its stops go to that thread's stop lists, and it runs there. */ @@ -74,7 +76,7 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final for (const key of indexWatchesOf(declaration.lookup)) state.watchesPending.push(idOf(state, key)); return { finalizer, - required: [...new Set([...reads, ...query?.has ?? []])].map(key => idOf(state, key)), + required: [...new Set([...reads, ...query?.has ?? []])].filter(key => !(query?.optional ?? []).includes(key)).map(key => idOf(state, key)), excluded: (query?.not ?? []).map(key => idOf(state, key)), watches, matches: [], @@ -82,6 +84,7 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final target, commands, reads, + optional: query?.optional ?? [], indexes, remote: null, }; @@ -205,7 +208,7 @@ const finalizeStops = (state: State, runner: FinalizerRunner, leaf: Leaf, errors if (entries === undefined) byArchetype.set(from, entries = []); entries.push(entry); } - const bind = rowBinder(state, runner.reads, []); + const bind = rowBinder(state, runner.reads, [], runner.optional); for (const [archetype, stopped] of byArchetype) { const row = bind(archetype); for (const entry of stopped) { @@ -433,7 +436,7 @@ export const finalizeAll = (state: State): Thrown[] => { if (leaf.kind !== "finalizer" || runner.remote !== null) continue; const finalizer: Untyped = leaf; runner.context.bind(); - const bind = rowBinder(state, runner.reads, []); + const bind = rowBinder(state, runner.reads, [], runner.optional); for (const archetype of state.archetypes) { if (archetype.count === 0 || !matches(runner, archetype)) continue; const row = bind(archetype); diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts index 341ad0b9..c1e513f5 100644 --- a/packages/ecs/src/implementation/world/optional.test.ts +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Entity, Index, System, workers } from "../../index.js"; +import { Entity, Finalizer, Index, System, workers } from "../../index.js"; import { kernelOf } from "./attach-kernel.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; @@ -75,6 +75,17 @@ const copySpeedKernel = System.create({ const speeds: number[] = []; const readSpeeds = System.create({ observer: true, components: { with: [other, order] } }, row => { speeds.push(row[order]); }); +/** What finalizers read of rows that stopped, on the world's thread and on the markers'. */ +const stopped: string[] = []; +const stoppedThere: string[] = []; +export const finalizeHere = Finalizer.create({ components: { with: [position, order], optional: [velocity] } }, row => { + stopped.push(`${row[position]}:${row[velocity] ?? "none"}`); +}); +export const finalizeThere = Finalizer.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + stoppedThere.push(`${row[position]}:${row[velocity] ?? "none"}:${row[marker] === null ? "null" : "marked"}`); +}); +const despawnAll = System.create({ components: { with: [position] } }, (row, { commands }) => { commands.despawn(row.entity); }); + let seen: (readonly [number, number | undefined, string | undefined])[] = []; const read = System.create({ observer: true, components: { with: [position], optional: [velocity, label] } }, row => { seen.push([row[position], row[velocity], row[label]]); @@ -238,6 +249,25 @@ describe("optional components", () => { } }); + it("are read by finalizers where an entity had them, never deciding a stop", async () => { + await using world = inline(); + await world.run(spawn); + stopped.length = 0; + await world.run(despawnAll); + expect(stopped.sort()).toEqual(["100:50", "10:5", "1:none"]); + }); + + it("are read by finalizers on a joined thread where an entity had them", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + stoppedThere.length = 0; + await world.run(despawnAll); + await remote.run(System.create({ observer: true }, () => {})); + expect(stoppedThere.sort()).toEqual(["10:5:null", "1:none:null"]); + await remote.dispose(); + await world.dispose(); + }); + it("are read by lookups by entity, undefined where the entity lacks one, as written and in kernels", async () => { for (const system of [copySpeed, copySpeedKernel]) { await using world = inline(); diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index 94ff200c..d9270230 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -62,6 +62,8 @@ type ThreadFinalizer = { readonly constants: readonly number[]; readonly constantLayouts: readonly Layout[]; readonly bind: (archetype: RowArchetype) => Row; + /** Its optional components, which a stop's row has only where the entity's archetype did. */ + readonly optional: readonly number[]; readonly context: object; /** * Resolves its resources: its thread's objects as it holds them, and the rest from the copies a group holds from @@ -182,9 +184,10 @@ export const createThreadFinalizers = (host: Threa const untyped: { body(row: object, context: object): void } = leaf; return { leaf, objects, starts, linear, layouts, constants, constantLayouts, - required: [...new Set([...reads, ...components.has ?? []])].map(host.numberOf), + required: [...new Set([...reads, ...components.has ?? []])].filter(key => !(components.optional ?? []).includes(key)).map(host.numberOf), excluded: (components.not ?? []).map(host.numberOf), - bind: rowBinderOf(host.rows, host.numberOf, reads, []), + optional: (components.optional ?? []).map(host.numberOf), + bind: rowBinderOf(host.rows, host.numberOf, reads, [], components.optional), context: value, cells: copies => { // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. @@ -296,11 +299,38 @@ export const createThreadFinalizers = (host: Threa column += columnBytes(count, stride); } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); - const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); + const entities = new Float64Array(buffer, entitiesAt(group), count); finalizer.cells(column); - for (let i = 0; i < count; i++) { - row.index = i; - guarded(index, finalizer, row); + if (finalizer.optional.length === 0) { + const row = finalizer.bind({ id: -1, entities, storageById }); + for (let i = 0; i < count; i++) { + row.index = i; + guarded(index, finalizer, row); + } + } + else { + // A stop's row has an optional component only where its archetype did: rows run a run of one + // archetype's stops at a time, and archetypes with the same ones share their copy of the columns. + const byPattern = new Map(); + for (let i = 0; i < count;) { + const id = u32[archetypeWords + i]; + let end = i + 1; + while (end < count && u32[archetypeWords + end] === id) end++; + const components = host.archetypeAt(id)?.components ?? []; + let pattern = 0; + finalizer.optional.forEach((component, k) => { if (components.includes(component)) pattern |= 1 << k; }); + let archetype = byPattern.get(pattern); + if (archetype === undefined) { + const storages: (FinalizedStorage | undefined)[] = [...storageById]; + finalizer.optional.forEach((component, k) => { if ((pattern & (1 << k)) === 0) storages[component] = undefined; }); + byPattern.set(pattern, archetype = { id: -1, entities, storageById: storages }); + } + const row = finalizer.bind(archetype); + for (; i < end; i++) { + row.index = i; + guarded(index, finalizer, row); + } + } } group += bytes; } diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index 0c09897e..02e486d7 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -52,8 +52,10 @@ const resolveShared = (what: string, declared: Declaration | ChangesDeclaration, const componentReads = (what: string, components: Declaration["components"], access: Access): Resolved["components"] => { const written = access.components?.write[0]; if (written !== undefined) throw new Error(`${what} writes "${labelOf(written)}" of its row, which is read-only: it holds the values from before the entity stopped matching.`); - const read = access.components?.read ?? components.with ?? []; - return compact({ read, has: (components.with ?? []).filter(key => !read.includes(key)), not: components.not }); + // Optional components are read where the entity had them, and never decide a stop. + const optional = (components.optional ?? []).filter(key => !(components.with ?? []).includes(key)); + const read = access.components?.read ?? [...components.with ?? [], ...optional]; + return compact({ read, has: (components.with ?? []).filter(key => !read.includes(key)), optional, not: components.not }); }; /** Throws if a resource is in both `read` and `write`, or a reduced one is written. */ diff --git a/packages/ecs/src/interface/finalizer/declaration.ts b/packages/ecs/src/interface/finalizer/declaration.ts index 325de32c..54ebdd06 100644 --- a/packages/ecs/src/interface/finalizer/declaration.ts +++ b/packages/ecs/src/interface/finalizer/declaration.ts @@ -16,9 +16,12 @@ type Shared = { readonly after?: readonly Finalizer[]; }; -/** A finalizer that runs for each entity that stops matching its components: those with every one in `with` and none in `not`. */ +/** + * A finalizer that runs for each entity that stops matching its components: those with every one in `with` and none in + * `not`. Its `optional` components, which never decide a stop, its row reads where the entity had them. + */ export type Declaration = Shared & { - readonly components: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components: { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; }; /** @@ -39,7 +42,7 @@ type ResolvedShared = { /** A finalizer's declaration as the world takes it: what its body reads of its row in `read`, the rest of `with` in `has`. */ export type Resolved = ResolvedShared & { - readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly not?: readonly Key[] }; + readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[] }; }; export type ResolvedChanges = ResolvedShared & { readonly watch?: readonly Key[] }; From 26bcb1f92ee93269f78f7314931716eab875b1a7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:05:00 -0700 Subject: [PATCH 122/127] ecs: docs describe optional components in kernels, lookups, finalizers, tails and joined observers; GPU kernels still reject them Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/followups.md | 10 ++++++---- packages/ecs/src/implementation/compiler/signature.ts | 2 +- .../ecs/src/implementation/compiler/transform.test.ts | 4 ++-- packages/ecs/src/interface/finalizer/body.ts | 8 ++++++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index ab602419..388cedcc 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -114,7 +114,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - `added`/`changed` pass when **any** of the listed components was added or changed. - - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`. Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Not yet in kernels, whose compiler has no presence test: an unflagged system with optional components runs as written, and `kernel: true` or `"gpu"` with them fails the build and its types. Not in tails or in observer systems on joined threads, whose copies of rows across archetypes don't tell an absent component from a present one: a system with them never runs as a tail, and such an observer system fails to be added there. + - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`. Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Lookups by entity take `optional` too, read where the entity has them and never required, and so do finalizers, whose stops still depend on `with` and `not` alone. CPU kernels read them through a presence column (`{ kind: "present" }`), any column with rows where the archetype has the component and empty where it doesn't, tested once per call: an absent component's columns are empty, so its reads give `undefined` as a body run as written does, and a write fails the system through `absent`, which names it (`labels`). A test of one for `undefined` lowers to that presence, and a kernel runs loops specialized for every optional component present and every one absent, and the general one only for archetypes with some: `optional 100K kernel` costs what `with 100K kernel` does. WASM runs archetypes with every optional component and JS the rest; a kernel whose rows run as a list (`up`, `order`) or whose lookup has optional components runs as JS. GPU kernels reject them, in types and at the build: WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row. Tails and observer systems on joined threads read rows from snapshots across archetypes, which record each row's archetype, so they bind rows a run of one archetype at a time, every archetype with the same optional components sharing one copy of the columns; finalizers there bind their stops the same way. - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 0ea0753d..51ba78e6 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -16,10 +16,9 @@ correctness, then optimization. Remove an item once it's decided or done. before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), or `world.share` returning a promise of the claim. Smallest form to be decided. -- **Optional components in kernels, tails and joined observer systems:** kernels need a presence test lowered in the - JS, WASM and WGSL backends (per archetype or per chunk, never per row) and writes of absent components failing the - system; tails and observer posts on joined threads need their row copies to tell absent components from present - ones. Today those systems run as written, never run as tails, or fail to be added there. +- **Optional components in GPU kernels:** each dispatch's record could carry a presence word, but WGSL has no + `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; supporting + them means a presence expression through the WASM and WGSL backends and a GPU semantics for absent reads and writes. - **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. @@ -58,6 +57,9 @@ correctness, then optimization. Remove an item once it's decided or done. ## Correctness +- **A finalizer without an id, placed on a joined thread, hangs the world** instead of being rejected: every run + waits for the thread to set it up. Found writing `optional.test.ts`, where an unbound `Finalizer.create` on a thread + a joined thread fills made every joined test time out. - **Transactions, future:** never by default, since they would cost every run. An explicit declarative option on a one-off system, making its run all or nothing. diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 27bc1184..b71c510b 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -110,7 +110,7 @@ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => for (const property of declaration.properties) { if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { const { value } = property; - if (value.type === "Literal" && value.value === "gpu" && optional !== null) return fail(optional, "a GPU kernel can't have optional components yet: list them in with, or use kernel: true to run it on the CPU"); + if (value.type === "Literal" && value.value === "gpu" && optional !== null) return fail(optional, "a GPU kernel can't have optional components: WGSL has no undefined to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; list them in with, or use kernel: true to run it on the CPU"); if (value.type === "Literal" && value.value === true) return { target: "cpu", required: true }; if (value.type === "Literal" && value.value === "gpu") return { target: "gpu", required: true }; if (value.type === "Literal" && value.value === false) return null; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index 90fc9056..bea7ec9d 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -359,7 +359,7 @@ describe("kernels by default", () => { expect(await attached(`const kernel = System.create({ components: { with: [score], optional: [mass] } }, row => { const found = row[mass]; if (found !== undefined) row[score] = found; });`)).toBe(true); expect(await attached(`const kernel = System.create({ kernel: true, components: { with: [score], optional: [mass] } }, row => { if (row[mass] !== undefined) row[mass] = row[score]; });`)).toBe(true); expect(await failure(`const kernel = System.create({ kernel: "gpu", components: { with: [score], optional: [mass] } }, row => { row[score] = 1; });`)) - .toBe("a GPU kernel can't have optional components yet: list them in with, or use kernel: true to run it on the CPU"); + .toBe("a GPU kernel can't have optional components: WGSL has no undefined to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; list them in with, or use kernel: true to run it on the CPU"); expect(await failure(`const kernel = System.create({ components: { with: [score] } }, row => { row[score] = row[mass]; });`)) .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); }); @@ -1456,7 +1456,7 @@ describe("inferred access", () => { expect(await inferred(system("components: { with: [position] }", "(row) => { console.log(row[mass]); }"))) .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[position]); }"))) - .toBe("position isn't in lookup.targets.with, so the system doesn't require it: add it there to use it"); + .toBe("position isn't in lookup.targets.with or optional, so the system doesn't require it: add it there to use it"); }); test("finalizers, in files that import only Finalizer", async () => { diff --git a/packages/ecs/src/interface/finalizer/body.ts b/packages/ecs/src/interface/finalizer/body.ts index 45605032..53b4b581 100644 --- a/packages/ecs/src/interface/finalizer/body.ts +++ b/packages/ecs/src/interface/finalizer/body.ts @@ -9,9 +9,13 @@ import type { ChangesDeclaration, Declaration } from './declaration.js'; import type { Essential, ReadonlyValue } from '../component/value.js'; import type { Elsewhere, ThreadsOf } from '../system/threads.js'; -/** The entity that stopped matching, with the values of its `with` as they are before the sync point applies. */ +/** + * The entity that stopped matching, with the values of its `with` as they are before the sync point applies, and of + * its `optional` where it had them, `undefined` where it didn't. + */ export type Row = - { readonly entity: Entity } & { readonly [K in Listed, "with"> & Component.Key]: Component.Value }; + { readonly entity: Entity } & { readonly [K in Listed, "with"> & Component.Key]: Component.Value } + & { readonly [K in Exclude, "optional">, Listed, "with">> & Component.Key]: Component.Value | undefined }; /** * Every resource, its lookups when declared, and the commands it issues for the next sync point: none on another From 7d314e4e2fc9d168027ec8c73b2bd2d3cdff504e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:11:30 -0700 Subject: [PATCH 123/127] ecs: test that an id-less finalizer on a joined thread's thread is refused or reported, never a hang The recorded hang was a test helper waiting for a joined status that a refused claim never reaches; the world refuses the claim, or reports the finalizer once, as designed. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 7 +-- .../world/unidentified-finalizers.test.ts | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/ecs/src/implementation/world/unidentified-finalizers.test.ts diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 51ba78e6..782138d1 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -15,7 +15,9 @@ correctness, then optimization. Remove an item once it's decided or done. - **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), - or `world.share` returning a promise of the claim. Smallest form to be decided. + or `world.share` returning a promise of the claim. Smallest form to be decided. A refused claim, such as one of a + thread an id-less finalizer runs on, shows the world's thread only `"gone"` and the joined thread only a rejected + first run, so a helper waiting for `"joined"` waits forever. - **Optional components in GPU kernels:** each dispatch's record could carry a presence word, but WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; supporting them means a presence expression through the WASM and WGSL backends and a GPU semantics for absent reads and writes. @@ -57,9 +59,6 @@ correctness, then optimization. Remove an item once it's decided or done. ## Correctness -- **A finalizer without an id, placed on a joined thread, hangs the world** instead of being rejected: every run - waits for the thread to set it up. Found writing `optional.test.ts`, where an unbound `Finalizer.create` on a thread - a joined thread fills made every joined test time out. - **Transactions, future:** never by default, since they would cost every run. An explicit declarative option on a one-off system, making its run all or nothing. diff --git a/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts b/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts new file mode 100644 index 00000000..02403c7c --- /dev/null +++ b/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Finalizer, System, workers } from "../../index.js"; +import { createWorld, join } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const held = Symbol("unidentifiedFinalizers/held"); +declare module "@adobe/ecs" { + interface Threads { unidentifiedFinalizers: true } + interface Components { [held]: { type: { readonly text: string } | null; thread: "unidentifiedFinalizers" } } +} + +const nothing = System.create(() => {}); +const spawn = System.create(({ commands }) => { commands.spawn([held])({ [held]: null }); }); +const seen: unknown[] = []; +/** A finalizer reading the thread's objects, made where the build gives it no id. */ +const unidentified = (): void => { Finalizer.create({ components: { with: [held] } }, row => { seen.push(row[held]); }); }; + +/** A world shared with a thread on this one claiming the thread, and its status once settled: joined or gone. */ +const shared = async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + let status = ""; + const settled = new Promise(resolve => { + world.threads(threads => { + status = threads.at(-1)?.status ?? ""; + if (status === "joined" || status === "gone") resolve(); + }); + }); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["unidentifiedFinalizers"] }); + await settled; + return { world, remote, status: () => status }; +}; + +describe("a finalizer without an id on a thread a joined thread fills", () => { + // Created after the claim, so the claim is made; the next run reports it once and the world goes on without it. + it("fails once, in a report, and leaves the world running", async () => { + const { world, remote, status } = await shared(); + expect(status()).toBe("joined"); + await remote.run(spawn); + unidentified(); + const first = await runReported(world, nothing); + expect(first.failures.map(failure => [failure.kind, failure.message.includes("has no id")])).toEqual([["finalizer", true]]); + expect((await runReported(world, nothing)).failures).toEqual([]); + await remote.run(nothing); + await world.dispose(); + }, 10_000); + + // Finalizers are program-wide, so the one the test above made is present when this thread claims: the claim is + // refused, and the joined thread's runs say why. + it("refuses a later claim of its thread, saying why", async () => { + const { world, remote, status } = await shared(); + expect(status()).toBe("gone"); + await expect(remote.run(nothing)).rejects.toThrow(/has no id, so a joined thread can't run it/); + await world.run(nothing); + await world.dispose(); + }, 10_000); +}); From a92013ccef1c0f5ac16bbedfe8d63dcc832e9d2a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:22:52 -0700 Subject: [PATCH 124/127] ecs: optional components' rows key by which keys are optional; mixed presence stamps per row; undefined assignment fails Rows cached for one system were handed to another with the same keys but other optional ones. A chunk stamped under presence tests of two components kept only the last test, missing chunk and block stamps. Assigning undefined to a present optional component now fails its system. Lookups on workers skip the extra presence check where a column exists, kernels keep archetypes off WASM only for optional components their body uses, and two doc comments are back on their functions. Co-Authored-By: Claude Opus 5.5 --- packages/ecs/design.md | 2 +- packages/ecs/src/guide/03-systems.test.ts | 2 +- packages/ecs/src/guide/guide.md | 2 +- .../src/implementation/compiler/expression.ts | 2 +- .../ecs/src/implementation/compiler/stamps.ts | 33 ++++++--- .../ecs/src/implementation/world/kernels.ts | 3 +- .../src/implementation/world/optional.test.ts | 68 +++++++++++++++++++ packages/ecs/src/implementation/world/rows.ts | 28 ++++++-- .../implementation/world/thread-systems.ts | 2 +- .../ecs/src/implementation/world/worker.ts | 2 +- 10 files changed, 122 insertions(+), 22 deletions(-) diff --git a/packages/ecs/design.md b/packages/ecs/design.md index 388cedcc..aa0ee28a 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -114,7 +114,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - `added`/`changed` pass when **any** of the listed components was added or changed. - - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`. Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Lookups by entity take `optional` too, read where the entity has them and never required, and so do finalizers, whose stops still depend on `with` and `not` alone. CPU kernels read them through a presence column (`{ kind: "present" }`), any column with rows where the archetype has the component and empty where it doesn't, tested once per call: an absent component's columns are empty, so its reads give `undefined` as a body run as written does, and a write fails the system through `absent`, which names it (`labels`). A test of one for `undefined` lowers to that presence, and a kernel runs loops specialized for every optional component present and every one absent, and the general one only for archetypes with some: `optional 100K kernel` costs what `with 100K kernel` does. WASM runs archetypes with every optional component and JS the rest; a kernel whose rows run as a list (`up`, `order`) or whose lookup has optional components runs as JS. GPU kernels reject them, in types and at the build: WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row. Tails and observer systems on joined threads read rows from snapshots across archetypes, which record each row's archetype, so they bind rows a run of one archetype at a time, every archetype with the same optional components sharing one copy of the columns; finalizers there bind their stops the same way. + - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`, as does assigning `undefined` to one it has, which removes nothing (`commands.remove` does). Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Lookups by entity take `optional` too, read where the entity has them and never required, and so do finalizers, whose stops still depend on `with` and `not` alone. CPU kernels read them through a presence column (`{ kind: "present" }`), any column with rows where the archetype has the component and empty where it doesn't, tested once per call: an absent component's columns are empty, so its reads give `undefined` as a body run as written does, and a write fails the system through `absent`, which names it (`labels`). A test of one for `undefined` lowers to that presence, and a kernel runs loops specialized for every optional component present and every one absent, and the general one only for archetypes with some: `optional 100K kernel` costs what `with 100K kernel` does. WASM runs archetypes with every optional component and JS the rest; a kernel whose rows run as a list (`up`, `order`) or whose lookup has optional components runs as JS. GPU kernels reject them, in types and at the build: WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row. Tails and observer systems on joined threads read rows from snapshots across archetypes, which record each row's archetype, so they bind rows a run of one archetype at a time, every archetype with the same optional components sharing one copy of the columns; finalizers there bind their stops the same way. - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index 17b7cf00..7cc76002 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -65,7 +65,7 @@ const spawnStill = System.create(({ commands }) => { }); // An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which -// entities a system runs on. +// entities a system runs on. Commands add and remove it; assigning undefined removes nothing. const speeds: (Vec3 | undefined)[] = []; const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { speeds.push(row[velocity]); diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 5bd676ae..7adf4b7d 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -205,7 +205,7 @@ it("runs a system once per entity, or once", async () => { ```ts // An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which -// entities a system runs on. +// entities a system runs on. Commands add and remove it; assigning undefined removes nothing. const speeds: (Vec3 | undefined)[] = []; const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { speeds.push(row[velocity]); diff --git a/packages/ecs/src/implementation/compiler/expression.ts b/packages/ecs/src/implementation/compiler/expression.ts index 2696f8f5..13d1900c 100644 --- a/packages/ecs/src/implementation/compiler/expression.ts +++ b/packages/ecs/src/implementation/compiler/expression.ts @@ -66,7 +66,6 @@ const literal = (node: Extract): Value => { return fail(node, "only number, boolean and null literals are in the kernel subset"); }; -/** Compiles an expression to a value, emitting any statements it needs into `out`. */ /** * `value === undefined` and its variants, `== null` included, for an optional component's whole value: whether the * call's archetype lacks it, or has it. Null for anything else. @@ -82,6 +81,7 @@ const presenceTest = (operator: string, value: Value, other: Value, scope: Scope return operator === "!==" || operator === "!=" ? present : not(present); }; +/** Compiles an expression to a value, emitting any statements it needs into `out`. */ export const expression = (node: Node, scope: Scope, out: Out): Value => { switch (node.type) { case "Literal": diff --git a/packages/ecs/src/implementation/compiler/stamps.ts b/packages/ecs/src/implementation/compiler/stamps.ts index 23c48dc6..f1fb7796 100644 --- a/packages/ecs/src/implementation/compiler/stamps.ts +++ b/packages/ecs/src/implementation/compiler/stamps.ts @@ -16,14 +16,31 @@ export type PerCall = { readonly blocks: number; readonly when: number | null }; /** * Chunk columns stamped on every row, with their block columns: by a top-level stamp that no earlier `next` can skip, - * or one in a test of an optional component's presence, the same for every row of a call. Every row in - * `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. + * or by stamps all in tests of one optional component's presence, the same for every row of a call. Every row in + * `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. A chunk + * stamped under tests of different components is left to its stamps per row, since no one test covers it. */ -export const unconditionalChunks = (rows: readonly Statement[], when: number | null = null, chunks = new Map()): ReadonlyMap => { - for (const statement of rows) { - if (exits(statement)) break; - if (statement.kind === "stamp" && !(chunks.get(statement.chunks)?.when === null)) chunks.set(statement.chunks, { blocks: statement.blocks, when }); - if (statement.kind === "if" && when === null && statement.test.kind === "present") unconditionalChunks(resolve(statement.consequent), statement.test.column, chunks); - } +export const unconditionalChunks = (rows: readonly Statement[]): ReadonlyMap => { + const chunks = new Map(); + const mixed = new Set(); + const visit = (statements: readonly Statement[], when: number | null): void => { + for (const statement of statements) { + if (exits(statement)) break; + if (statement.kind === "stamp") { + const found = chunks.get(statement.chunks); + if (when === null) { + chunks.set(statement.chunks, { blocks: statement.blocks, when }); + mixed.delete(statement.chunks); + } + else if (found === undefined && !mixed.has(statement.chunks)) chunks.set(statement.chunks, { blocks: statement.blocks, when }); + else if (found !== undefined && found.when !== null && found.when !== when) { + chunks.delete(statement.chunks); + mixed.add(statement.chunks); + } + } + if (statement.kind === "if" && when === null && statement.test.kind === "present") visit(resolve(statement.consequent), statement.test.column); + } + }; + visit(rows, null); return chunks; }; diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index 31224955..84dba26b 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -161,7 +161,8 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript indexes: indexes.flatMap(bound => bound === null ? [] : [{ lookup: bound.lookup, reader: bound.reader, watched: bound.watched.length }]), keys, labels: keys.map(id => labelOf(state.keys[id])), - optional: ids(leaf.declaration.components?.optional), + // Only those its body uses: one it declares and never touches doesn't keep an archetype lacking it off WASM. + optional: ids(leaf.declaration.components?.optional).filter(id => sources.some(source => source.kind !== "entity" && source.id === id)), starts: keys.map(id => state.starts[id]), spawns, }; diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts index c1e513f5..be0674ae 100644 --- a/packages/ecs/src/implementation/world/optional.test.ts +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -135,6 +135,47 @@ const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote return { world, remote }; }; + +/** Keys of systems whose rows have the same keys but other optional ones, and of stamps under two presence tests. */ +const near = Symbol("optionalTest/near"); +const far = Symbol("optionalTest/far"); +const mark = Symbol("optionalTest/mark"); +declare module "@adobe/ecs" { + interface Components { + [near]: { type: number }; + [far]: { type: number }; + [mark]: { type: number }; + } +} +const spawnApart = System.create(({ commands }) => { + commands.spawn([near])({ [near]: 1 }); + commands.spawn([far])({ [far]: 7 }); +}); +const nearSeen: unknown[] = []; +const farSeen: unknown[] = []; +const fromFar = System.create({ kernel: false, components: { with: [near], optional: [far] } }, row => { + const value = row[far]; + nearSeen.push(value); + row[near] = value ?? -1; +}); +const fromNear = System.create({ kernel: false, components: { with: [far], optional: [near] } }, row => { + const at = row[near]; + farSeen.push([at, row[far]]); + if (at !== undefined) row[near] = row[far]; +}); +const spawnStamped = System.create(({ commands }) => { + commands.spawn([mark, near])({ [mark]: 0, [near]: 1 }); +}); +const markEither = System.create({ kernel: true, components: { with: [mark], optional: [near, far] } }, row => { + if (row[near] !== undefined) row[mark] = 1; + if (row[far] !== undefined) row[mark] = 2; +}); +const marked: number[] = []; +const watchStamped = System.create({ observer: true, components: { with: [mark], changed: [mark] } }, row => { marked.push(row[mark]); }); +const clearNear = System.create({ kernel: false, components: { with: [mark], optional: [near] } }, row => { + if (row[near] !== undefined) row[near] = undefined; +}); + describe("optional components", () => { it("never narrow what a system matches, and read undefined where an entity lacks one", async () => { await using world = inline(); @@ -286,4 +327,31 @@ describe("optional components", () => { expect(report.failures.map(failure => failure.message)).toEqual([`An entity lacks "optionalTest/velocity", optional in its system: add it with commands.add rather than assigning it.`]); }); + it("give systems with the same keys but other optional ones rows of their own", async () => { + await using world = inline(); + await world.run(spawnApart); + const first = await runReported(world, fromFar); + const second = await runReported(world, fromNear); + expect([...first.failures, ...second.failures]).toEqual([]); + expect(nearSeen).toEqual([undefined]); + expect(farSeen).toEqual([[undefined, 7]]); + }); + + it("stamp a chunk changed where tests of two of them write one component", async () => { + await using world = inline(); + await world.run(spawnStamped); + await world.run(watchStamped); + marked.length = 0; + await world.run(markEither); + await world.run(watchStamped); + expect(marked).toEqual([1]); + }); + + it("fail their system when assigned undefined, which removes nothing", async () => { + await using world = inline(); + await world.run(spawnStamped); + const report = await runReported(world, clearNear); + expect(report.failures.map(failure => failure.message)).toEqual([`"optionalTest/near" was assigned undefined, which removes nothing: remove it with commands.remove.`]); + }); + }); diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 0ca9a443..474ac209 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -133,11 +133,18 @@ const absentSetterOf = (key: Key): (value: unknown) => void => () => { throw new Error(`An entity lacks "${labelOf(key)}", optional in its system: add it with commands.add rather than assigning it.`); }; +/** A present optional component's setter, which fails its system for `undefined`: that removes nothing. */ +const presentSetterOf = (key: Key, set: (value: unknown) => void): (value: unknown) => void => value => { + if (value === undefined) throw new Error(`"${labelOf(key)}" was assigned undefined, which removes nothing: remove it with commands.remove.`); + set(value); +}; + /** - * A row of an archetype's `keys`, `absent` naming those it lacks, which an optional component may be: they read as - * `undefined`, and assigning one fails its system. + * A row of an archetype's `keys`. `absent` is undefined for a member and, for an optional component, whether the + * archetype lacks it: one it lacks reads as `undefined` and assigning it fails its system, and one it has fails its + * system when assigned `undefined`. */ -export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[], absent: readonly boolean[] = []): Row => { +export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[], absent: readonly (boolean | undefined)[] = []): Row => { const slots = ids.map((id, i): Slot | undefined => absent[i] === true ? undefined : slotOf(archetype, id)); // Its own prototype gives the row its own root map. Rows built from `{}` share one, and defining an accessor with a // different getter than an existing transition's makes V8 switch the object to slow dictionary properties. @@ -167,7 +174,7 @@ export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly enumerable: true, } : { get: getterOf(host, layout, slot, row), - set: writes.includes(key) ? setterOf(host, layout, slot, row) : undefined, + set: !writes.includes(key) ? undefined : absent[i] === false ? presentSetterOf(key, setterOf(host, layout, slot, row)) : setterOf(host, layout, slot, row), enumerable: true, }); }); @@ -295,11 +302,18 @@ export const rowBinderOf = (host: RowHost, idOfKey: (key else row.bind(archetype); return row; }; - // Each pattern of which optional components an archetype has is a row of its own, so no read checks per row. - const absent: boolean[] = keys.map(() => false); + // Each pattern of which optional components an archetype has is a row of its own, so no read checks per row. The + // pattern marks every key, `-` for a member and `0` or `1` for an optional one absent or present, so systems with + // the same keys but other optional ones never share a row. + const absent: (boolean | undefined)[] = keys.map(() => undefined); return archetype => { let pattern = signature + "|"; - for (const i of optionals) { + for (let i = 0, next = 0; i < keys.length; i++) { + if (optionals[next] !== i) { + pattern += "-"; + continue; + } + next++; absent[i] = archetype.storageById[ids[i]] === undefined; pattern += absent[i] ? "0" : "1"; } diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index 698f5056..da8447aa 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -1175,13 +1175,13 @@ const localStorage = (component: number, layout: Layout, views: Views, values: u }; }; -/** A lookup returning `read` values keyed by component, from this thread's views. */ /** What a lookup reads and requires: its reads but its optional components, which an entity it finds may lack. */ const requiredReads = (host: RunnerHost, lookup: SystemLookupLists): readonly number[] => { const optional = "optional" in lookup ? lookup.optional ?? [] : []; return (lookup.read ?? []).filter(key => !optional.includes(key)).map(host.numberOf); }; +/** A lookup returning `read` values keyed by component, from this thread's views. */ const lookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const read = (lookup.read ?? []).map(host.numberOf); const required = requiredReads(host, lookup); diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index d221a033..ab4a7c8f 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -241,7 +241,7 @@ const createLookup = (lookup: LookupSetup) => (entity: Entity): readonly unknown const layout = layouts[id]; const storage = archetype.storages.get(id); // An optional component the entity lacks reads undefined. - return !archetype.storages.has(id) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + return storage === undefined && !archetype.storages.has(id) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; }); }; From 502aa08a09c9f3de2a19331e1e5f831256b09772 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:22:52 -0700 Subject: [PATCH 125/127] ci: API docs build on every pull request; pages paths stay inside their package and the site CI runs `pnpm -r run docs`, so typedoc warnings fail pull requests, not only the deploy. collect-pages reads packages from pnpm-workspace.yaml, normalizes "./" to ".", and rejects a `from` leaving its package or a `to` leaving the site, which check-workspace-conventions now checks too; site/ is ignored. The GPU samples build for /data/gpu-samples/, where the site serves them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++ .gitignore | 1 + packages/data-gpu-samples/vite.config.ts | 4 +-- scripts/check-workspace-conventions.mjs | 5 ++- scripts/collect-pages.mjs | 22 ++++++------- scripts/pages.mjs | 41 ++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 scripts/pages.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac27b7d..2f4f9827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,5 +50,8 @@ jobs: - name: Build (every package, topological) run: pnpm -r run build + - name: API docs (every package that defines them; warnings fail) + run: pnpm -r run docs + - name: Test (each package runs its own complete suite) run: pnpm -r run test diff --git a/.gitignore b/.gitignore index 093202f5..039c012c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ packages/data/references # Vitest browser attachments and failure screenshots .vitest/ +/site/ diff --git a/packages/data-gpu-samples/vite.config.ts b/packages/data-gpu-samples/vite.config.ts index 11e224a8..e84a5469 100644 --- a/packages/data-gpu-samples/vite.config.ts +++ b/packages/data-gpu-samples/vite.config.ts @@ -14,9 +14,9 @@ export default defineConfig({ }, }, root: ".", - // In production (GitHub Pages) the app lives at /data/graphics-samples/. + // In production (GitHub Pages) the app lives at /data/gpu-samples/, its `pages` target. // The dev server ignores this — it always serves from /. - base: process.env.CI ? "/data/graphics-samples/" : "/", + base: process.env.CI ? "/data/gpu-samples/" : "/", build: { outDir: "dist" }, server: { port: 3008, open: false }, }); diff --git a/scripts/check-workspace-conventions.mjs b/scripts/check-workspace-conventions.mjs index 34f613db..673dd58d 100644 --- a/scripts/check-workspace-conventions.mjs +++ b/scripts/check-workspace-conventions.mjs @@ -24,6 +24,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, relative, resolve } from "node:path"; +import { pagesProblems } from "./pages.mjs"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); @@ -52,9 +53,7 @@ for (const name of readdirSync(packagesDir)) { errors.push(`${name}: publishable packages hold a LICENSE identical to the monorepo's (copy ../../LICENSE)`); } } - if (pkg.pages !== undefined && !(Array.isArray(pkg.pages) && pkg.pages.every((page) => typeof page?.from === "string" && typeof page?.to === "string"))) { - errors.push(`${name}: "pages" lists { "from", "to" } folders to publish (see scripts/collect-pages.mjs)`); - } + for (const problem of pagesProblems(pkg.pages)) errors.push(`${name}: ${problem}`); if (pkg.scripts?.["publish-public"]) { errors.push(`${name}: remove the "publish-public" script — publishing is governed by the "private" field and \`pnpm -r publish\``); } diff --git a/scripts/collect-pages.mjs b/scripts/collect-pages.mjs index 4a00cd7c..eab7a4ce 100644 --- a/scripts/collect-pages.mjs +++ b/scripts/collect-pages.mjs @@ -6,12 +6,12 @@ // // usage: node scripts/collect-pages.mjs -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, join, normalize } from "node:path"; +import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { normalizedPath, pagesProblems, workspacePackages } from "./pages.mjs"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const packagesDir = join(root, "packages"); const site = process.argv[2]; if (site === undefined) { console.error("usage: node scripts/collect-pages.mjs "); @@ -19,20 +19,18 @@ if (site === undefined) { } const copies = []; -for (const name of readdirSync(packagesDir)) { - let pkg; - try { - pkg = JSON.parse(readFileSync(join(packagesDir, name, "package.json"), "utf8")); - } catch { - continue; // not a package directory - } - for (const { from, to } of pkg.pages ?? []) copies.push({ name, from: join(packagesDir, name, from), to: normalize(to) }); +const errors = []; +for (const name of workspacePackages(root)) { + const pkg = JSON.parse(readFileSync(join(root, name, "package.json"), "utf8")); + const problems = pagesProblems(pkg.pages); + for (const problem of problems) errors.push(`${name}: ${problem}`); + if (problems.length > 0) continue; + for (const { from, to } of pkg.pages ?? []) copies.push({ name, from: join(root, name, normalizedPath(from)), to: normalizedPath(to) }); } // The root first, so a package's own path is never overwritten by it. copies.sort((a, b) => (a.to === "." ? 0 : 1) - (b.to === "." ? 0 : 1) || a.to.localeCompare(b.to)); const taken = new Map(); -const errors = []; for (const { name, from, to } of copies) { if (taken.has(to)) errors.push(`${name}: publishes to "${to}", which ${taken.get(to)} already does`); taken.set(to, name); diff --git a/scripts/pages.mjs b/scripts/pages.mjs new file mode 100644 index 00000000..20f1564c --- /dev/null +++ b/scripts/pages.mjs @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// What packages publish to the GitHub Pages site, shared by collect-pages.mjs, which assembles it, and +// check-workspace-conventions.mjs, which validates each package's `pages` field. + +import { readFileSync } from "node:fs"; +import { join, posix } from "node:path"; + +/** The workspace's packages, as pnpm-workspace.yaml lists them: their directories relative to the root. */ +export const workspacePackages = (root) => { + const lines = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8").split("\n"); + const start = lines.findIndex((line) => line.trim() === "packages:"); + const listed = []; + for (const line of lines.slice(start + 1)) { + const item = /^\s+-\s+['"]?([^'"]+?)['"]?\s*$/.exec(line); + if (item === null) break; + listed.push(item[1]); + } + return listed; +}; + +/** A path normalized with forward slashes and no trailing slash, "." for the directory itself. */ +export const normalizedPath = (path) => { + const normalized = posix.normalize(path.replace(/\\/g, "/")).replace(/\/+$/, ""); + return normalized === "" || normalized === "./" ? "." : normalized; +}; + +/** Whether a normalized path stays inside the directory it's relative to. */ +const inside = (path) => !posix.isAbsolute(path) && path !== ".." && !path.startsWith("../"); + +/** What's wrong with a package's `pages` field, one message per problem; none when it's absent or right. */ +export const pagesProblems = (pages) => { + if (pages === undefined) return []; + if (!(Array.isArray(pages) && pages.every((page) => typeof page?.from === "string" && typeof page?.to === "string"))) { + return [`"pages" lists { "from", "to" } folders to publish (see scripts/collect-pages.mjs)`]; + } + return pages.flatMap(({ from, to }) => [ + ...inside(normalizedPath(from)) ? [] : [`"pages" from "${from}" leaves the package`], + ...inside(normalizedPath(to)) ? [] : [`"pages" to "${to}" leaves the site`], + ]); +}; From 6c946c0398b2ddc72a7f7468272f3d0133db19c4 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:40:56 -0700 Subject: [PATCH 126/127] ecs: follow-ups note optional groups from boards' use of optional components Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 782138d1..9a5880a5 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -21,6 +21,9 @@ correctness, then optimization. Remove an item once it's decided or done. - **Optional components in GPU kernels:** each dispatch's record could carry a presence word, but WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; supporting them means a presence expression through the WASM and WGSL backends and a GPU semantics for absent reads and writes. +- **Optional groups:** components always present together (boards' text's four, path's three) must each be tested + for `undefined` before building one object. Possible: an optional group, present or absent as a whole, narrowed by + one test. Boards isn't asking for it; record usage before deciding. - **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. From 7b5ef12cd2bc1bb59abadca80200938c81731293 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Sat, 24 Oct 2026 02:41:04 -0700 Subject: [PATCH 127/127] ecs: follow-ups drop the API reference, now built Co-Authored-By: Claude Opus 5.5 --- packages/ecs/followups.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 9a5880a5..e6b89344 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -87,13 +87,6 @@ correctness, then optimization. Remove an item once it's decided or done. - **A test helper awaiting the next tick.** Tests watch `clock` with an observer system today; a helper in `src/observe` would do it (from the todo sample). -## Documentation and tooling - -- **Generated API reference, once the API work is done.** typedoc over the public entry points (`src/index.ts` and - `@adobe/ecs/compiler`), from a uniform `docs` script, linked from the README beside the guide. Publishing: today - `.github/workflows/deploy-docs.yml` names each package; make it run `pnpm -r run docs` and collect every package's - output into Pages instead of adding another named step (a monorepo change, `packages/data` included). - ## Decisions awaiting confirmation - **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every