diff --git a/ecs/CLAUDE.md b/ecs/CLAUDE.md index 9dc83611..90c63fc3 100644 --- a/ecs/CLAUDE.md +++ b/ecs/CLAUDE.md @@ -34,6 +34,12 @@ conversation, the diff or the alternatives. Tell them what it is and what it doe - "X, not B, C or D": say what is done; - "previously", "now", "instead of", "moved from", "whose source of truth is…". +## Layout + +A feature's or package's root folder holds only its public exports, one file per export, named after it, and their +tests. Its implementation lives in folders below. Nothing outside a feature imports below its root, and within a +package only its entry file re-exports. + ## Never use top-level await Do not use `await` at module top level in any package's sources. Defer async work to a lazy-init pattern: store the @@ -63,7 +69,9 @@ Standard script names a package may define: every package's `pages`. Publishing is governed only by the `private` field: `pnpm -r publish` publishes every package with `private: false` and -skips the rest. Every package sets `private` explicitly, and samples are `private: true`. Don't add a `publish-public` +skips the rest. Every package sets `private` explicitly. Samples are `private: true`, and every library is published +publicly from the start: `private: false`, `publishConfig` with `"access": "public"`, `independentVersion: true`, and +`LICENSE` among its files, as `ecs-gpu`'s are. Don't add a `publish-public` or similar script. `scripts/check-workspace-conventions.mjs` (`pnpm run check:workspace`, run in CI) enforces these. ## Checks diff --git a/ecs/README.md b/ecs/README.md index 0cb051ba..30c60ec5 100644 --- a/ecs/README.md +++ b/ecs/README.md @@ -7,6 +7,8 @@ A high-performance, multithreaded Entity Component System for web-based applicat - **[`@adobe/ecs`](packages/ecs)**: the ECS and its build plugin, `@adobe/ecs/compiler`. - **[`@adobe/ecs-gpu`](packages/ecs-gpu)**: WebGPU devices, canvases, render targets, views and frame passes for the ECS. +- **[`@adobe/ecs-persistence`](packages/ecs-persistence)**: saving, loading and copying the entities of worlds, as + binary columns. - **[`ecs-todo-vanillajs`](packages/ecs-todo-vanillajs)**: a todo app whose world drives the DOM through systems, with vanilla custom elements. - **[`ecs-particles-webgpu`](packages/ecs-particles-webgpu)**: particles simulated by kernels, on the CPU and with diff --git a/ecs/packages/ecs-gpu-views/src/README.md b/ecs/packages/ecs-gpu-views/src/README.md index 07488260..87680bbd 100644 --- a/ecs/packages/ecs-gpu-views/src/README.md +++ b/ecs/packages/ecs-gpu-views/src/README.md @@ -11,7 +11,7 @@ GPU Views tab. - **Main** (`main.ts`) owns the page. It starts the world's worker and joins the world. It moves each `` to the world's thread as the input of a run of `Surface.attach`, and `Surface.observe` sends each canvas's size in device - pixels whenever it changes. It spawns the views and the points, and adds `show`, an observer system writing the + pixels whenever it changes. It spawns the views and the points, and adds `show`, a readonly system writing the frame count into the overlay. - **The world's worker** (`world.ts`) creates the world, which ticks every frame, keeps it supplied with a device with `Device.keep`, and shares it on the port main sends. diff --git a/ecs/packages/ecs-gpu-views/src/main.ts b/ecs/packages/ecs-gpu-views/src/main.ts index 8f4acd53..c2a2a191 100644 --- a/ecs/packages/ecs-gpu-views/src/main.ts +++ b/ecs/packages/ecs-gpu-views/src/main.ts @@ -17,7 +17,7 @@ let drawn = 0; /** Whether the world on the worker has shut down, after the page said it was leaving. */ let left = false; // Added on this thread, it runs here after each of the world's frames. -const show = System.create({ observer: true, resources: { changed: [frames] } }, ({ resources }) => { +const show = System.create({ readonly: true, resources: { changed: [frames] } }, ({ resources }) => { drawn = resources[frames]; overlay(`frame ${drawn} · drawn on the world's worker into two canvases: two views side by side, and one from above`); }); diff --git a/ecs/packages/ecs-gpu/LICENSE b/ecs/packages/ecs-gpu/LICENSE new file mode 100644 index 00000000..4290d535 --- /dev/null +++ b/ecs/packages/ecs-gpu/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Adobe, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ecs/packages/ecs-gpu/package.json b/ecs/packages/ecs-gpu/package.json index a2f67358..8b7493d3 100644 --- a/ecs/packages/ecs-gpu/package.json +++ b/ecs/packages/ecs-gpu/package.json @@ -5,16 +5,22 @@ "license": "MIT", "type": "module", "sideEffects": true, - "private": true, + "private": false, + "independentVersion": true, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, "files": [ "dist", "src", "!src/**/*.test.ts", "!src/**/*.test-helper.ts", - "!src/**/*.test-worker.ts" + "!src/**/*.test-worker.ts", + "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.build.json && vite build", + "build": "cp ../../LICENSE . && tsc -p tsconfig.build.json && vite build", "typecheck": "tsc && tsc -p tsconfig.build.json", "test": "pnpm exec playwright install chromium && vitest --run" }, diff --git a/ecs/packages/ecs-guide/src/README.md b/ecs/packages/ecs-guide/src/README.md index e17fa2b6..78ea8081 100644 --- a/ecs/packages/ecs-guide/src/README.md +++ b/ecs/packages/ecs-guide/src/README.md @@ -69,7 +69,7 @@ and so are the builds, a service that checks and compiles off the main thread an are rounded panels on the base background, a gutter apart, and the editor's colors are Spectrum's palette, whose tokens flip with the theme. -Each feature's `model.ts` holds systems with input, one per thing that happens, and `dom.ts` the observer systems that +Each feature's `model.ts` holds systems with input, one per thing that happens, and `dom.ts` the readonly systems that bind elements, with the ticking systems a feature needs, as one composite the app adds. `events.ts` turns DOM events, the builds' results and the frame's messages into runs. Typing stays smooth: each keystroke is one run and the bindings it changes, while checking, compiling and running happen in workers and the frame, after the code has been diff --git a/ecs/packages/ecs-guide/src/app/compiled.page.test.ts b/ecs/packages/ecs-guide/src/app/compiled.page.test.ts index 2d5999cb..ebc35624 100644 --- a/ecs/packages/ecs-guide/src/app/compiled.page.test.ts +++ b/ecs/packages/ecs-guide/src/app/compiled.page.test.ts @@ -81,7 +81,7 @@ it("marks each of chapter 14's systems above its call with what it compiles to, await statusOf(page); const lenses = await lensesReading(page, chipLabels); expect(lenses.map(lens => lens.line.trim().slice(0, 30))).toEqual([ - "const move = System.create({ c", "const drift = System.create({ ", "const slow = System.create({ k", "const read = System.create({ o", "const spawn = System.create(({", + "const move = System.create({ c", "const drift = System.create({ ", "const slow = System.create({ k", "const read = System.create({ r", "const spawn = System.create(({", ]); expect(lenses[0]?.label).toBe("move: JS compiled, WASM compiled, WGSL if declared kernel: \"gpu\". Show what it compiles to."); await (await chipOf(page, "spawn")).hover(); diff --git a/ecs/packages/ecs-guide/src/app/hover.page.test.ts b/ecs/packages/ecs-guide/src/app/hover.page.test.ts index d3f0e4e1..1ea37fc6 100644 --- a/ecs/packages/ecs-guide/src/app/hover.page.test.ts +++ b/ecs/packages/ecs-guide/src/app/hover.page.test.ts @@ -57,7 +57,7 @@ it.each(["dark", "light"] as const)("shows the types of what's hovered, System.c await away(page); await hoverWord(page, "await using world = World.create()", "create"); const world = await hovered(page); - expect(world.signature).toMatch(/^\(property\) create: /); + expect(world.signature).toMatch(/^const create: /); expect(errors).toEqual([]); } finally { await page.close(); diff --git a/ecs/packages/ecs-guide/src/features/appearance/dom.ts b/ecs/packages/ecs-guide/src/features/appearance/dom.ts index bfcfe2b6..3005c1e6 100644 --- a/ecs/packages/ecs-guide/src/features/appearance/dom.ts +++ b/ecs/packages/ecs-guide/src/features/appearance/dom.ts @@ -21,7 +21,7 @@ declare module "@adobe/ecs" { /** Colors the page light or dark, as the system prefers. */ export const bindTheme = System.create({ - observer: true, + readonly: true, resources: { is: [theme], changed: [dark] }, }, ({ resources }) => { resources[theme].setAttribute("color", resources[dark] ? "dark" : "light"); @@ -29,7 +29,7 @@ export const bindTheme = System.create({ /** Says whether this browser has WebGPU. */ export const bindWebgpu = System.create({ - observer: true, + readonly: true, resources: { is: [note], changed: [webgpu] }, }, ({ resources }) => { resources[note].webgpu = resources[webgpu]; @@ -37,7 +37,7 @@ export const bindWebgpu = System.create({ /** Shows the note in the chapter on GPU kernels only. */ export const bindNote = System.create({ - observer: true, + readonly: true, components: { with: [shown, chapterNumber], changed: [shown] }, resources: { is: [note] }, }, (row, { resources }) => { @@ -49,7 +49,7 @@ export const bindNote = System.create({ * without an example, there's nothing to resize. */ export const bindColumns = System.create({ - observer: true, + readonly: true, components: { with: [shown, sources] }, resources: { is: [columnsElement] }, }, (row, { resources }) => { diff --git a/ecs/packages/ecs-guide/src/features/builds/dom.ts b/ecs/packages/ecs-guide/src/features/builds/dom.ts index 0078594a..216cea34 100644 --- a/ecs/packages/ecs-guide/src/features/builds/dom.ts +++ b/ecs/packages/ecs-guide/src/features/builds/dom.ts @@ -36,7 +36,7 @@ declare module "@adobe/ecs" { /** Sends each revision of a chapter's files to be checked and compiled. */ export const requestBuilds = System.create({ - observer: true, + readonly: true, components: { with: [revision, sources], changed: [revision] }, resources: { is: [builds] }, }, (row, { resources }) => { @@ -45,7 +45,7 @@ export const requestBuilds = System.create({ /** Lists the shown chapter's problems, and marks the shown file's in the editor. */ export const bindProblems = System.create({ - observer: true, + readonly: true, components: { with: [shown, typeProblems, buildProblems, sources, fileShown], changed: [shown, typeProblems, buildProblems, fileShown] }, resources: { is: [problemsElement, editor] }, }, (row, { resources }) => { @@ -57,7 +57,7 @@ export const bindProblems = System.create({ /** Shows where the shown chapter's run is. */ export const bindStatus = System.create({ - observer: true, + readonly: true, components: { with: [shown, runState], changed: [shown, runState] }, resources: { is: [statusElement] }, }, (row, { resources }) => { @@ -66,7 +66,7 @@ export const bindStatus = System.create({ /** Shows what the shown chapter's run logged. */ export const bindConsole = System.create({ - observer: true, + readonly: true, components: { with: [shown, logged], changed: [shown, logged] }, resources: { is: [consoleElement] }, }, (row, { resources }) => { @@ -78,7 +78,7 @@ export const bindConsole = System.create({ * was last shown, and the Problems, each saying whether an error is among them. */ export const bindTabs = System.create({ - observer: true, + readonly: true, components: { with: [shown, tab, logged, consoleSeen, typeProblems, buildProblems], changed: [shown, tab, logged, consoleSeen, typeProblems, buildProblems] }, resources: { is: [tabsElement] }, }, (row, { resources }) => { @@ -96,7 +96,7 @@ export const bindTabs = System.create({ /** Lists what the shown chapter's systems compile to, and opens the one the reader chose. */ export const bindCompiled = System.create({ - observer: true, + readonly: true, components: { with: [shown, described, systemOpened], changed: [shown, described, systemOpened] }, resources: { is: [compiledElement] }, }, (row, { resources }) => { @@ -107,7 +107,7 @@ export const bindCompiled = System.create({ /** Lets the editor ask TypeScript what's at a place in the shown chapter's files. */ export const bindQuickInfo = System.create({ - observer: true, + readonly: true, components: { with: [shown], changed: [shown] }, resources: { is: [editor, builds] }, }, (row, { resources }) => { @@ -118,7 +118,7 @@ export const bindQuickInfo = System.create({ /** Marks the shown file's systems in the editor at their calls. */ export const bindSystemMarks = System.create({ - observer: true, + readonly: true, components: { with: [shown, described, sources, fileShown], changed: [shown, described, fileShown] }, resources: { is: [editor] }, }, (row, { resources }) => { @@ -129,7 +129,7 @@ export const bindSystemMarks = System.create({ /** Frames the shown chapter's latest run. */ export const bindOutput = System.create({ - observer: true, + readonly: true, components: { with: [shown, page], changed: [shown, page] }, resources: { is: [outputElement] }, }, (row, { resources }) => { diff --git a/ecs/packages/ecs-guide/src/features/chapters/dom.ts b/ecs/packages/ecs-guide/src/features/chapters/dom.ts index 37f9de8a..e3b4310b 100644 --- a/ecs/packages/ecs-guide/src/features/chapters/dom.ts +++ b/ecs/packages/ecs-guide/src/features/chapters/dom.ts @@ -17,7 +17,7 @@ declare module "@adobe/ecs" { /** Lists the chapters in the navigation, in order, whenever a chapter is added. */ export const bindChapters = System.create({ - observer: true, + readonly: true, resources: { is: [nav] }, lookup: { chapters: { index: inOrder, changed: true } }, }, ({ resources, lookup }) => { @@ -32,7 +32,7 @@ export const bindChapters = System.create({ /** Shows the shown chapter: selected in the navigation, its prose, and its title in the page's. */ export const bindShown = System.create({ - observer: true, + readonly: true, components: { with: [shown, slug, chapterNumber, title, prose], changed: [shown] }, resources: { is: [nav, proseElement] }, }, (row, { resources }) => { diff --git a/ecs/packages/ecs-guide/src/features/examples/dom.ts b/ecs/packages/ecs-guide/src/features/examples/dom.ts index 7cd9ad02..5082fc22 100644 --- a/ecs/packages/ecs-guide/src/features/examples/dom.ts +++ b/ecs/packages/ecs-guide/src/features/examples/dom.ts @@ -24,7 +24,7 @@ declare module "@adobe/ecs" { /** Puts the shown chapter's shown file in the editor, and its files in the tabs; a chapter without examples hides them. */ export const bindEditor = System.create({ - observer: true, + readonly: true, components: { with: [shown, sources, fileShown], changed: [shown, sources, fileShown] }, resources: { is: [editor, filesElement, exampleArea] }, }, (row, { resources }) => { @@ -37,7 +37,7 @@ export const bindEditor = System.create({ /** Shows the lines asked for in the editor. */ export const bindRevealed = System.create({ - observer: true, + readonly: true, components: { with: [shown, revealed], changed: [shown, revealed] }, resources: { is: [editor] }, }, (row, { resources }) => { @@ -46,7 +46,7 @@ export const bindRevealed = System.create({ /** Lets the reset button reset only a chapter edited. */ export const bindReset = System.create({ - observer: true, + readonly: true, components: { with: [shown, sources, originals], changed: [shown, sources] }, resources: { is: [resetElement] }, }, (row, { resources }) => { diff --git a/ecs/packages/ecs-guide/src/features/model.test.ts b/ecs/packages/ecs-guide/src/features/model.test.ts index 2e076177..1e2bb3d2 100644 --- a/ecs/packages/ecs-guide/src/features/model.test.ts +++ b/ecs/packages/ecs-guide/src/features/model.test.ts @@ -19,7 +19,7 @@ type Read = { }; const read: Read[] = []; const readChapters = System.create({ - observer: true, + readonly: true, components: { with: [slug, chapterNumber, sources, revision, fileShown, revealed, runState, logged, page, typeProblems, buildProblems, tab, consoleSeen], optional: [shown] }, }, row => { read.push({ @@ -190,7 +190,7 @@ it("frames a chapter shown again only once its new revision compiles, not its la /** What a chapter's systems compile to, and the system opened, as tests read them. */ const compiledOf = async (world: World): Promise => { let found: unknown = null; - await world.run(System.create({ observer: true, components: { with: [shown, described, systemOpened, tab, tabPicked] } }, row => { + await world.run(System.create({ readonly: true, components: { with: [shown, described, systemOpened, tab, tabPicked] } }, row => { found = { described: row[described], opened: row[systemOpened], tab: row[tab], picked: row[tabPicked] }; })); return found; diff --git a/ecs/packages/ecs-particles-webgpu/src/gpu/main.ts b/ecs/packages/ecs-particles-webgpu/src/gpu/main.ts index 2b9fd667..32f3d571 100644 --- a/ecs/packages/ecs-particles-webgpu/src/gpu/main.ts +++ b/ecs/packages/ecs-particles-webgpu/src/gpu/main.ts @@ -61,7 +61,7 @@ const start = async (): Promise => { await world.run(spawn, { count, width, 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: { with: [position, visible, enabled] } }, row => { + const probe = System.create({ readonly: true, components: { with: [position, visible, enabled] } }, row => { if (probed.length < 3) probed.push(row[position]); }); Reflect.set(globalThis, "sampleProbe", async () => { diff --git a/ecs/packages/ecs-particles-webgpu/src/particles.test.ts b/ecs/packages/ecs-particles-webgpu/src/particles.test.ts index a7c9b5a7..18a8b316 100644 --- a/ecs/packages/ecs-particles-webgpu/src/particles.test.ts +++ b/ecs/packages/ecs-particles-webgpu/src/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 }) => { spawned = resources[count]; })); + await world.run(System.create({ readonly: true }, ({ resources }) => { spawned = resources[count]; })); expect(spawned).toBe(100); const expected = reference(frames); expect(positions).toHaveLength(expected.length); diff --git a/ecs/packages/ecs-particles-webgpu/src/particles.ts b/ecs/packages/ecs-particles-webgpu/src/particles.ts index a3970f6d..62fd6531 100644 --- a/ecs/packages/ecs-particles-webgpu/src/particles.ts +++ b/ecs/packages/ecs-particles-webgpu/src/particles.ts @@ -54,7 +54,7 @@ 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({ - observer: true, + readonly: true, components: { with: [position] }, }, row => { positions.push(row[position]); diff --git a/ecs/packages/ecs-persistence/LICENSE b/ecs/packages/ecs-persistence/LICENSE new file mode 100644 index 00000000..4290d535 --- /dev/null +++ b/ecs/packages/ecs-persistence/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Adobe, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ecs/packages/ecs-persistence/README.md b/ecs/packages/ecs-persistence/README.md new file mode 100644 index 00000000..2778579d --- /dev/null +++ b/ecs/packages/ecs-persistence/README.md @@ -0,0 +1,72 @@ +# @adobe/ecs-persistence + +Saving, loading and copying the entities of `@adobe/ecs` worlds. A snapshot holds whole archetypes' columns: a struct's +or a number's bytes as the world stores them, which saving and loading copy as blocks, and the values of strings and +other data. Its entity values are rewritten as they're loaded, so references between the entities it holds still name +each other. + +## Marking what persists + +A component or resource persists when its declaration names it with the `persist` trait, by a stable name: saved data +names it so, whatever its module's path or version. + +```ts +import type { Entity, Vec3 } from "@adobe/ecs"; +import { persist } from "@adobe/ecs-persistence"; + +export const position = Symbol(); +export const parent = Symbol(); +export const score = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3; [persist]: "position" }; + [parent]: { type: Entity; reference: "owner"; [persist]: "parent" }; + } + interface Resources { + [score]: { type: number; default: 0; [persist]: "score" }; + } +} +``` + +Only data persists: a value with a schema. A derived component doesn't: its rule derives it again once its entity is +loaded. + +## Saving and loading + +```ts +import { decode, encode, snapshot, spawn } from "@adobe/ecs-persistence"; + +const taken = await snapshot({ world }); +if (taken.failure !== undefined) throw new Error(taken.failure); +const bytes = encode(taken); + +const loaded = decode(bytes); +if (loaded.failure !== undefined) throw new Error(loaded.failure); +const spawned = await spawn({ world: other, snapshot: loaded }); +``` + +- `snapshot({ world, ... })` takes every entity holding a persisted component, those matching `with` and `not`, or + `root` and everything it owns through persisted owner references. Except with `root` or `resources: false`, it + takes the persisted resources too. +- `encode` writes it as bytes, and `decode` reads them back, viewing each column's bytes where they are in the buffer. + It refuses bytes naming a component or resource this program doesn't persist, or persists with another schema. +- `spawn({ world, snapshot, entities? })` spawns an entity for each of the snapshot's and sets its resources, its entity values + naming the entities spawned for theirs. An entity value naming an entity outside the snapshot follows what + despawning that entity would do: an entity it owns isn't spawned, nor what that owns, and the result counts them in + `dropped`; a weak reference to it is left out; any other value naming it is `Entity.none`. The result's `entities` + maps each of the snapshot's entities to the one spawned for it, or null; `entities: false` leaves the map out, which + saves about 85 ms and 50 MB at a million entities. +- Copying entities from one world into another is a snapshot spawned there: `spawn({ world: to, snapshot: await snapshot({ world: from }) })`, without + bytes. + +Each reports why it didn't happen as `{ failure }`, rather than throwing, and `result.failure !== undefined` tells +which. A snapshot is opaque: only these functions +read it. + +## Speed + +`pnpm test:perf` compares, at 100K and 1M entities with a `Vec3`, a number, an owner reference and a struct holding an +entity, this package with reading rows and spawning entity by entity. At 1M entities, a snapshot takes about 6 ms +against 110 ms, encoding 5 ms against 150 ms for `JSON.stringify` of the rows, and decoding views the bytes where they +are. Spawning takes about 310 ms against 1,450 ms, most of it the world's bookkeeping of owner references at its sync +point; without references, 1M entities spawn in about 26 ms. diff --git a/ecs/packages/ecs-persistence/package.json b/ecs/packages/ecs-persistence/package.json new file mode 100644 index 00000000..2ef270b3 --- /dev/null +++ b/ecs/packages/ecs-persistence/package.json @@ -0,0 +1,42 @@ +{ + "name": "@adobe/ecs-persistence", + "version": "0.0.1", + "description": "Saving, loading and copying the entities of @adobe/ecs worlds, as binary columns", + "license": "MIT", + "type": "module", + "sideEffects": true, + "private": false, + "independentVersion": true, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts", + "!src/**/*.test-helper.ts", + "LICENSE" + ], + "scripts": { + "build": "cp ../../LICENSE . && tsc -p tsconfig.build.json && vite build", + "typecheck": "tsc && tsc -p tsconfig.build.json", + "test": "vitest --run", + "test:perf": "vitest --run --config vitest.perf.config.ts" + }, + "dependencies": { + "@adobe/ecs": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.8.3", + "vite": "^8.3.4", + "vitest": "^5.0.5" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/types/index.d.ts" + } + } +} diff --git a/ecs/packages/ecs-persistence/perf/persistence.perf.ts b/ecs/packages/ecs-persistence/perf/persistence.perf.ts new file mode 100644 index 00000000..41228201 --- /dev/null +++ b/ecs/packages/ecs-persistence/perf/persistence.perf.ts @@ -0,0 +1,111 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity, System, World, workers, type Vec3 } from "@adobe/ecs"; +import { it } from "vitest"; +import { decode } from "../src/decode.js"; +import { encode } from "../src/encode.js"; +import { persist } from "../src/persist.js"; +import { snapshot } from "../src/snapshot.js"; +import { spawn } from "../src/spawn.js"; + +// Snapshot, encode, decode, spawn and merge of entities with a Vec3, a number, an owner reference and a struct holding +// an entity: through tables and spawnTables, and through rows and per-entity commands, as a program does without +// them. Each prints its median of a few runs; none asserts on time. + +const position = Symbol(); +const health = Symbol(); +const parent = Symbol(); +const link = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3; [persist]: "perf/position" }; + [health]: { type: number; [persist]: "perf/health" }; + [parent]: { type: Entity; reference: "owner"; [persist]: "perf/parent" }; + [link]: { type: { readonly to: Entity; readonly weight: number }; [persist]: "perf/link" }; + } +} + +const size = { count: 0 }; +/** A root and `count - 1` entities each owned by an earlier one, and each linked to another. */ +const build = System.create(({ commands }) => { + const made: Entity[] = [commands.spawn([position, health, link])({ [position]: [0, 0, 0], [health]: 1, [link]: { to: Entity.none, weight: 0 } })]; + for (let i = 1; i < size.count; i++) { + made.push(commands.spawn([position, health, parent, link])({ [position]: [i, i, i], [health]: i, [parent]: made[i >> 1], [link]: { to: made[(i * 7) % i], weight: i } })); + } +}); + +/** The row form of a snapshot: each entity's values, read row by row. */ +type Rows = { entities: Entity[]; positions: Vec3[]; healths: number[]; parents: Entity[]; links: { readonly to: Entity; readonly weight: number }[] }; +const rows: Rows = { entities: [], positions: [], healths: [], parents: [], links: [] }; +const readRows = System.create({ readonly: true, components: { with: [position, health, link], optional: [parent] } }, row => { + rows.entities.push(row.entity); + rows.positions.push(row[position]); + rows.healths.push(row[health]); + rows.parents.push(row[parent] ?? Entity.none); + rows.links.push(row[link]); +}); +const given: { rows: Rows } = { rows }; +/** Spawns each row, then sets its references once every entity is known, as commands must. */ +const spawnRows = System.create(({ commands }) => { + const { entities, positions, healths, parents, links } = given.rows; + const spawned = new Map(); + for (let i = 0; i < entities.length; i++) { + spawned.set(entities[i], commands.spawn([position, health, link])({ [position]: positions[i], [health]: healths[i], [link]: { to: Entity.none, weight: links[i].weight } })); + } + for (let i = 0; i < entities.length; i++) { + const entity = spawned.get(entities[i]) ?? Entity.none; + const to = spawned.get(links[i].to) ?? Entity.none; + if (parents[i] !== Entity.none) commands.add(entity, [parent, link], { [parent]: spawned.get(parents[i]) ?? Entity.none, [link]: { to, weight: links[i].weight } }); + else commands.add(entity, link, { to, weight: links[i].weight }); + } +}); + +const median = (times: readonly number[]): number => [...times].sort((a, b) => a - b)[times.length >> 1]; +const timed = async (runs: number, before: () => Promise | void, action: () => Promise | unknown): Promise => { + const times: number[] = []; + for (let run = 0; run < runs; run++) { + await before(); + const start = performance.now(); + await action(); + times.push(performance.now() - start); + } + return median(times); +}; + +const results: string[] = []; + +for (const count of [100_000, 1_000_000]) { + it(`persists ${count} entities`, async () => { + const runs = count > 100_000 ? 3 : 5; + size.count = count; + await using world = World.create({ [workers]: 0 }); + await world.run(build); + const ms = (value: number): string => `${value.toFixed(1)} ms`; + + const rowSnapshot = await timed(runs, () => { rows.entities = []; rows.positions = []; rows.healths = []; rows.parents = []; rows.links = []; }, () => world.run(readRows)); + const rowJson = await timed(runs, () => {}, () => JSON.stringify(rows)); + const rowText = JSON.stringify(rows); + const rowParse = await timed(runs, () => {}, () => JSON.parse(rowText)); + let into = World.create({ [workers]: 0 }); + const rowSpawn = await timed(runs, async () => { await into.dispose(); into = World.create({ [workers]: 0 }); given.rows = rows; }, () => into.run(spawnRows)); + + let taken = await snapshot({ world }); + const tableSnapshot = await timed(runs, () => {}, async () => { taken = await snapshot({ world }); }); + if ("failure" in taken) throw new Error(taken.failure); + const ready = taken; + let bytes = encode(ready); + const tableEncode = await timed(runs, () => {}, () => { bytes = encode(ready); }); + let decoded = decode(bytes); + const tableDecode = await timed(runs, () => {}, () => { decoded = decode(bytes); }); + if ("failure" in decoded) throw new Error(decoded.failure); + const loaded = decoded; + const tableSpawn = await timed(runs, async () => { await into.dispose(); into = World.create({ [workers]: 0 }); }, () => spawn({ world: into, snapshot: loaded, entities: false })); + const tableMerge = await timed(runs, async () => { await into.dispose(); into = World.create({ [workers]: 0 }); }, async () => { const copied = await snapshot({ world, resources: false }); if (copied.failure === undefined) await spawn({ world: into, snapshot: copied, entities: false }); }); + await into.dispose(); + + results.push( + `${count} entities, rows and commands: snapshot ${ms(rowSnapshot)}, JSON.stringify ${ms(rowJson)} (${(rowText.length / 1e6).toFixed(1)} MB), JSON.parse ${ms(rowParse)}, spawn ${ms(rowSpawn)}`, + `${count} entities, tables: snapshot ${ms(tableSnapshot)}, encode ${ms(tableEncode)} (${(bytes.byteLength / 1e6).toFixed(1)} MB), decode ${ms(tableDecode)}, spawn ${ms(tableSpawn)}, merge ${ms(tableMerge)}`, + ); + process.stdout.write(`${results.slice(-2).join("\n")}\n`); + }); +} diff --git a/ecs/packages/ecs-persistence/src/decode.ts b/ecs/packages/ecs-persistence/src/decode.ts new file mode 100644 index 00000000..cb6b01ac --- /dev/null +++ b/ecs/packages/ecs-persistence/src/decode.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Failure } from "./failure.js"; +import { decodeContents } from "./format/format.js"; +import { contents } from "./program/contents.js"; +import type { Snapshot } from "./snapshot.js"; + +/** + * The snapshot `bytes` holds, its columns of bytes viewed where they are in `bytes`, copied once only when `bytes` + * doesn't start at a multiple of 8; or why it can't be read: it isn't one, or names a component or resource this + * program doesn't persist, or persists with another schema. + */ +export const decode = (bytes: Uint8Array): Snapshot | Failure => { + const decoded = decodeContents(bytes); + return "failure" in decoded ? decoded : { [contents]: decoded }; +}; diff --git a/ecs/packages/ecs-persistence/src/encode.ts b/ecs/packages/ecs-persistence/src/encode.ts new file mode 100644 index 00000000..52d5c6a7 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/encode.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { encodeContents } from "./format/format.js"; +import { contents } from "./program/contents.js"; +import type { Snapshot } from "./snapshot.js"; + +/** + * A snapshot as bytes, little-endian: a header naming each persisted component and resource with its schema, then + * 8-byte-aligned blocks of each table's entities and columns, its bytes columns copied whole. + */ +export const encode = (snapshot: Snapshot): Uint8Array => encodeContents(snapshot[contents]); diff --git a/ecs/packages/ecs-persistence/src/failure.ts b/ecs/packages/ecs-persistence/src/failure.ts new file mode 100644 index 00000000..4136a2ad --- /dev/null +++ b/ecs/packages/ecs-persistence/src/failure.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** Why a snapshot, a decoding or a spawn didn't happen. */ +export type Failure = { readonly failure: string }; diff --git a/ecs/packages/ecs-persistence/src/format/format.ts b/ecs/packages/ecs-persistence/src/format/format.ts new file mode 100644 index 00000000..a2e8d7d5 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/format/format.ts @@ -0,0 +1,185 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component, Resource, Schema, World } from "@adobe/ecs"; +import type { Failure } from "../failure.js"; +import type { Contents } from "../program/contents.js"; +import { programOf, type Persisted } from "../program/program.js"; +import { fromJson, toJson } from "../values/values.js"; + +/** + * The binary form of a snapshot, little-endian: + * - 16 bytes: `ECSP`, the format's version as a u32, the header's length in bytes as a u32, and 4 bytes of zeros; + * - the header, JSON text padded with spaces to a multiple of 8 bytes: each component and resource by name with its + * schema, and each table's count and where its entities and columns are; + * - blocks, each starting at a multiple of 8 bytes: a table's entities as f64s, a column's bytes as its components' + * layout holds them, and a column of values as JSON text. + * A loader views the blocks of bytes where they are, in the buffer it's given. + */ + +const magic = [0x45, 0x43, 0x53, 0x50]; +const version = 1; +const prefix = 16; + +/** Where a column's data is. */ +type ColumnJson = + | { readonly kind: "bytes"; readonly offset: number; readonly length: number; readonly stride: number } + | { readonly kind: "values"; readonly offset: number; readonly length: number } + | { readonly kind: "value"; readonly value: unknown }; + +type Header = { + readonly components: readonly { readonly name: string; readonly schema: Schema }[]; + readonly resources: readonly { readonly name: string; readonly schema: Schema; readonly value: unknown }[]; + readonly tables: readonly { readonly count: number; readonly entities: number; readonly columns: readonly ({ readonly component: number } & ColumnJson)[] }[]; +}; + +const aligned = (offset: number): number => Math.ceil(offset / 8) * 8; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** A snapshot's contents as bytes. */ +export const encodeContents = (snapshot: Contents): Uint8Array => { + const blocks: Uint8Array[] = []; + let offset = 0; + /** Places a block, returning its offset from the first block. */ + const place = (bytes: Uint8Array): number => { + const at = offset; + blocks.push(bytes); + offset = aligned(offset + bytes.byteLength); + return at; + }; + const indexOf = new Map(snapshot.components.map((persisted, index) => [persisted.key, index])); + const tables = snapshot.tables.map(table => { + const entities = place(new Uint8Array(table.entities.buffer, table.entities.byteOffset, table.entities.byteLength)); + const columns = snapshot.components.flatMap((persisted): ({ readonly component: number } & ColumnJson)[] => { + const key: symbol = persisted.key; + const column: World.Table.Column | undefined = Reflect.get(table.columns, key); + const component = indexOf.get(persisted.key) ?? -1; + if (column === undefined) return []; + if (column.kind === "bytes") return [{ component, kind: "bytes", offset: place(column.bytes), length: column.bytes.byteLength, stride: column.stride }]; + if (column.kind === "value") return [{ component, kind: "value", value: toJson(persisted.schema, column.value) }]; + const text = encoder.encode(JSON.stringify(column.values.map(value => toJson(persisted.schema, value)))); + return [{ component, kind: "values", offset: place(text), length: text.byteLength }]; + }); + return { count: table.count, entities, columns }; + }); + const header: Header = { + components: snapshot.components.map(({ name, schema }) => ({ name, schema })), + resources: snapshot.resources.map(({ persisted, value }) => ({ name: persisted.name, schema: persisted.schema, value: toJson(persisted.schema, value) })), + tables, + }; + const text = encoder.encode(JSON.stringify(header)); + const headerLength = aligned(text.byteLength); + const bytes = new Uint8Array(prefix + headerLength + offset); + bytes.set(magic, 0); + const view = new DataView(bytes.buffer); + view.setUint32(4, version, true); + view.setUint32(8, headerLength, true); + bytes.set(text, prefix); + bytes.fill(0x20, prefix + text.byteLength, prefix + headerLength); + let at = prefix + headerLength; + for (const block of blocks) { + bytes.set(block, at); + at = aligned(at + block.byteLength); + } + return bytes; +}; + +const isObject = (value: unknown): value is object => typeof value === "object" && value !== null; + +/** The header's JSON, checked enough to read it without casts, or null. */ +const headerOf = (json: unknown): Header | null => { + if (!isObject(json)) return null; + const components: unknown = Reflect.get(json, "components"); + const resources: unknown = Reflect.get(json, "resources"); + const tables: unknown = Reflect.get(json, "tables"); + if (!Array.isArray(components) || !Array.isArray(resources) || !Array.isArray(tables)) return null; + const named = (item: unknown): boolean => isObject(item) && typeof Reflect.get(item, "name") === "string" && isObject(Reflect.get(item, "schema")); + if (!components.every(named) || !resources.every(named)) return null; + const isColumn = (item: unknown): boolean => { + if (!isObject(item) || typeof Reflect.get(item, "component") !== "number") return false; + const kind: unknown = Reflect.get(item, "kind"); + if (kind === "value") return true; + const numbers = kind === "bytes" ? ["offset", "length", "stride"] : kind === "values" ? ["offset", "length"] : null; + return numbers !== null && numbers.every(name => typeof Reflect.get(item, name) === "number"); + }; + const isTable = (item: unknown): boolean => { + if (!isObject(item) || typeof Reflect.get(item, "count") !== "number" || typeof Reflect.get(item, "entities") !== "number") return false; + const columns: unknown = Reflect.get(item, "columns"); + return Array.isArray(columns) && columns.every(isColumn); + }; + if (!tables.every(isTable)) return null; + // Checked above: each item has the fields the header's type names, of their types. + const header = json as Header; + return header; +}; + +/** Whether two schemas are the same, by their JSON in their properties' order, which places a struct's fields. */ +const sameSchema = (a: Schema, b: Schema): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** + * The snapshot contents `bytes` holds, its components and resources by name as this program persists them, its blocks of + * bytes viewed where they are; or why it can't be read: it isn't one, or names what this program doesn't persist, or + * persists with another schema. + */ +export const decodeContents = (bytes: Uint8Array): Contents | Failure => { + if (bytes.byteLength < prefix || magic.some((byte, at) => bytes[at] !== byte)) return { failure: "These bytes aren't a snapshot." }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const found = view.getUint32(4, true); + if (found !== version) return { failure: `This snapshot's format is version ${found}; this package reads version ${version}.` }; + const headerLength = view.getUint32(8, true); + let json: unknown; + try { + json = JSON.parse(decoder.decode(bytes.subarray(prefix, prefix + headerLength))); + } + catch { + return { failure: "This snapshot's header isn't JSON." }; + } + const header = headerOf(json); + if (header === null) return { failure: "This snapshot's header isn't one this package writes." }; + const program = programOf(); + if (typeof program === "string") return { failure: program }; + const components: Persisted[] = []; + for (const { name, schema } of header.components) { + const persisted = program.components.get(name); + if (persisted === undefined) return { failure: `This snapshot holds component "${name}", which this program doesn't persist.` }; + if (!sameSchema(persisted.schema, schema)) return { failure: `Component "${name}" has another schema here than in this snapshot.` }; + components.push(persisted); + } + const resources: { persisted: Persisted; value: unknown }[] = []; + for (const { name, schema, value } of header.resources) { + const persisted = program.resources.get(name); + if (persisted === undefined) return { failure: `This snapshot holds resource "${name}", which this program doesn't persist.` }; + if (!sameSchema(persisted.schema, schema)) return { failure: `Resource "${name}" has another schema here than in this snapshot.` }; + resources.push({ persisted, value: fromJson(schema, value) }); + } + // Blocks are 8-byte aligned in the buffer the format was written to; one given at another offset is copied once. + const blocks = (bytes.byteOffset + prefix + headerLength) % 8 === 0 ? bytes : bytes.slice(); + const base = blocks.byteOffset + prefix + headerLength; + const end = blocks.byteOffset + blocks.byteLength; + const inBounds = (offset: number, length: number): boolean => offset >= 0 && length >= 0 && base + offset + length <= end; + const tables: World.Table[] = []; + for (const table of header.tables) { + if (!inBounds(table.entities, table.count * 8)) return { failure: "This snapshot's blocks are cut short." }; + const columns: { [key: symbol]: World.Table.Column } = {}; + for (const column of table.columns) { + const persisted = components[column.component]; + if (persisted === undefined) return { failure: "This snapshot names a component it doesn't hold." }; + if (column.kind === "value") columns[persisted.key] = { kind: "value", value: fromJson(persisted.schema, column.value) }; + else if (!inBounds(column.offset, column.length)) return { failure: "This snapshot's blocks are cut short." }; + else if (column.kind === "bytes") columns[persisted.key] = { kind: "bytes", bytes: new Uint8Array(blocks.buffer, base + column.offset, column.length), stride: column.stride }; + else { + let values: unknown; + try { + values = JSON.parse(decoder.decode(new Uint8Array(blocks.buffer, base + column.offset, column.length))); + } + catch { + return { failure: `Component "${persisted.name}"'s values in this snapshot aren't JSON.` }; + } + if (!Array.isArray(values)) return { failure: `Component "${persisted.name}"'s values in this snapshot aren't a list.` }; + columns[persisted.key] = { kind: "values", values: values.map(value => fromJson(persisted.schema, value)) }; + } + } + tables.push({ count: table.count, entities: new Float64Array(blocks.buffer, base + table.entities, table.count), columns }); + } + return { components, tables, resources }; +}; diff --git a/ecs/packages/ecs-persistence/src/index.ts b/ecs/packages/ecs-persistence/src/index.ts new file mode 100644 index 00000000..f977c261 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/index.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/** + * Saving, loading and copying the entities of `@adobe/ecs` worlds: snapshots of their persisted components and + * resources, as binary columns. + * @module @adobe/ecs-persistence + */ +export { decode } from "./decode.js"; +export { encode } from "./encode.js"; +export type { Failure } from "./failure.js"; +export { persist } from "./persist.js"; +export { snapshot, type Snapshot } from "./snapshot.js"; +export { spawn, type Spawned } from "./spawn.js"; diff --git a/ecs/packages/ecs-persistence/src/persist.ts b/ecs/packages/ecs-persistence/src/persist.ts new file mode 100644 index 00000000..54e6896b --- /dev/null +++ b/ecs/packages/ecs-persistence/src/persist.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component, Components } from "@adobe/ecs"; + +/** + * The trait marking a component or resource to persist, its value the stable name saved data names it by: unlike its + * key's id, the name stays the same across versions and moves of its module. Not on a derived component, which its + * rule derives again once its entity is loaded. + */ +export const persist = Symbol(); + +declare module "@adobe/ecs" { + interface Traits { + readonly [persist]: N extends Component.Key ? Components[N] extends { readonly derived: true } ? never : string : string; + } +} diff --git a/ecs/packages/ecs-persistence/src/persistence.test.ts b/ecs/packages/ecs-persistence/src/persistence.test.ts new file mode 100644 index 00000000..397f50a3 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/persistence.test.ts @@ -0,0 +1,222 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity, System, World, workers, type Schema, type Vec3 } from "@adobe/ecs"; +import { describe, expect, it } from "vitest"; +import { decode } from "./decode.js"; +import { encode } from "./encode.js"; +import { persist } from "./persist.js"; +import { snapshot, type Snapshot } from "./snapshot.js"; +import { spawn } from "./spawn.js"; + +const position = Symbol(); +const health = Symbol(); +const level = Symbol(); +const alive = Symbol(); +const color = Symbol(); +const name = Symbol(); +const tags = Symbol(); +const inventory = Symbol(); +const seen = Symbol(); +const team = Symbol(); +const parent = Symbol(); +const target = Symbol(); +const link = Symbol(); +const friends = Symbol(); +const scratch = Symbol(); +const score = Symbol(); +const motto = Symbol(); +const unsaved = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3; [persist]: "position" }; + [health]: { type: number; [persist]: "health" }; + [level]: { type: Schema.Type<{ type: "integer"; minimum: 0; maximum: 99 }>; [persist]: "level" }; + [alive]: { type: boolean; [persist]: "alive" }; + [color]: { type: { readonly r: number; readonly g: number; readonly b: number }; [persist]: "color" }; + [name]: { type: string; [persist]: "name" }; + [tags]: { type: readonly string[]; [persist]: "tags" }; + [inventory]: { type: ReadonlyMap; [persist]: "inventory" }; + [seen]: { type: ReadonlySet; [persist]: "seen" }; + [team]: { type: "red" | "blue"; shared: true; [persist]: "team" }; + [parent]: { type: Entity; reference: "owner"; [persist]: "parent" }; + [target]: { type: Entity; reference: "weak"; [persist]: "target" }; + [link]: { type: { readonly to: Entity; readonly weight: number }; [persist]: "link" }; + [friends]: { type: readonly Entity[]; [persist]: "friends" }; + [scratch]: { type: number }; + } + interface Resources { + [score]: { type: number; default: 0; [persist]: "score" }; + [motto]: { type: string; default: ""; [persist]: "motto" }; + [unsaved]: { type: number; default: 0 }; + } +} + +const inline = () => World.create({ [workers]: 0 }); + +type Seen = { + readonly name: string; + readonly entity: Entity; + readonly values: { readonly [key: string]: unknown }; +}; + +const rows: Seen[] = []; +const readAll = System.create({ readonly: true, components: { with: [name], optional: [position, health, level, alive, color, tags, inventory, seen, team, parent, target, link, friends, scratch] } }, row => { + rows.push({ + name: row[name], entity: row.entity, + values: { + position: row[position], health: row[health], level: row[level], alive: row[alive], color: row[color], tags: row[tags], inventory: row[inventory], + seen: row[seen], team: row[team], parent: row[parent], target: row[target], link: row[link], friends: row[friends], scratch: row[scratch], + }, + }); +}); +const read = async (world: World): Promise => { + rows.length = 0; + await world.run(readAll); + return [...rows].sort((a, b) => a.name.localeCompare(b.name)); +}; + +/** Each entity's values with its entity values given as the names of the entities they name. */ +const named = (seen: readonly Seen[]): { readonly [name: string]: unknown } => { + const names = new Map(seen.map(entry => [entry.entity, entry.name])); + const nameOf = (entity: unknown): unknown => typeof entity === "number" ? entity === Entity.none ? "none" : names.get(entity) ?? `outside ${entity}` : entity; + return Object.fromEntries(seen.map(entry => [entry.name, { + ...entry.values, + parent: nameOf(entry.values["parent"]), target: nameOf(entry.values["target"]), + link: entry.values["link"] === undefined ? undefined : { ...Object(entry.values["link"]), to: nameOf(Reflect.get(Object(entry.values["link"]), "to")) }, + friends: Array.isArray(entry.values["friends"]) ? entry.values["friends"].map(nameOf) : undefined, + }])); +}; + +const scene = System.create(({ commands }) => { + const root = commands.spawn([name, position, health, level, alive, color])({ [name]: "root", [position]: [1, 2, 3], [health]: 0.5, [level]: 7, [alive]: true, [color]: { r: 1, g: 0.5, b: 0 } }); + const child = commands.spawn([name, parent, tags, inventory, seen])({ [name]: "child", [parent]: root, [tags]: ["a", "b"], [inventory]: new Map([["gold", 3]]), [seen]: new Set(["x"]) }); + commands.spawn([name, parent, friends])({ [name]: "grandchild", [parent]: child, [friends]: [root, child] }); + commands.spawn([name, team, target], { [team]: "blue" })({ [name]: "pointer", [target]: child }); + const loop = commands.spawn([name, link])({ [name]: "loop", [link]: { to: Entity.none, weight: 2 } }); + commands.add(loop, link, { to: loop, weight: 2 }); + commands.spawn([name, scratch])({ [name]: "scratched", [scratch]: 9 }); +}); + +const taken = async (input: Parameters[0]): Promise => { + const result = await snapshot(input); + if (result.failure !== undefined) throw new Error(result.failure); + return result; +}; + +const decoded = (bytes: Uint8Array): Snapshot => { + const result = decode(bytes); + if ("failure" in result) throw new Error(result.failure); + return result; +}; + +describe("persistence", () => { + it("saves and loads every kind of value and the persisted resources, each entity value naming the entity loaded for it, cycles and self references included", async () => { + await using from = World.create({ [workers]: 0, [score]: 12, [motto]: "onward", [unsaved]: 3 }); + await from.run(scene); + const before = named(await read(from)); + const bytes = encode(await taken({ world: from })); + const loaded = decoded(bytes); + await using into = inline(); + const spawned = await spawn({ world: into, snapshot: loaded }); + expect("failure" in spawned).toBe(false); + const after = named(await read(into)); + // What isn't persisted, an unpersisted component, isn't loaded. + expect(after).toEqual({ ...before, scratched: { ...Object(before["scratched"]), scratch: undefined } }); + const resources = await into.run(System.create({ readonly: true }, ({ resources }) => [resources[score], resources[motto], resources[unsaved]])); + expect(resources).toEqual([12, "onward", 0]); + }); + + it("merges into a world holding entities, its entities new ones", async () => { + await using from = inline(); + await from.run(scene); + await using into = inline(); + await into.run(scene); + const there = await read(into); + const copied = await spawn({ world: into, snapshot: await taken({ world: from, resources: false }) }); + expect("failure" in copied).toBe(false); + const all = await read(into); + expect(all.length).toBe(there.length * 2); + const roots = all.filter(entry => entry.name === "root").map(entry => entry.entity); + expect(new Set(roots).size).toBe(2); + const children = all.filter(entry => entry.name === "child"); + expect(children.map(entry => entry.values["parent"]).sort()).toEqual([...roots].sort()); + }); + + it("takes an owner subtree, every entity it owns through others included", async () => { + await using world = inline(); + await world.run(scene); + const before = await read(world); + const root = before.find(entry => entry.name === "root")?.entity ?? Entity.none; + const subtree = await taken({ world, root }); + await using other = inline(); + const spawned = await spawn({ world: other, snapshot: subtree }); + if (spawned.failure !== undefined) throw new Error(spawned.failure); + expect(spawned.entities.size).toBe(3); + expect(spawned.dropped).toBe(0); + const spawnedRoot = spawned.entities.get(root); + expect(named(await read(other))).toMatchObject({ root: {} }); + expect((await read(other)).find(entry => entry.name === "root")?.entity).toBe(spawnedRoot); + expect(named(await read(other))).toMatchObject({ child: { parent: "root" }, grandchild: { parent: "child", friends: ["root", "child"] } }); + }); + + it("leaves the map of entities out of what it spawned when asked to, and spawns the same", async () => { + await using world = inline(); + await world.run(scene); + const all = await taken({ world, resources: false }); + await using other = inline(); + const spawned = await spawn({ world: other, snapshot: all, entities: false }); + if (spawned.failure !== undefined) throw new Error(spawned.failure); + expect(spawned).toEqual({ dropped: 0 }); + expect((await read(other)).length).toBe((await read(world)).length); + }); + + it("spawns nothing owned, directly or through others, by an entity outside the snapshot, saying how many", async () => { + await using world = inline(); + await world.run(scene); + const child = (await read(world)).find(entry => entry.name === "child")?.entity ?? Entity.none; + // The child's owner, the root, isn't in its subtree. + const subtree = await taken({ world, root: child }); + await using other = inline(); + const spawned = await spawn({ world: other, snapshot: subtree }); + if (spawned.failure !== undefined) throw new Error(spawned.failure); + expect(spawned.dropped).toBe(2); + expect(spawned.entities.get(child)).toBeNull(); + expect(await read(other)).toEqual([]); + }); + + it("takes the entities a query matches, each loaded without a weak reference to an entity outside them", async () => { + await using world = inline(); + await world.run(scene); + const pointers = await taken({ world, with: [target] }); + await using other = inline(); + const spawned = await spawn({ world: other, snapshot: decoded(encode(pointers)) }); + expect("failure" in spawned).toBe(false); + expect(named(await read(other))).toEqual({ pointer: expect.objectContaining({ team: "blue", target: undefined }) }); + }); + + it("refuses bytes that aren't a snapshot, a component this program doesn't persist, and one with another schema", async () => { + await using world = inline(); + await world.run(scene); + const bytes = encode(await taken({ world })); + expect(decode(new Uint8Array(32))).toEqual({ failure: "These bytes aren't a snapshot." }); + const text = new TextDecoder().decode(bytes); + const edited = (from: string, to: string): Uint8Array => { + // Edits of the same length keep every offset in place. + expect(to.length).toBe(from.length); + return new TextEncoder().encode(text.replace(from, to)); + }; + expect(decode(edited(`"name":"health"`, `"name":"hea1th"`))).toEqual({ failure: `This snapshot holds component "hea1th", which this program doesn't persist.` }); + expect(decode(edited(`"maximum":99`, `"maximum":98`))).toEqual({ failure: `Component "level" has another schema here than in this snapshot.` }); + expect(decode(bytes.subarray(0, bytes.byteLength - 8))).toEqual({ failure: "This snapshot's blocks are cut short." }); + }); + + it("loads bytes given at an offset that isn't a multiple of 8", async () => { + await using world = inline(); + await world.run(scene); + const bytes = encode(await taken({ world })); + const shifted = new Uint8Array(bytes.byteLength + 3); + shifted.set(bytes, 3); + await using other = inline(); + expect("failure" in await spawn({ world: other, snapshot: decoded(shifted.subarray(3)) })).toBe(false); + expect((await read(other)).length).toBe((await read(world)).length); + }); +}); diff --git a/ecs/packages/ecs-persistence/src/program/contents.ts b/ecs/packages/ecs-persistence/src/program/contents.ts new file mode 100644 index 00000000..aaf3c305 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/program/contents.ts @@ -0,0 +1,17 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component, Resource, World } from "@adobe/ecs"; +import type { Persisted } from "./program.js"; + +/** Where a snapshot holds what it holds, a key only this package uses. */ +export const contents: unique symbol = Symbol("snapshot contents"); + +/** + * The persisted part of some of a world's entities: of each archetype, its entities and a column of each persisted + * component it holds, keyed by this program's keys; and the values of persisted resources. + */ +export type Contents = { + /** Each persisted component a table of it may hold. */ + readonly components: readonly Persisted[]; + readonly tables: readonly World.Table[]; + readonly resources: readonly { readonly persisted: Persisted; readonly value: unknown }[]; +}; diff --git a/ecs/packages/ecs-persistence/src/program/program.ts b/ecs/packages/ecs-persistence/src/program/program.ts new file mode 100644 index 00000000..518583d9 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/program/program.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component, Resource, type Schema } from "@adobe/ecs"; +import { persist } from "../persist.js"; + +/** A key persisted, with its name and schema. */ +export type Persisted = { readonly name: string; readonly key: K; readonly schema: Schema }; + +/** What the program persists: its components and resources by name. */ +export type Program = { + readonly components: ReadonlyMap>; + readonly resources: ReadonlyMap>; +}; + +/** The keys of `declarations` with a `persist` trait by name, or why one can't persist: it isn't data, or a name is taken. */ +const namedIn = (declarations: ReadonlyMap, kind: string): ReadonlyMap> | string => { + const named = new Map>(); + for (const [key, declaration] of declarations) { + const name: unknown = declaration[persist]; + if (typeof name !== "string") continue; + if (declaration.type === undefined) return `The ${kind} persisted as "${name}" isn't data: it has no schema.`; + if (named.has(name)) return `Two ${kind}s are persisted as "${name}".`; + named.set(name, { name, key, schema: declaration.type }); + } + return named; +}; + +/** What the program persists, as its modules registered so far declare it, or why it can't. */ +export const programOf = (): Program | string => { + const components = namedIn(Component.declarations(), "component"); + if (typeof components === "string") return components; + const resources = namedIn(Resource.declarations(), "resource"); + if (typeof resources === "string") return resources; + return { components, resources }; +}; diff --git a/ecs/packages/ecs-persistence/src/selection/selection.ts b/ecs/packages/ecs-persistence/src/selection/selection.ts new file mode 100644 index 00000000..32856d86 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/selection/selection.ts @@ -0,0 +1,96 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component, System, type Entity, type Resource, type World } from "@adobe/ecs"; +import type { Failure } from "../failure.js"; +import type { Contents } from "../program/contents.js"; +import type { Program } from "../program/program.js"; + +/** + * Which entities a snapshot takes: those matching `with` and `not`, every one holding a persisted component by + * default; or `root` and every entity it owns, directly or through others, by persisted owner references. + */ +export type Selection = + | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly resources?: boolean } + | { readonly root: Entity }; + +/** Which tables a snapshot reads: those of its persisted components, matching `with` and `not`, and its resources. */ +type Reading = { + readonly components: readonly Component.Key[]; + readonly with?: readonly Component.Key[]; + readonly not?: readonly Component.Key[]; + readonly resources?: readonly Resource.Key[]; +}; + +/** A readonly system reading the tables `declaration` names. */ +const tablesSystemOf = (declaration: Reading) => + System.create({ readonly: true, tables: declaration }, ({ tables }) => tables); + +/** The rows of `table` whose entities `keep` takes, as a table of their own. */ +const rowsOf = (table: World.Table, keep: (entity: number) => boolean): World.Table | null => { + const rows: number[] = []; + for (let row = 0; row < table.count; row++) if (keep(table.entities[row])) rows.push(row); + if (rows.length === table.count) return table; + if (rows.length === 0) return null; + const columns: { [key: symbol]: World.Table.Column } = {}; + for (const key of Object.getOwnPropertySymbols(table.columns)) { + const column: World.Table.Column | undefined = Reflect.get(table.columns, key); + if (column === undefined) continue; + if (column.kind === "value") columns[key] = column; + else if (column.kind === "values") columns[key] = { kind: "values", values: rows.map(row => column.values[row]) }; + else { + const { stride } = column; + const bytes = new Uint8Array(rows.length * stride); + rows.forEach((row, at) => bytes.set(column.bytes.subarray(row * stride, (row + 1) * stride), at * stride)); + columns[key] = { kind: "bytes", bytes, stride }; + } + } + return { count: rows.length, entities: Float64Array.from(rows, row => table.entities[row]), columns }; +}; + +/** The entities `root` owns, itself included, through the owner references among `owners` in the tables. */ +const subtreeOf = (tables: readonly World.Table[], owners: readonly Component.Key[], root: Entity): ReadonlySet => { + const children = new Map(); + for (const table of tables) { + for (const reference of owners) { + const key: symbol = reference; + const column: World.Table.Column | undefined = Reflect.get(table.columns, key); + if (column?.kind !== "bytes") continue; + const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength); + for (let row = 0; row < table.count; row++) { + const owner = view.getFloat64(row * column.stride, true); + const list = children.get(owner); + if (list === undefined) children.set(owner, [table.entities[row]]); + else list.push(table.entities[row]); + } + } + } + const taken = new Set([root]); + const waiting: number[] = [root]; + for (let next = waiting.pop(); next !== undefined; next = waiting.pop()) { + for (const child of children.get(next) ?? []) { + if (taken.has(child)) continue; + taken.add(child); + waiting.push(child); + } + } + return taken; +}; + +/** The persisted part of the entities `selection` takes from `world`, on its thread, or why it can't be read. */ +export const select = async (world: World, program: Program, selection: Selection): Promise => { + const components = [...program.components.values()]; + const keys = components.map(persisted => persisted.key); + const subtree = "root" in selection; + const resources = subtree || selection.resources === false ? [] : [...program.resources.values()]; + const declaration: Reading = subtree + ? { components: keys } + : { components: keys, with: selection.with ?? [], not: selection.not ?? [], resources: resources.map(persisted => persisted.key) }; + const read = await world.run(tablesSystemOf(declaration)).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) })); + if ("failure" in read) return read; + let tables = read.archetypes; + if (subtree) { + const owners = keys.filter(key => Component.declarations().get(key)?.reference === "owner"); + const taken = subtreeOf(tables, owners, selection.root); + tables = tables.flatMap(table => rowsOf(table, entity => taken.has(entity)) ?? []); + } + return { components, tables, resources: resources.map(persisted => ({ persisted, value: read.resources[persisted.key] })) }; +}; diff --git a/ecs/packages/ecs-persistence/src/snapshot.ts b/ecs/packages/ecs-persistence/src/snapshot.ts new file mode 100644 index 00000000..f1e9ef13 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/snapshot.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { World } from "@adobe/ecs"; +import type { Failure } from "./failure.js"; +import { contents, type Contents } from "./program/contents.js"; +import { programOf } from "./program/program.js"; +import { select, type Selection } from "./selection/selection.js"; + +/** The persisted part of some of a world's entities and its persisted resources, which `encode` and `spawn` take. */ +export type Snapshot = { readonly failure?: undefined; readonly [contents]: Contents }; + +/** + * A snapshot of some of `world`'s entities, read on its thread: by default every entity holding a persisted + * component, and the persisted resources; with `with` and `not`, those a query matches, and the resources unless + * `resources` is false; with `root`, it and every entity it owns, through persisted owner references, without + * resources. Fails, as data, when the program's persisted keys can't be saved or the world can't be read. + */ +export const snapshot = async (input: { readonly world: World } & Selection): Promise => { + const program = programOf(); + if (typeof program === "string") return { failure: program }; + const taken = await select(input.world, program, input); + return "failure" in taken ? taken : { [contents]: taken }; +}; diff --git a/ecs/packages/ecs-persistence/src/spawn.ts b/ecs/packages/ecs-persistence/src/spawn.ts new file mode 100644 index 00000000..4605dbb4 --- /dev/null +++ b/ecs/packages/ecs-persistence/src/spawn.ts @@ -0,0 +1,60 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { System, type Entity, type World } from "@adobe/ecs"; +import type { Failure } from "./failure.js"; +import { contents } from "./program/contents.js"; +import type { Snapshot } from "./snapshot.js"; + +/** + * What `spawn` made: how many of the snapshot's entities it didn't spawn, those owned by an entity outside it; and, + * unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, or null for one it + * didn't spawn. + */ +export type Spawned = { readonly failure?: undefined; readonly dropped: number } + & (Entities extends false ? {} : { readonly entities: ReadonlyMap }); + +const isTables = (value: unknown): value is readonly World.Table[] => Array.isArray(value) && value.every(table => + typeof table === "object" && table !== null && typeof Reflect.get(table, "count") === "number" && Reflect.get(table, "entities") instanceof Float64Array + && typeof Reflect.get(table, "columns") === "object"); + +const isKeyed = (value: unknown): value is { readonly [key: symbol]: unknown } => typeof value === "object" && value !== null; + +/** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities. */ +const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown }) => { + if (!isTables(input.tables) || !isKeyed(input.resources)) throw new Error("A snapshot holds tables and resources."); + return commands.spawnTables(input.tables, input.resources); +}); + +/** + * Spawns an entity in `world` for each of the snapshot's, with its persisted components, and sets the persisted + * resources, on the world's thread, as the world's references would leave them had the entities outside the snapshot + * despawned: each entity value naming one of the snapshot's names the one spawned for it; an entity owned, directly + * or through others, by an entity outside the snapshot isn't spawned, which the result counts; a weak reference to + * one is left out; and any other entity value naming one is `Entity.none`. With `entities: false`, the result leaves + * out its map of entities, which costs about 85 ms and 50 MB at a million. Fails, as data, for a column or + * resource that doesn't fit, spawning nothing. + */ +export function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities: false }): Promise | Failure>; +export function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities?: true }): Promise; +export async function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities?: boolean }): Promise | Failure> { + const { tables, resources } = input.snapshot[contents]; + const values: { [key: symbol]: unknown } = {}; + for (const { persisted, value } of resources) values[persisted.key] = value; + const spawned = await input.world.run(spawnTables, { tables, resources: values }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) })); + if ("failure" in spawned) return spawned; + if (input.entities === false) return { dropped: spawned.dropped }; + return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) }; +} + +/** Each of the tables' entities to the one spawned for it, or null for one that wasn't. */ +const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap => { + const entities = new Map(); + tables.forEach((table, t) => { + const made = spawned[t]; + for (let row = 0; row < table.count; row++) { + // A table's entities and `spawnTables`' are entities, `Entity.none`, 0, for a row it didn't spawn. + const entity = made[row] as Entity; + entities.set(table.entities[row] as Entity, entity === 0 ? null : entity); + } + }); + return entities; +}; diff --git a/ecs/packages/ecs-persistence/src/values/values.test.ts b/ecs/packages/ecs-persistence/src/values/values.test.ts new file mode 100644 index 00000000..40258b0e --- /dev/null +++ b/ecs/packages/ecs-persistence/src/values/values.test.ts @@ -0,0 +1,26 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "@adobe/ecs"; +import { describe, expect, it } from "vitest"; +import { fromJson, toJson } from "./values.js"; + +const through = (schema: Schema, value: unknown): unknown => fromJson(schema, JSON.parse(JSON.stringify(toJson(schema, value)))); + +describe("values as JSON", () => { + it("read back numbers JSON lacks", () => { + const numbers: Schema = { type: "array", items: { type: "number" } }; + expect(through(numbers, [NaN, Infinity, -Infinity, -0, 1.5])).toEqual([NaN, Infinity, -Infinity, -0, 1.5]); + const zero = through(numbers, [-0]); + expect(Array.isArray(zero) && Object.is(zero[0], -0)).toBe(true); + }); + + it("read back maps, sets and objects without the fields they lack", () => { + const schema: Schema = { type: "object", properties: { counts: { type: "map", keys: { type: "string" }, values: { type: "number" } }, seen: { type: "set", items: { type: "string" } }, note: { type: "string" } }, required: ["counts", "seen"] }; + expect(through(schema, { counts: new Map([["a", 1]]), seen: new Set(["x"]) })).toEqual({ counts: new Map([["a", 1]]), seen: new Set(["x"]) }); + }); + + it("read back a union's values as the members they were, a string as a string and a number as a number", () => { + const schema: Schema = { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }, { type: "set", items: { type: "number" } }, { type: "array", items: { type: "number" } }] }; + const values = ["NaN", NaN, 2, null, new Set([1]), [1]]; + expect(values.map(value => through(schema, value))).toEqual(values); + }); +}); diff --git a/ecs/packages/ecs-persistence/src/values/values.ts b/ecs/packages/ecs-persistence/src/values/values.ts new file mode 100644 index 00000000..eed5286a --- /dev/null +++ b/ecs/packages/ecs-persistence/src/values/values.ts @@ -0,0 +1,97 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "@adobe/ecs"; + +// Values of components stored as values, strings and other data that isn't a fixed layout, as JSON their schema reads +// back: a map as its entries, a set as its items, numbers JSON lacks as text, and a union's value as its member's +// index and its JSON. + +/** A number JSON can't hold, as text: `NaN`, `Infinity`, `-Infinity` or `-0`. */ +const numberToJson = (value: number): number | string => + Number.isNaN(value) ? "NaN" : value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : Object.is(value, -0) ? "-0" : value; + +const numberFromJson = (json: unknown): unknown => { + if (typeof json !== "string") return json; + switch (json) { + case "NaN": return NaN; + case "Infinity": return Infinity; + case "-Infinity": return -Infinity; + case "-0": return -0; + default: return json; + } +}; + +/** Whether a value is of the JSON kind a schema's values are, to pick a union's member. */ +const fits = (schema: Schema, value: unknown): boolean => { + if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value); + if ("anyOf" in schema) return schema.anyOf.some(member => fits(member, value)); + if ("allOf" in schema) return schema.allOf.every(member => fits(member, value)); + if (!("type" in schema)) return true; + switch (schema.type) { + case "number": case "integer": return typeof value === "number"; + case "string": return typeof value === "string"; + case "boolean": return typeof value === "boolean"; + case "null": return value === null; + case "array": return Array.isArray(value); + case "set": return value instanceof Set; + case "map": return value instanceof Map; + case "object": return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map); + default: return true; + } +}; + +/** A value of `schema` as JSON. */ +export const toJson = (schema: Schema, value: unknown): unknown => { + if ("anyOf" in schema) { + const index = schema.anyOf.findIndex(candidate => fits(candidate, value)); + const member = schema.anyOf[index]; + return member === undefined ? [-1, value] : [index, toJson(member, value)]; + } + if (typeof value === "number") return numberToJson(value); + if (!("type" in schema)) return value; + switch (schema.type) { + case "array": return Array.isArray(value) ? value.map(item => toJson(schema.items, item)) : value; + case "set": return value instanceof Set ? Array.from(value, item => toJson(schema.items, item)) : value; + case "map": return value instanceof Map ? Array.from(value, ([key, item]) => [toJson(schema.keys, key), toJson(schema.values, item)]) : value; + case "object": { + if (typeof value !== "object" || value === null) return value; + const json: { [name: string]: unknown } = {}; + for (const name of Object.keys(value)) { + const field = schema.properties[name]; + const item: unknown = Reflect.get(value, name); + if (item !== undefined) json[name] = field === undefined ? item : toJson(field, item); + } + return json; + } + default: return value; + } +}; + +/** A value of `schema` read back from its JSON. */ +export const fromJson = (schema: Schema, json: unknown): unknown => { + if ("anyOf" in schema) { + if (!Array.isArray(json) || typeof json[0] !== "number") return json; + const member = schema.anyOf[json[0]]; + return member === undefined ? json[1] : fromJson(member, json[1]); + } + if ("const" in schema) return schema.const; + if (!("type" in schema)) return json; + switch (schema.type) { + case "number": case "integer": return numberFromJson(json); + case "array": return Array.isArray(json) ? json.map(item => fromJson(schema.items, item)) : json; + case "set": return Array.isArray(json) ? new Set(json.map(item => fromJson(schema.items, item))) : json; + case "map": return Array.isArray(json) + ? new Map(json.map(entry => Array.isArray(entry) ? [fromJson(schema.keys, entry[0]), fromJson(schema.values, entry[1])] : [entry, undefined])) + : json; + case "object": { + if (typeof json !== "object" || json === null || Array.isArray(json)) return json; + const value: { [name: string]: unknown } = {}; + for (const name of Object.keys(json)) { + const field = schema.properties[name]; + const item: unknown = Reflect.get(json, name); + value[name] = field === undefined ? item : fromJson(field, item); + } + return value; + } + default: return json; + } +}; diff --git a/ecs/packages/ecs-persistence/tsconfig.build.json b/ecs/packages/ecs-persistence/tsconfig.build.json new file mode 100644 index 00000000..c52de471 --- /dev/null +++ b/ecs/packages/ecs-persistence/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist/types", + "types": [] + }, + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test-helper.ts" + ] +} diff --git a/ecs/packages/ecs-persistence/tsconfig.json b/ecs/packages/ecs-persistence/tsconfig.json new file mode 100644 index 00000000..aada00c6 --- /dev/null +++ b/ecs/packages/ecs-persistence/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024", "ESNext.Disposable", "DOM"], + "types": ["node"], + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "perf", "*.ts"], + // The test configs take the vitest a workspace links: in one holding several variants of vitest, another variant + // than this package's. Vitest checks them as it loads them. + "exclude": ["vitest.config.ts", "vitest.perf.config.ts"] +} diff --git a/ecs/packages/ecs-persistence/vite.config.ts b/ecs/packages/ecs-persistence/vite.config.ts new file mode 100644 index 00000000..a6d92d1a --- /dev/null +++ b/ecs/packages/ecs-persistence/vite.config.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Compiler } from "@adobe/ecs/compiler"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +/** + * The library's build: one module of its sources, which ships the registrations of its trait and its systems' input + * schemas, for the application's build to compile and identify. + */ +export default defineConfig({ + plugins: [Compiler.plugin({ library: true })], + build: { + outDir: fileURLToPath(new URL("dist", import.meta.url)), + emptyOutDir: false, + minify: false, + sourcemap: true, + lib: { entry: fileURLToPath(new URL("src/index.ts", import.meta.url)), formats: ["es"], fileName: "index" }, + rolldownOptions: { external: ["@adobe/ecs", "@adobe/ecs/compiler"] }, + }, +}); diff --git a/ecs/packages/ecs-persistence/vitest.config.ts b/ecs/packages/ecs-persistence/vitest.config.ts new file mode 100644 index 00000000..9330fa6e --- /dev/null +++ b/ecs/packages/ecs-persistence/vitest.config.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Compiler } from "@adobe/ecs/compiler"; +import { defineConfig } from "vitest/config"; + +// Persistence needs no browser: every test runs in node. +export default defineConfig({ + plugins: [Compiler.plugin()], + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/ecs/packages/ecs-persistence/vitest.perf.config.ts b/ecs/packages/ecs-persistence/vitest.perf.config.ts new file mode 100644 index 00000000..9504eba5 --- /dev/null +++ b/ecs/packages/ecs-persistence/vitest.perf.config.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Compiler } from "@adobe/ecs/compiler"; +import { defineConfig } from "vitest/config"; + +// The benchmarks, in node: each prints its timings and asserts nothing about them. +export default defineConfig({ + plugins: [Compiler.plugin()], + test: { + include: ["perf/**/*.perf.ts"], + environment: "node", + testTimeout: 600000, + }, +}); diff --git a/ecs/packages/ecs-todo-vanillajs/src/README.md b/ecs/packages/ecs-todo-vanillajs/src/README.md index d5195851..248952b9 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/README.md +++ b/ecs/packages/ecs-todo-vanillajs/src/README.md @@ -17,18 +17,18 @@ filter, and generate 10 or 1,000 todos. the app starts. Every DOM system that uses one declares it in `resources.is`, so it's skipped in a world without it. - **Repeated elements** are component values on the entity they show, declared on `main`: a todo's `row`. - *Created* by an ordinary system, `createRows`, over todos without a row, which adds the row by a command. It runs - on the tick after the run that added the todo, since observer systems can't issue commands. - - *Bound* by an observer system, `bindRows`, with the row and the values it shows in `changed`: it writes a new row + on the tick after the run that added the todo, since readonly systems can't issue commands. + - *Bound* by a readonly system, `bindRows`, with the row and the values it shows in `changed`: it writes a new row once and then only rows whose todo changed, after each run's and tick's sync point. - - *Placed* by an observer system, `placeRows`, which runs the rows whose row or order changed in list order, through + - *Placed* by a readonly system, `placeRows`, which runs the rows whose row or order changed in list order, through a sorted index, and puts each after the row before it, which is always placed already. A row already in place isn't touched, and one in the list moves with `moveBefore`, never leaving it, so it keeps its focus; where the browser has no `moveBefore`, it gets its focus back. - - *Filtered* by the filter feature's observer system, `filterRows`, which sets `hidden` on rows whose visibility + - *Filtered* by the filter feature's readonly system, `filterRows`, which sets `hidden` on rows whose visibility changes, as their todo or the filter changes, and moves focus off a row it hides to the nearest one shown. - *Removed* by a finalizer on the row, which runs whatever ends the todo, `world.dispose()` included. - Rows out of view skip their rendering (`content-visibility: auto`). -- **Fixed elements showing counts** are bound by an observer system that runs only when an index's entries changed: +- **Fixed elements showing counts** are bound by a readonly system that runs only when an index's entries changed: `bindCompletion` looks up `completion/byDone` with `changed: true`, so it writes the summary and the toggle-all box after a pass in which a todo was added, removed or toggled, and is skipped before its body otherwise. - **Events become runs** in each feature's `events.ts`: listeners on the page's root call `world.run(system, input)` @@ -37,7 +37,7 @@ filter, and generate 10 or 1,000 todos. to its todo (`attach`), and a move's `item` and `before` are read with `entityOfElement`. Systems acting on one todo name it as their `target`, so they run on its row alone, writing it in place, and on nothing once it's gone. - **Failures:** a run whose system failed rejects, and an event's run, its promise left, goes unhandled, which the - browser logs; what fails in ticks and observer systems the world reports with `reportError`, which the browser logs + browser logs; what fails in ticks and readonly systems the world reports with `reportError`, which the browser logs too, once per system while it keeps failing. ## Structure @@ -71,7 +71,7 @@ Adding a feature is a folder and a line in `features/index.ts`. ## Tests -- `*/model.test.ts`, in node: each feature's model systems against a world, read back through observer systems. +- `*/model.test.ts`, in node: each feature's model systems against a world, read back through readonly systems. - `*/elements/*.browser.test.ts`: elements alone, with no world: properties set, the render checked, events recorded. - `shared/attach.browser.test.ts`: the entity an element was attached to, found from an event on it or within its open shadow root, and none for others. @@ -80,7 +80,7 @@ Adding a feature is a folder and a line in `features/index.ts`. off, edited; no rows without a list; none for a todo deleted before its tick; several runs before one tick; all removed on dispose. - `app/app.browser.test.ts`: the whole app on the page's markup, used through DOM events as a user would, with no - console errors; edits committed on blur; a row's events after its todo is deleted; a tick's system's and observer + console errors; edits committed on blur; a row's events after its todo is deleted; a tick's system's and readonly system's failures reported. - `app/proportional.browser.test.ts`: each operation's DOM mutations, and how often the DOM systems reach rows, are the same with 100 todos as with 2,000, less what each row it changed costs. @@ -92,9 +92,9 @@ Adding a feature is a folder and a line in `features/index.ts`. ## Performance -Apple M1 Max, Chromium, with other benchmarks running on the machine. A run's time includes the observer systems +Apple M1 Max, Chromium, with other benchmarks running on the machine. A run's time includes the readonly systems after it; each is the median of 21 runs, each made after a rendered frame as an event's would be, unless it's a single -pass. The tick after it is timed from its frame's start to the end of its observer systems. Mutations count the page's +pass. The tick after it is timed from its frame's start to the end of its readonly systems. Mutations count the page's and its shadow roots' records until that tick ends. | Operation | 1K run ms | 10K run ms | Tick after, ms | Mutations | @@ -120,12 +120,12 @@ world, and toggling one at 26 µs with 1K todos, 62 µs with 10K and 400 µs wit What the current API does and doesn't offer the DOM path, each with the change it would take. -- **Observer systems can't issue commands,** so a row is created by an ordinary system on the tick after the run that +- **Readonly systems can't issue commands,** so a row is created by an ordinary system on the tick after the run that added its todo. That tick runs at the next animation frame, before that frame is painted, so a row added in response to an event shows in the frame that would have shown it anyway, at 0.05–0.08 ms. *No change needed.* -- **Observer systems can't declare `up`.** A view entity of its own, `{ view, viewOf }`, can't be bound by an observer +- **Readonly systems can't declare `up`.** A view entity of its own, `{ view, viewOf }`, can't be bound by a readonly system through `up: { through: viewOf }`; as a ticking system, its writes come a tick late, and a new view's first only on - the tick after it's made. *Proposal:* allow `up` in observer systems, which only read through it. + the tick after it's made. *Proposal:* allow `up` in readonly systems, which only read through it. - **`order` can't go with `up`.** Views can't be placed in their models' order from a system over the views. *Proposal:* let `order` take an index over the entity a reference names, or allow it with `up`. - **No index finds an entity by its element.** Object parts of index keys are interned by structure @@ -138,14 +138,14 @@ What the current API does and doesn't offer the DOM path, each with the change i any component might be on another thread; its body then reads by `Reflect`. *Proposal:* none unless bindings get their own constructor; a binding is four to six lines. - **A run's fixed cost** in Chromium is small, and none of it is a task: a run that changes nothing takes 1.0 µs back - 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 + to back in an empty world, at most 0.3 µs more with the app's readonly 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 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`, which the finding after it removes. The rest is - the sync point, the observer pass and the DOM writes, about 13 µs. + the one chunk that changed, for each readonly system with `changed`, which the finding after it removes. The rest is + the sync point, the readonly 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, @@ -154,11 +154,11 @@ What the current API does and doesn't offer the DOM path, each with the change i sorts both buckets first: 6.2, 12.7 and 69 µs (`bucket move … in order`), against 9.4, 65 and 418. - **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 + filters take 5.0% of samples, against 6.9% scanning the chunk, most of it the chunk checks of readonly 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 +- **Nothing awaits the next tick.** Tests watch `clock` changes through a readonly system, after a first run that passes it + regardless, and a world ticks only while a system that isn't a readonly system is added. *Proposal:* none for consumers; a test helper in `src/observe` would do. ## Not kept diff --git a/ecs/packages/ecs-todo-vanillajs/src/app/app.browser.test.ts b/ecs/packages/ecs-todo-vanillajs/src/app/app.browser.test.ts index 239a0832..c8b45d05 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/app/app.browser.test.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/app/app.browser.test.ts @@ -185,10 +185,10 @@ describe("the todo app", () => { const failing = System.create({ components: { with: [todo] } }, () => { throw new Error("a tick's system failed"); }); -const failingObserver = System.create({ observer: true, components: { with: [row], changed: [row] } }, () => { +const failingObserver = System.create({ readonly: true, components: { with: [row], changed: [row] } }, () => { throw new Error("a tick's observer failed"); }); -const pass = System.create({ observer: true }, () => {}); +const pass = System.create({ readonly: true }, () => {}); describe("the todo app's failures", () => { /** What the world reported, as `error` events on the page, which the browser logs unless they're handled. */ diff --git a/ecs/packages/ecs-todo-vanillajs/src/app/todo.perf-browser-test.ts b/ecs/packages/ecs-todo-vanillajs/src/app/todo.perf-browser-test.ts index 26ca1c25..970e4761 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/app/todo.perf-browser-test.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/app/todo.perf-browser-test.ts @@ -164,7 +164,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, + readonly: true, resources: { changed: [filter], is: [list] }, }, ({ resources }) => { resources[list].dataset.filter = resources[filter]; diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/completion/dom.ts b/ecs/packages/ecs-todo-vanillajs/src/features/completion/dom.ts index 74a9043a..ab8ccb9b 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/completion/dom.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/features/completion/dom.ts @@ -20,7 +20,7 @@ declare module "@adobe/ecs" { * removed or toggled: the index's entries changing is its only change filter. */ export const bindCompletion = System.create({ - observer: true, + readonly: true, resources: { is: [summary, toggleAllBox] }, lookup: { byDone: { index: byDone, changed: true } }, }, ({ resources, lookup }) => { diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/completion/model.test.ts b/ecs/packages/ecs-todo-vanillajs/src/features/completion/model.test.ts index 7739ac90..926d727a 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/completion/model.test.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/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: { with: [done], changed: [done] } }, () => { +const countWrites = System.create({ readonly: true, components: { with: [done], changed: [done] } }, () => { writes += 1; }); diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/filter/dom.ts b/ecs/packages/ecs-todo-vanillajs/src/features/filter/dom.ts index ab329baa..f4b3dd8a 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/filter/dom.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/features/filter/dom.ts @@ -38,7 +38,7 @@ const focusNearest = (hiding: Element, chosen: Filter): void => { * README). */ export const filterRows = System.create({ - observer: true, + readonly: true, components: { with: [done, row], changed: [done, row] }, resources: { changed: [filter] }, }, (item, { resources }) => { @@ -50,7 +50,7 @@ export const filterRows = System.create({ }); export const bindFilters = System.create({ - observer: true, + readonly: true, resources: { changed: [filter], is: [filters] }, }, ({ resources }) => { resources[filters].filter = resources[filter]; diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/filter/model.test.ts b/ecs/packages/ecs-todo-vanillajs/src/features/filter/model.test.ts index a541b0b3..63f0eb09 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/filter/model.test.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/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 }) => { +const readFilter = System.create({ readonly: true }, ({ resources }) => { seen = resources[filter]; }); diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.browser.test.ts b/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.browser.test.ts index e4cdc1de..225f3bb0 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.browser.test.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/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: { with: [row] } }, () => { +const countRows = System.create({ readonly: true, components: { with: [row] } }, () => { rows += 1; }); diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.ts b/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.ts index 808c05ee..c3a4d3fb 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/features/todos/dom.ts @@ -35,7 +35,7 @@ export const createRows = System.create({ /** Writes what changed of each todo to its row. */ export const bindRows = System.create({ - observer: true, + readonly: true, components: { with: [label, done, row], changed: [label, done, row] }, }, item => { const element = item[row]; @@ -64,7 +64,7 @@ const place = (parent: Element, element: HTMLElement, before: Element | null): v * its focus. */ export const placeRows = System.create({ - observer: true, + readonly: true, components: { with: [row, order], changed: [row, order] }, order: placed, resources: { is: [list] }, diff --git a/ecs/packages/ecs-todo-vanillajs/src/features/todos/todos.test-helper.ts b/ecs/packages/ecs-todo-vanillajs/src/features/todos/todos.test-helper.ts index 3444968b..cfa8dd13 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/features/todos/todos.test-helper.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/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: { with: [label, done, order] }, order: ordered }, row => { +const readTodos = System.create({ readonly: 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/ecs/packages/ecs-todo-vanillajs/src/shared/ticks.test-helper.ts b/ecs/packages/ecs-todo-vanillajs/src/shared/ticks.test-helper.ts index bb102da3..0f637602 100644 --- a/ecs/packages/ecs-todo-vanillajs/src/shared/ticks.test-helper.ts +++ b/ecs/packages/ecs-todo-vanillajs/src/shared/ticks.test-helper.ts @@ -4,7 +4,7 @@ import { System, clock, type World } from "@adobe/ecs"; const waiting: (() => void)[] = []; /** Passes after each tick, the only passes that change the clock, and on its first. */ -const ticked = System.create({ observer: true, resources: { changed: [clock] } }, () => { +const ticked = System.create({ readonly: true, resources: { changed: [clock] } }, () => { for (const resolve of waiting.splice(0)) resolve(); }); diff --git a/ecs/packages/ecs-transfers-canvas/src/README.md b/ecs/packages/ecs-transfers-canvas/src/README.md index 43d4783a..b8c076fc 100644 --- a/ecs/packages/ecs-transfers-canvas/src/README.md +++ b/ecs/packages/ecs-transfers-canvas/src/README.md @@ -12,7 +12,7 @@ counts the sprites and frames. - **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 + `attachSurface`, and adds `show`, a readonly 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. diff --git a/ecs/packages/ecs-transfers-canvas/src/main.ts b/ecs/packages/ecs-transfers-canvas/src/main.ts index 0554dc98..a145e607 100644 --- a/ecs/packages/ecs-transfers-canvas/src/main.ts +++ b/ecs/packages/ecs-transfers-canvas/src/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: { changed: [stats] } }, ({ resources }) => { +const show = System.create({ readonly: 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/ecs/packages/ecs/README.md b/ecs/packages/ecs/README.md index 1f988c74..55eb94aa 100644 --- a/ecs/packages/ecs/README.md +++ b/ecs/packages/ecs/README.md @@ -22,7 +22,7 @@ A high-performance, multithreaded Entity Component System for web-based applicat - 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 + - Readonly systems for reading state into a UI - **Changes** - 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 @@ -33,7 +33,7 @@ A high-performance, multithreaded Entity Component System for web-based applicat - 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 + - Joined threads run their own systems and readonly systems, and lend their workers to the pool - **Runtime** - Kernels allocate nothing per row; commands and lookups reuse their buffers - Runs resolve to what a body returns, and reject naming each system that failed; ticks' failures are reported out of diff --git a/ecs/packages/ecs/design.md b/ecs/packages/ecs/design.md index 68951495..60ce4e28 100644 --- a/ecs/packages/ecs/design.md +++ b/ecs/packages/ecs/design.md @@ -29,7 +29,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - 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. + - **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 a readonly system 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 a readonly 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. @@ -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 (`Shapes.circle`). 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. + - **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 (`Shapes.circle`). 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; a readonly system'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. @@ -61,7 +61,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **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. - - **Observer systems see values a GPU kernel wrote:** the observer pass reads back what the GPU wrote that its systems read, before they run. + - **Readonly systems see values a GPU kernel wrote:** the readonly pass reads back what the GPU wrote that its systems read, before they run. - **GPU errors:** a run's GPU work, its render bodies' included, runs in validation and out-of-memory error scopes; once the GPU gives their errors, the first is a `"gpu"` failure reported out of band, under the system whose run submitted it, since waiting for them would hold every run until the GPU process catches up; a later run of that system whose scopes give none takes it as recovered. - **Numbers on the GPU:** arithmetic is f32. Integer column reads, bitwise operators and locals assigned only integers stay i32 or u32, so integer chains keep every bit, and stores wrap as in JS. WGSL is the standard where JS differs only for NaN or infinite inputs or results: WGSL leaves those indeterminate, and GPU kernels use its own `min`, `max`, `%`, truthiness and conversions there. Where JS differs for finite values, as `Math.round` and `**` do, the GPU keeps JS's result; the math types follow WGSL instead. @@ -103,7 +103,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`where`** (`components: { with, where: { [team]: "red", [cell]: (cell, { resources }) => … } }`) keeps a system to the matched archetypes whose shared values pass: a value by equality, its number interned once, a predicate by what it returns. Its keys are shared components, required as `with`'s are; not with `up`, `order` or a target, nor on render systems or lookups. - **Predicates** read their value, the world's resources, literals, and module-scope constants and pure functions: the build checks each in place (`compiler/predicates.ts`), following functions it calls through the linker, and fails at what may change outside the world, a module variable, an object constant, a global, or a name a function around it declares, and at what may change what it reads or isn't pure: a write or `delete` through anything but an object or array it wrote out itself (its value, resources, parameters, globals and function objects may be shared), a call on such a value of a method that changes what it's called on (`push`, `sort`, `set`, `fill` and their kin, or one named by a computed key), a member of `Math` or `Number` but their listed pure ones (never `Math.random`), and `import()`. It infers the resources they read, as it does a body's, into the access record's `where`; they count as the system's resource reads for order, conflicts and placement. - **Evaluation** (`world/where.ts`): as written, on the thread that runs the system, the world's or a joined thread's for one placed or added there: once per archetype as it appears (when the system registers, or a sync point makes one), and again for every matched archetype when a resource a predicate reads changed since they were last evaluated; otherwise the cached result stands. A run's archetypes are those that pass, in a buffer that only grows, rebuilt only when a result changed, so a run allocates nothing. Workers, WASM and GPU dispatches see only those. - - **Change filters:** with `where` and change filters, each (system, matched archetype) keeps its own last-ran point: one `where` skipped keeps its point, so when it passes again its rows count as changed since the system last ran over it, kernels taking that point as the call's `lastRun`; resource filters and indexes' `changed: true` pass an archetype's rows by its point too, and a system filtered only by indexes is skipped before its body unless one changed since its oldest point. A new archetype starts at the system's last run. Rows moving carry their debts: once such a system registered, or a joined thread holds claims, sync points log each block of rows moved between archetypes in the arrivals ring (`arrivals.ts`: from, to and tick, in shared memory, its offset in the directory header), and each such system reads it as it runs, in order, from its own cursor: the archetype moved into owes the older of what it owed and the point the archetype moved from has (its own, or the last run for one the system doesn't match), and running over it settles that. Its rows pass the change filters since the older of its point and what it owes, so a row moved out of an archetype it skipped is counted changed if it changed after the system last saw it there, however many moves and runs over other archetypes came between; a move between archetypes it saw costs nothing, while an archetype owing counts its other rows changed since that older point too, as `changed` is conservative. Reading costs each run the moves since its last, never its matches. A system that fell more than the ring's 4,096 entries behind owes, for every archetype, its oldest point, at that run and the next. A system without `where`, or without change filters, keeps its single `lastRun`. On a joined thread, its points are kept there: an observer system there with `where` and change filters is posted after every sync point, and a snapshot of its rows copies every matched row; systems with `where` don't run as tails. + - **Change filters:** with `where` and change filters, each (system, matched archetype) keeps its own last-ran point: one `where` skipped keeps its point, so when it passes again its rows count as changed since the system last ran over it, kernels taking that point as the call's `lastRun`; resource filters and indexes' `changed: true` pass an archetype's rows by its point too, and a system filtered only by indexes is skipped before its body unless one changed since its oldest point. A new archetype starts at the system's last run. Rows moving carry their debts: once such a system registered, or a joined thread holds claims, sync points log each block of rows moved between archetypes in the arrivals ring (`arrivals.ts`: from, to and tick, in shared memory, its offset in the directory header), and each such system reads it as it runs, in order, from its own cursor: the archetype moved into owes the older of what it owed and the point the archetype moved from has (its own, or the last run for one the system doesn't match), and running over it settles that. Its rows pass the change filters since the older of its point and what it owes, so a row moved out of an archetype it skipped is counted changed if it changed after the system last saw it there, however many moves and runs over other archetypes came between; a move between archetypes it saw costs nothing, while an archetype owing counts its other rows changed since that older point too, as `changed` is conservative. Reading costs each run the moves since its last, never its matches. A system that fell more than the ring's 4,096 entries behind owes, for every archetype, its oldest point, at that run and the next. A system without `where`, or without change filters, keeps its single `lastRun`. On a joined thread, its points are kept there: a readonly system there with `where` and change filters is posted after every sync point, and a snapshot of its rows copies every matched row; systems with `where` don't run as tails. - **Kernels** read a shared component of their rows as a constant per call: the build knows it's shared from its declaration's `shared` trait, which it reads as it reads `reduce` (`augmentations.ts`, `Link.sharedOf`), and compiles `row[team]` to the IR's `shared` expression, a leaf of `KernelDescription.shared[index]`. JS kernels take the call's archetype's values as their last parameter, `shared`, and read `h0`, `h0[1]`, as they read resources; WASM takes each leaf as a scalar parameter after the lookups', computed per call by the runner, and posted with the call's scalars to workers, or with its `run` message; WGSL reads each from its dispatch's record, whose words follow the columns' addresses, an integer leaf as an integer and a boolean as a word, as their columns would hold them. The values are those rows read: a JS or WASM kernel's results stay bit-exact with the body as written. On the GPU, which computes in f32 (Precision, below), a shared f32 value read and stored, an integer leaf, and one product, sum or difference of two f32s are bit-exact, since JS's f64 result of those rounds to the same f32; longer arithmetic, a quotient or a sum of products, rounds each step in f32 where JS rounds once at the store, so it differs by a few roundings of its largest term (`shared-gpu.browser.test.ts`: at most 1.65e-7 of it, against a bound of 2^-21). Kernels never write one, read one optional, read one through a lookup or `up`, or issue commands naming one, at the build (a tried body then runs as written) and at registration, which also refuses a field column of a shared component, as a build that didn't know it was shared compiles. - **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 (`Shapes.circle`) 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): @@ -128,7 +128,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`, 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. + - **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 readonly 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. @@ -140,13 +140,13 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`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. - - 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. + - 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; a readonly 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, 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`. + - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in a readonly system 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, constant?)` returns the world's spawner of exactly those components, the same function for a world, set of components and equal constants each time while every shared value among them is held, and on a joined thread for a list and one constants object, which takes the values `constant` doesn't give, or nothing once none is required, and returns a real `Entity`; each component's value comes from one of the two calls, one given in both failing the system, and every shared component's from `constant`; `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. @@ -154,11 +154,11 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **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. - - **Observer systems** (`observer: true`) read what a sync point left, after it, on the world's thread or the joined thread they're placed on or were added on (`threads.md`): rows, resources, lookups and change filters, in no order. They write and reduce into nothing, take no input, aren't kernels or render systems, have no `up` and are ordered with nothing, in types and at creation, and their context has no `commands`. Schedules list a composite's observer members apart from its tiers, never conflicting; constraints naming one are ignored. + - **Readonly systems** (`readonly: true`) read what a sync point left, after it, on the world's thread or the joined thread they're placed on or were added on (`threads.md`): rows, resources, lookups and change filters, in no order. They write and reduce into nothing, take no input, aren't kernels or render systems, have no `up` and are ordered with nothing, in types and at creation, and their context has no `commands`. Schedules list a composite's readonly members apart from its tiers, never conflicting; constraints naming one are ignored. - **Scheduling:** - **Ticks:** `world.add(system)` adds a system to every tick from the next one on, ordered with those already added as a composite's members are; one with no ordering path to an added system it conflicts with throws, as does one with input. A tick runs the added set as one composite, on a beat of the `cadence` resource where `always` is set, `wake` is due or a run arrived since the last tick, and only while some system is added that runs in a tier. Transactions and on-demand systems are runs. - - **The observer pass:** after each run's sync point, once its tails are done, and before `run` resolves, the world reads back what the GPU wrote that observer systems read, or the indexes they look up through hold by, then refreshes those indexes, and runs each once: the run's own, then those added, alone or within added composites, as a later value of the list each pass starts from. A system removed meanwhile, even by an earlier body of the pass, is skipped. What the run's own throw fails the run, as does a unique index's conflict the pass's refresh found; what those added throw is reported out of band. A body returns no promise: by type, and at runtime, where returning a promise or another thenable fails its system, never awaited, so nothing holds the world's runs. `add` never throws for one and keeps it out of the composite ticks run, so adding or removing one leaves that composite as it is, and it never makes the loop tick. A run of observer systems alone has no tiers; with nothing queued, no GPU and no joined thread it skips its sync point, and it doesn't wake the loop. After a tick the pass runs at once with the tick's values, while its tails may still run. Observer systems on joined threads are posted to their threads in the same pass, after those here ran. A run of the world's waits for its own there to run, the post marking it owed to that run: the thread gives back what each body returned, structured-cloned, for the one the run resolves to, or what it threw, which fails the run; a value that can't be posted fails it too, and a thread gone fails it with a `"thread"` failure. What those added throw reaches the world's thread by message and is reported out of band. A joined thread's `run` of one of its own settles there, once its body ran, to what it returned or with what it threw. - - **Removing:** `world.remove(system)` takes a value `add` was given off every tick. A system added alone and within an added composite stays until both are removed, and constraints naming a removed system are ignored, so a removal that leaves two systems that conflict with no ordering path between them throws, removing nothing, as `add` throws for one; a value not added does nothing, one no constructor returned throws, and so does a call on a joined thread for any system but an observer system. Once it returns, nothing calls the removed system's body again: a tick or run in progress reads it as removed from its next tier on, and from its next row on when its own body removed it; a run queued before skips it; and a task already posted to a joined thread holding it skips it there (`threads.md`). Its memory is released: the world forgets its schedule and runner, and those of composites only it was in, releasing what the runner held once the run or tick in progress is done, and a joined thread drops what it held for it. A later `run` of it, or of a composite holding it, registers it again, from `lastRun` 0. An observer system here whose runner holds nothing beyond its JS values (no index, order, GPU or joined thread) keeps that runner, stopped, while its value lives: added or run again once the run that removed it is done, it registers at once with that runner, restarted from `lastRun` 0, so adding it again costs no new runner, context or buffers; added again so, it runs from the next pass, even a queued run's. + - **The readonly pass:** after each run's sync point, once its tails are done, and before `run` resolves, the world reads back what the GPU wrote that readonly systems read, or the indexes they look up through hold by, then refreshes those indexes, and runs each once: the run's own, then those added, alone or within added composites, as a later value of the list each pass starts from. A system removed meanwhile, even by an earlier body of the pass, is skipped. What the run's own throw fails the run, as does a unique index's conflict the pass's refresh found; what those added throw is reported out of band. A body returns no promise: by type, and at runtime, where returning a promise or another thenable fails its system, never awaited, so nothing holds the world's runs. `add` never throws for one and keeps it out of the composite ticks run, so adding or removing one leaves that composite as it is, and it never makes the loop tick. A run of readonly systems alone has no tiers; with nothing queued, no GPU and no joined thread it skips its sync point, and it doesn't wake the loop. After a tick the pass runs at once with the tick's values, while its tails may still run. Readonly systems on joined threads are posted to their threads in the same pass, after those here ran. A run of the world's waits for its own there to run, the post marking it owed to that run: the thread gives back what each body returned, structured-cloned, for the one the run resolves to, or what it threw, which fails the run; a value that can't be posted fails it too, and a thread gone fails it with a `"thread"` failure. What those added throw reaches the world's thread by message and is reported out of band. A joined thread's `run` of one of its own settles there, once its body ran, to what it returned or with what it threw. + - **Removing:** `world.remove(system)` takes a value `add` was given off every tick. A system added alone and within an added composite stays until both are removed, and constraints naming a removed system are ignored, so a removal that leaves two systems that conflict with no ordering path between them throws, removing nothing, as `add` throws for one; a value not added does nothing, one no constructor returned throws, and so does a call on a joined thread for any system but a readonly system. Once it returns, nothing calls the removed system's body again: a tick or run in progress reads it as removed from its next tier on, and from its next row on when its own body removed it; a run queued before skips it; and a task already posted to a joined thread holding it skips it there (`threads.md`). Its memory is released: the world forgets its schedule and runner, and those of composites only it was in, releasing what the runner held once the run or tick in progress is done, and a joined thread drops what it held for it. A later `run` of it, or of a composite holding it, registers it again, from `lastRun` 0. A readonly system here whose runner holds nothing beyond its JS values (no index, order, GPU or joined thread) keeps that runner, stopped, while its value lives: added or run again once the run that removed it is done, it registers at once with that runner, restarted from `lastRun` 0, so adding it again costs no new runner, context or buffers; added again so, it runs from the next pass, even a queued run's. - Ordering comes from `before`/`after`/`during`, sorted into tiers. Systems in the same tier may run in parallel. A constraint that names a system outside the composite being run is ignored. - **`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. @@ -178,17 +178,19 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Built-in resources:** `gpuDevice` (`GPUDevice | null`); `workers`, the kernel pool's size, `null` by default for the platform's choice; `cadence`, `{ kind: "frame" }` by default, or `{ kind: "fixed", step, maxSteps }`; `always`, false by default; `wake`, reduced with `"min"`, Infinity for none; and `clock`, `{ now, delta, tick }`. Only the world writes `clock`: declaring a write to it is a type error, and throws at creation for untyped callers. - **Cadence:** `frame` beats on `requestAnimationFrame` where it exists, and on a 60 per second timer otherwise. `fixed` beats every `step` milliseconds; a beat that's behind runs at most `maxSteps` ticks (4 by default) and drops the rest of the debt, and each tick's `clock.delta` is `step`. A quiet loop schedules nothing and builds no debt. - **Clock and wakes:** before each tick the world writes `clock`: `now`, the beat's time in milliseconds (under `fixed`, the step's), `delta` since the previous tick, and `tick`, how many ran before. A system asks for a later tick by contributing a time to `wake`; the first beat at or after it runs a tick, once per wake time. A run arriving wakes the loop for the next beat; the clock's own writes never do. - - **Failures out of band** (`implementation/world/out-of-band.ts`): what fails where no run waits, in ticks and their tails, in observer systems added, on joined threads (their observer systems and finalizers, by message) and in GPU work, is raised on the world's thread as an `Error` naming what failed, its cause what was thrown: through the platform's `reportError` where it has one, as browsers and their workers do, so it's logged and dispatched as an `error` event; else thrown from a task of its own, an uncaught exception, as in node. Each is reported once per system, finalizer, index or GPU work while it keeps failing, and again once it recovered: a system once a tick or pass ran its body without its failing, a system its conditions or change filters skipped staying as it was; a finalizer once a tick's sync points ran it without its failing; an index once a tick found no conflict in it; GPU work once a run of its system gave its error scopes no error. Joined threads tell the world only of failures, so their observer systems are reported once per placement and their finalizers once. Once the world is disposed nothing more is reported, a GPU error or a tick's tails settling after it included. A run's value is kept only for the run that resolves to it (`State.wanted`): its runner's, or a joined thread's, which posts only that one, so composites' and ticks' members return values that are never held, cloned or posted. - - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), resolving once it joined: its claims made, and the observer systems it added as its join resolved in place, so a run started afterwards runs on the threads it claims and reaches them; 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, which resolves, once the world accepted the claim, to 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, its `join` and the world's `share` rejecting with why, as both do when the world is disposed, a port is shared again while its thread lives, or the other side's thread ended first as the platform reports it: a `Worker`'s `error` before it joined, a port's `close`, a node worker's `exit`, or, once the other side heard from it, a Web Lock; a browser worker ended before the other side heard from it isn't reported, and that side stays pending ([`threads.md`](threads.md)). A joined thread adds observer systems before awaiting anything else after `join`, so `share` resolves with them in place. 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, 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, failing with theirs, and a tick's failures are reported out of band once its tails are done. 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; what they throw reaches the world by message and is reported out of band. 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 reject. - - **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 factories' own runs, which nothing awaits, rethrow their rejections out of band; what their systems added to the world throw the world reports itself. 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: reported out of band for a system added, and 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. + - **Failures out of band** (`implementation/world/out-of-band.ts`): what fails where no run waits, in ticks and their tails, in readonly systems added, on joined threads (their readonly systems and finalizers, by message) and in GPU work, is raised on the world's thread as an `Error` naming what failed, its cause what was thrown: through the platform's `reportError` where it has one, as browsers and their workers do, so it's logged and dispatched as an `error` event; else thrown from a task of its own, an uncaught exception, as in node. Each is reported once per system, finalizer, index or GPU work while it keeps failing, and again once it recovered: a system once a tick or pass ran its body without its failing, a system its conditions or change filters skipped staying as it was; a finalizer once a tick's sync points ran it without its failing; an index once a tick found no conflict in it; GPU work once a run of its system gave its error scopes no error. Joined threads tell the world only of failures, so their readonly systems are reported once per placement and their finalizers once. Once the world is disposed nothing more is reported, a GPU error or a tick's tails settling after it included. A run's value is kept only for the run that resolves to it (`State.wanted`): its runner's, or a joined thread's, which posts only that one, so composites' and ticks' members return values that are never held, cloned or posted. + - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), resolving once it joined: its claims made, and the readonly systems it added as its join resolved in place, so a run started afterwards runs on the threads it claims and reaches them; 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, which resolves, once the world accepted the claim, to a `World` whose `run` works as here, and whose `add` and `remove` take readonly systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (a readonly 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, its `join` and the world's `share` rejecting with why, as both do when the world is disposed, a port is shared again while its thread lives, or the other side's thread ended first as the platform reports it: a `Worker`'s `error` before it joined, a port's `close`, a node worker's `exit`, or, once the other side heard from it, a Web Lock; a browser worker ended before the other side heard from it isn't reported, and that side stays pending ([`threads.md`](threads.md)). A joined thread adds readonly systems before awaiting anything else after `join`, so `share` resolves with them in place. 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, 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, failing with theirs, and a tick's failures are reported out of band once its tails are done. 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 post of a readonly system, and the index of an entity they stopped is held until the thread acknowledges; what they throw reaches the world by message and is reported out of band. 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 reject. + - **Readonly systems are the way to read a world.** A consumer reads once by running a readonly 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 readonly systems. Derived values are computed by systems, inside the ECS. + - **Tables:** a readonly system without components declaring `tables: { components, with, not, resources }` reads, on the world's thread, `tables.archetypes`: of each archetype matching `with` and `not` that holds any of `components`, its count, its entities in row order and a copy of each of those components' columns it holds, as bytes in its layout (`stride` per row) for a linear one, values for a string or an object, and one value for a shared component or a constant; and `tables.resources`, each resource's value. What it names counts as what it reads, for readback and placement (`tables-read.ts`). A joined thread refuses one, running or adding it. `commands.spawnTables(tables, resources?)` spawns their rows back, one batch block copy per column, and rewrites every entity value naming an entity of the tables, a reference, a struct's field or an array's item, to the one spawned for it; for entities outside them it does what despawning them would: a row owned by one, directly or through owner references among the tables, isn't spawned, a weak reference to one is left out of its row, which goes to the archetype without it, and any other value naming one is `Entity.none`. `resources` sets each resource it names at the sync point, in a buffer of the world's own, since the sync point skips systems' commands to the resource entity: checked first, so a resource only the world writes, one on another thread, or a value its schema doesn't take fails the system, spawning nothing. It returns each table's new entities, `Entity.none` for a row it didn't spawn, and how many those were. A column whose stride isn't its component's, whose length isn't its table's count, or that gives a shared component anything but its value fails the system. Without a value that can hold an entity, nothing is mapped (`tables.ts`). Persistence and copies between worlds build on these, outside the core (`@adobe/ecs-persistence`). + - **Declarations:** `Component.declarations()` and `Resource.declarations()` give every key its modules registered so far, with its declaration: its schema as `type`, its `default` and its traits, those of your own by their keys, so a library finds the keys a trait of its own marks. + - **Observer factories** (`src/observe/`, internal, never exported: consumers write plain readonly systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give readonly systems 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 readonly systems alone. Per world, in a `WeakMap`: one readonly 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 readonly 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 factories' own runs, which nothing awaits, rethrow their rejections out of band; what their systems added to the world throw the world reports itself. 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, a readonly system 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 runs of readonly systems. Benchmarks count ticks with a readonly 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 a readonly system, they add no maintenance or delivering system, since readonly 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: reported out of band for a system added, and 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 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 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 failures 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, runs observer systems, and resolves to what its per-run body returned: a system without components resolves to its body's value, `undefined` when its body was skipped, and any other system or composite to nothing. A one-off read is a run of an observer system returning what it read. Runs are sequential in call order; physically, runs that don't conflict may overlap. The queue chains its own promise, which never rejects and never handles the one `run` returned, so a failing run nobody awaits is an unhandled rejection whatever follows it. + - `run(system, ...input): Promise` runs a system or composite, ends in a sync point, runs readonly systems, and resolves to what its per-run body returned: a system without components resolves to its body's value, `undefined` when its body was skipped, and any other system or composite to nothing. A one-off read is a run of a readonly system returning what it read. Runs are sequential in call order; physically, runs that don't conflict may overlap. The queue chains its own promise, which never rejects and never handles the one `run` returned, so a failing run nobody awaits is an unhandled rejection whatever follows it. - 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). - - **A run whose systems failed rejects,** once its sync point applied what was done and its observer systems ran, with an `Error` naming each failure: a system or finalizer that threw or returned a promise, a system whose joined thread is gone, a unique index's conflict found in its tiers, its sync point or its observer pass, its own observer systems on joined threads, each by its label and id with the message and what was thrown as its `cause`, and the systems skipped after a failed tier; with several, an `AggregateError` of them. Its exact shape is no contract. Nothing is undone: there are no transactions. Domain errors are data a system writes, not throws. It rejects too 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. Commands that did nothing, and tiers that waited for a joined thread, are no failures, and nothing public counts them; the implementation gathers them, with what ran where, for its tests (`recorderOf`). + - **Readonly systems** run after its sync point, before it resolves (see the readonly pass). + - **A run whose systems failed rejects,** once its sync point applied what was done and its readonly systems ran, with an `Error` naming each failure: a system or finalizer that threw or returned a promise, a system whose joined thread is gone, a unique index's conflict found in its tiers, its sync point or its readonly pass, its own readonly systems on joined threads, each by its label and id with the message and what was thrown as its `cause`, and the systems skipped after a failed tier; with several, an `AggregateError` of them. Its exact shape is no contract. Nothing is undone: there are no transactions. Domain errors are data a system writes, not throws. It rejects too 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. Commands that did nothing, and tiers that waited for a joined thread, are no failures, and nothing public counts them; the implementation gathers them, with what ran where, for its tests (`recorderOf`). - 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' 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. @@ -241,10 +243,10 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **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: { 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. + - **`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. Readonly 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 a readonly system 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. - - **Unique conflicts:** a unique index keeps the entity with the lower index for a key, and fails what found each other: the run, when its tiers, sync point or observer pass refreshed the index, or else the tick or its pass, reported out of band; named by the index's label, with the key and both entities. + - **Unique conflicts:** a unique index keeps the entity with the lower index for a key, and fails what found each other: the run, when its tiers, sync point or readonly pass refreshed the index, or else the tick or its pass, reported out of band; named by the index's label, with the key and both entities. - **Lifetime:** a world builds an index the first time a registered system or finalizer uses it, in O(entities), keeps it current before each tier holding a user, and releases it, arena blocks included, with its last user, through removal and collection as runners are. - **Threads:** kernels look up through indexes of struct keys, on the world's thread, pool workers and joined threads alike; an index with an object key part lives on the world's thread, so a system on a joined thread using one throws. `order` runs on the world's thread. @@ -294,13 +296,13 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **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. - **Dispatch:** WASM chunks go through a per-worker task ring in shared memory, signalled with `Atomics.notify`; workers stay in that window across runs until a message must reach them. A round trip to a waiting worker, `wake`, is 0.76 µs in node and 0.78–1.1 µs in chromium. -- **Joined threads:** runs through slots in shared memory, completing through a word, `remote run` 12.4 µs in node, about 2.7 µs past two threads waking each other through `Atomics.waitAsync`; its observer systems posted after each sync point, waking only a thread that read the last wake (the observer pass, below). Their objects follow the world's rows through a move log in shared memory, one entry per row moved, replayed lazily on that thread, `log replay` 21 ns a move in node, or rebuilt from the layout directory and each row's tag once it lapped, `log rebuild 100K` 3.2 ns a row; their ticks are the world's storages' alone; the structure epoch and directory counts are kept only while a joined thread holds claims. Its systems run there as tasks through its task block, consecutive tiers on it as one, both threads spinning briefly before they wait, the joined thread returning to its event loop once a tick, at about 2 µs a tick back to back: `handoff` 10.9 µs a tick of five tasks, `fused tiers` 6.9 µs a tick of one, in node. Its finalizers' stops are copied into its stop list, a column at a time, and run there as it replays: `despawn objects 10K` 83 ns a despawn on the world's thread in node, against 77 with the finalizer run there inline. Its terminal systems run as tails after the sync point, claiming rows in chunks, a late one's reads copied a run of rows at a time by the next writer: `tail prompt` 20.4 µs a tick of a 1,000-row tail, `tail late snapshot 10K` 6.7 ns a row copied, in node; the copy lets the world's work after its writer overlap the tail, `tail overlap` 2.53 ms a tick of a 2.5 ms tail and 2 ms of work against 4.70 for `tail overlap waiting`, the writer waiting for the tail instead. +- **Joined threads:** runs through slots in shared memory, completing through a word, `remote run` 12.4 µs in node, about 2.7 µs past two threads waking each other through `Atomics.waitAsync`; its readonly systems posted after each sync point, waking only a thread that read the last wake (the readonly pass, below). Their objects follow the world's rows through a move log in shared memory, one entry per row moved, replayed lazily on that thread, `log replay` 21 ns a move in node, or rebuilt from the layout directory and each row's tag once it lapped, `log rebuild 100K` 3.2 ns a row; their ticks are the world's storages' alone; the structure epoch and directory counts are kept only while a joined thread holds claims. Its systems run there as tasks through its task block, consecutive tiers on it as one, both threads spinning briefly before they wait, the joined thread returning to its event loop once a tick, at about 2 µs a tick back to back: `handoff` 10.9 µs a tick of five tasks, `fused tiers` 6.9 µs a tick of one, in node. Its finalizers' stops are copied into its stop list, a column at a time, and run there as it replays: `despawn objects 10K` 83 ns a despawn on the world's thread in node, against 77 with the finalizer run there inline. Its terminal systems run as tails after the sync point, claiming rows in chunks, a late one's reads copied a run of rows at a time by the next writer: `tail prompt` 20.4 µs a tick of a 1,000-row tail, `tail late snapshot 10K` 6.7 ns a row copied, in node; the copy lets the world's work after its writer overlap the tail, `tail overlap` 2.53 ms a tick of a 2.5 ms tail and 2 ms of work against 4.70 for `tail overlap waiting`, the writer waiting for the tail instead. - **Layout directory:** a header at offset 0, a table and a record per archetype in shared memory, flushed for the archetypes that grew or were created at the end of each sync point. Workers check one version word per archetype a task touches and rebuild views only when it moved, so growth posts no message and leaves workers' windows open: `sync point grow 100 archetypes workers` 1.43 → 0.62 ms in chromium. - **Kernels:** interleaved layouts, SIMD within rows (SLP), across dense rows, and across rows of whole interleaved elements (four tight vec3 rows are three vectors), 16-byte loads for three-lane rows with column slack, exact integer `%`. - **Observer factories,** in node: `read observe factory warm` 125 ns, cold 0.88 µs, adding its notifier again reusing its runner; `observe 1K entities factory` 0.54 µs a run, one changed 29; `select page 100K factory` 16 µs a change; `select page 100K churn factory` 2.8 µs a page; `count 1M changed factory` 42 µs; `select factory` 7.5 ms. - **Main-thread spawns and adds:** a list of components resolves to a shape shared by every list of the same components: an archetype by its index, in a table per world, and a plain list by its keys among the last eight plain lists. Spawns are staged into a batch per component set and block-copied at the sync point. A shape's spawner is generated code, one straight-line read by key and store per value, with only numbers written into the code and keys, columns and writers passed in, so each read sees one object shape and each store one layout; where a content security policy forbids `'unsafe-eval'`, it's a loop over the shape's columns, about twice as slow (`perf.md`). The plugin rewrites `commands.spawn(components)({ ... })` and `commands.add(entity, components, { ... })` with literal keys in main-thread systems into a per-site function with one parameter per value, so no values object is built: a spawn calls a spawner generated for the site's key order and the list's shape, kept per site by archetype index and by shape; an add appends each value to the command buffer by the component ids it resolved once per world and list. A site's keys are read in module scope, outside every binding the generated code declares, and a kernel's locals are printed with their ids, so no name of the module's or the body's can capture or be captured by one the compiler generates. No keys are listed on these paths; keys are listed only per query (`where`, `order`) and per world (`World.create`'s values). - **Main-thread systems:** one accessor row per set of reads and writes, in fast properties, shared across systems and archetypes and rebound per pass; getters and setters specialized per layout; resource cells resolved once per run. A run starts on the runner's first command buffer and chunk without staging, sets its chunks' length only when it differs, and checks conditions only when it has some: `run 50 idle` 110 → 73 ns a system. -- **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. +- **The readonly pass:** each list of readonly 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 a readonly system a tick, and a run of readonly 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`), 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. @@ -347,5 +349,5 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - 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 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 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 readonly systems 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/ecs/packages/ecs/ecs-dom.md b/ecs/packages/ecs/ecs-dom.md index 3b5b6a27..7d547a04 100644 --- a/ecs/packages/ecs/ecs-dom.md +++ b/ecs/packages/ecs/ecs-dom.md @@ -11,13 +11,13 @@ hasn't settled is under Open. - **A binding system writes the model to the element** when the components that drive it change: ```ts - System.create({ observer: true, components: { with: [label, done, row], changed: [label, done, row] } }, entry => { + System.create({ readonly: true, components: { with: [label, done, row], changed: [label, done, row] } }, entry => { entry[row].label = entry[label]; entry[row].done = entry[done]; }); ``` - The build infers what it reads from its body. As an observer system it issues no commands and sees each run's and + The build infers what it reads from its body. As a readonly system it issues no commands and sees each run's and tick's applied state. Chunk and block ticks skip unchanged rows, so its cost scales with what changed. - **Elements know nothing about the ECS.** An element takes properties and dispatches DOM events; a property write that changes nothing is dropped, and it never changes a property its owner writes. So it can be tested alone, and @@ -29,16 +29,16 @@ hasn't settled is under Open. ## Repeated and fixed elements - **Repeated elements are components of the entity they show**, as the todo sample's rows are: - - **Created** by an ordinary system over entities without one, adding it by a command; observer systems can't issue + - **Created** by an ordinary system over entities without one, adding it by a command; readonly systems can't issue commands, so it's added on the tick after the run that made the entity. - - **Bound** by an observer system with the element and what it shows in `changed`. - - **Placed** by an observer system that runs rows whose element or order changed, in list order through a sorted + - **Bound** by a readonly system with the element and what it shows in `changed`. + - **Placed** by a readonly system that runs rows whose element or order changed, in list order through a sorted index, putting each after the one before it; `moveBefore` keeps focus where the browser has it. - **Removed** by a finalizer on the element component, which runs whatever ends the entity, `dispose` included, so no node leaks. - **Fixed elements are resources:** each feature finds its own within the page's root as the app starts, and every system that uses one declares it in `resources.is`, so it's skipped in a world without it. -- **Counts and summaries** are bound by observer systems that run only when an index's entries changed (`changed: true` +- **Counts and summaries** are bound by readonly systems that run only when an index's entries changed (`changed: true` on an index lookup). ## Constraints @@ -54,7 +54,7 @@ hasn't settled is under Open. ## Open - **Views as entities of their own,** `{ element, viewOf: Entity }`, for one model entity shown several times, a list - row and a details pane: their binding systems would read the model through `up: { through: viewOf }`, which observer + row and a details pane: their binding systems would read the model through `up: { through: viewOf }`, which readonly systems can't declare yet (`followups.md`). - **Boilerplate:** one binding system per element type and set of driving components. A helper building one from a map of properties to components would be a public addition. diff --git a/ecs/packages/ecs/followups.md b/ecs/packages/ecs/followups.md index 9d121d79..ad261d0b 100644 --- a/ecs/packages/ecs/followups.md +++ b/ecs/packages/ecs/followups.md @@ -20,8 +20,8 @@ correctness, then optimization. Remove an item once it's decided or done. - **`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 +- **Readonly 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 readonly systems, since it only reads; let `order` take an index over the entity a reference names. (from the todo sample) - **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 @@ -87,6 +87,12 @@ correctness, then optimization. Remove an item once it's decided or done. ## Optimization +- **Owner references at the sync point:** spawning 1M entities with an owner reference costs about 200 ms more at + the sync point than without one, whether through `commands.spawn` or `commands.spawnTables`: the holders' bookkeeping, + per entity. Bulk spawns of tables could record holders per block. +- **A row system over many small archetypes** inlines its rows' per-archetype `bind` into its run only after a second + optimization, which V8 makes or not depending on what else the bundle holds: `small archetypes 1K` runs in 0.075 ms + or 0.083 ms with no change to its path. A binding that doesn't rely on being inlined would steady it. - **f64 SIMD for `Vec3` bodies:** automatic kernels' f64x2 lanes run only across dense single-f32 columns, so a body over `Vec3` components, as chapter 14's `move`, runs in WASM a row at a time. Measure lanes within a row's leaves. - Index step 5 items. @@ -99,9 +105,15 @@ correctness, then optimization. Remove an item once it's decided or done. ## 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 +- **Persistence, next:** `@adobe/ecs-persistence` saves, loads and copies whole snapshots. + - **Incremental saves:** changes finalizers watching the `persist` trait (`watch` by trait, above) would give + autosaves and deltas. + - **Migrations:** a snapshot holds each component's schema, and a load refuses one that differs; a declared + migration could read the old schema's values instead. + - **Tables on joined threads:** a joined thread refuses a readonly system reading tables. + - **Layouts across versions:** a snapshot's bytes are checked by schema and stride; a layout placing a schema's + fields differently in another version of the ECS would load wrongly. Possible: a layout's signature in the format. +- **A test helper awaiting the next tick.** Tests watch `clock` with a readonly system today; a helper in `src/observe` would do it (from the todo sample). ## Publishing as adobe/ecs @@ -132,7 +144,7 @@ Once this repository is public as `adobe/ecs`: `up`, is skipped before its body, keeping its `lastRun`. The alternative, a condition, would run no row while the index is unchanged, whatever else changed. Recommended: accept. -- **Re-adding an observer system:** a re-added observer system runs from the next pass, even for a run already queued, +- **Re-adding a readonly system:** a re-added readonly system runs from the next pass, even for a run already queued, while a first add waits for registration. Recommended: accept. - **Indexes without ids on joined threads:** these now work instead of throwing; the documentation only required ids on keys. Recommended: accept. @@ -156,14 +168,14 @@ Once this repository is public as `adobe/ecs`: ran its body without its failing, conditions and change filters skipping it changing nothing; a finalizer once a tick's sync points ran it without its failing; an index once a tick found no conflict in it; GPU work once a run of its system gave its scopes no error. Joined threads tell the world only of - failures, so their observer systems are reported once per placement and their finalizers once. Recommended: accept. + failures, so their readonly systems are reported once per placement and their finalizers once. Recommended: accept. - **What's no failure and isn't public:** commands that did nothing and tiers that waited for a joined thread are gathered only for the implementation's tests. Recommended: accept, until instrumentation is designed. - **What fails a run and what's out of band:** a run rejects for its systems and finalizers, its tails, its joined - threads gone, its unique indexes' conflicts, those its observer pass found included, and its own observer systems, + threads gone, its unique indexes' conflicts, those its readonly pass found included, and its own readonly systems, those on joined threads included; what those added throw, GPU errors, which come after the run, and kernels failing to load on the pool after it are out of band. A joined thread's `run` of its - own observer system rejects with what it threw there, its own `Error`, and a `run` of several of them resolves to + own readonly system rejects with what it threw there, its own `Error`, and a `run` of several of them resolves to nothing. Recommended: accept. - **The rejection's shape:** one failure's `Error`, `System "label" (id) failed: message` with what was thrown as its `cause`; several, an `AggregateError` of them; systems skipped after a failed tier named in the message. A joined @@ -171,7 +183,7 @@ Once this repository is public as `adobe/ecs`: - **A `share` that fails is an unhandled rejection when nothing handles it,** as a run's is: a refused claim, a dispose while the thread joins, a port shared again while its thread lives, or that thread gone first, one leaving on the turn its join resolved included. Recommended: accept. -- **`share` covers observer systems added before awaiting anything else after `join`:** the joined thread says it's +- **`share` covers readonly systems added before awaiting anything else after `join`:** the joined thread says it's ready on its next turn of the event loop. One added after an import or a fetch reaches the runs after its add arrived; an idle rule covering those would wait an unbounded number of turns. Recommended: accept, as documented. - **A node `MessagePort` already closed** is told by `hasRef()` false once a message listener is set, since its @@ -188,7 +200,7 @@ Once this repository is public as `adobe/ecs`: thenable a per-run, row or render body returned fails its system at once, never awaited, so no body holds the world's runs. Types can't tell a body returning `any`, a body typed `any`, nor an async function typed as returning `void`; the runtime check covers those. The build refuses `async` row bodies. -- **A world's run of its observer system on a joined thread** waits for it to run there, resolving to its value as +- **A world's run of its readonly system on a joined thread** waits for it to run there, resolving to its value as structured clone, or rejecting with what it threw there, which isn't reported out of band; a value that can't be posted fails it. A post owed to it that the thread gives back while the run waits is posted again at once. Recommended: accept. @@ -213,6 +225,6 @@ Once this repository is public as `adobe/ecs`: - **Kernels don't read strings yet,** by type and check, and **an index keyed by strings is looked up only on the world's thread,** which keeps it by values. Recommended: accept for now; kernels would need the records' code units compared in the compiler, and joined lookups an index of interned records. -- **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing +- **A readonly 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. diff --git a/ecs/packages/ecs/memory.md b/ecs/packages/ecs/memory.md index c4b30189..6824ea22 100644 --- a/ecs/packages/ecs/memory.md +++ b/ecs/packages/ecs/memory.md @@ -42,8 +42,8 @@ decisions about ecs here rather than in a personal memory, so every machine and ## Direction -- **Reading and observing go through systems.** Consumers write plain observer systems, as the DOM work will; the - `Observe` pattern isn't offered to them, and a world has no observers. Observer factories built on observer +- **Reading and observing go through systems.** Consumers write plain readonly systems, as the DOM work will; the + `Observe` pattern isn't offered to them, and a world has no readonly systems. Observer factories built on observer systems with only the public API stay internal (`src/observe`), for tests, debugging and benchmarks, never exported. - **Observing single entities is mainly for debugging.** Production reads, such as DOM bindings, are ordinary row systems over every matching row, which always beat one system per entity. Spend optimization effort on row systems diff --git a/ecs/packages/ecs/package.json b/ecs/packages/ecs/package.json index 3ddf53ed..c6fbc445 100644 --- a/ecs/packages/ecs/package.json +++ b/ecs/packages/ecs/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/ecs", - "version": "0.2.1", + "version": "0.2.3", "description": "Adobe ECS", "license": "MIT", "type": "module", diff --git a/ecs/packages/ecs/src/exports.test.ts b/ecs/packages/ecs/src/exports.test.ts index 53e05f57..b6a42b41 100644 --- a/ecs/packages/ecs/src/exports.test.ts +++ b/ecs/packages/ecs/src/exports.test.ts @@ -50,7 +50,7 @@ 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: Promise = World.join(port, { threads: ["main"] }); -export const read = System.create({ observer: true, components: { with: [position] }, lookup: { at: { with: [position] } } }, () => {}); +export const read = System.create({ readonly: true, components: { with: [position] }, lookup: { at: { with: [position] } } }, () => {}); export const added = world.add(read); `; diff --git a/ecs/packages/ecs/src/guide/00-keys.ts b/ecs/packages/ecs/src/guide/00-keys.ts index fa64e990..57af5d5d 100644 --- a/ecs/packages/ecs/src/guide/00-keys.ts +++ b/ecs/packages/ecs/src/guide/00-keys.ts @@ -23,7 +23,7 @@ const award = System.create({ components: { with: [score] } }, (row, { resources row[score] += resources[bonus]; }); -const read = System.create({ observer: true, components: { with: [score] } }, row => { +const read = System.create({ readonly: true, components: { with: [score] } }, row => { console.log("score", row[score]); }); diff --git a/ecs/packages/ecs/src/guide/01-resources.md b/ecs/packages/ecs/src/guide/01-resources.md index faef5f21..f3c047cc 100644 --- a/ecs/packages/ecs/src/guide/01-resources.md +++ b/ecs/packages/ecs/src/guide/01-resources.md @@ -17,6 +17,6 @@ that isn't data, such as an `AudioContext`, is stored as it is; a change inside written back. An `Entity` resource with no entity holds `Entity.none`, which is `0` rather than `null`, so it stays data every thread can read. -A system reads resources by key. With `observer: true` it only reads, and runs after the world's changes apply. +A system reads resources by key. With `readonly: true` it only reads, and runs after the world's changes apply. [`run`](01-resources.ts#L31-L32) runs a system once, and resolves to what the body returns, for a system without components. diff --git a/ecs/packages/ecs/src/guide/01-resources.ts b/ecs/packages/ecs/src/guide/01-resources.ts index 16c03a64..a225c7be 100644 --- a/ecs/packages/ecs/src/guide/01-resources.ts +++ b/ecs/packages/ecs/src/guide/01-resources.ts @@ -22,7 +22,7 @@ declare module "@adobe/ecs" { } } -const read = System.create({ observer: true }, ({ resources }) => +const read = System.create({ readonly: true }, ({ resources }) => [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]); export const main = async (): Promise => { diff --git a/ecs/packages/ecs/src/guide/02-entities.ts b/ecs/packages/ecs/src/guide/02-entities.ts index ba26bd4e..8f9f3c94 100644 --- a/ecs/packages/ecs/src/guide/02-entities.ts +++ b/ecs/packages/ecs/src/guide/02-entities.ts @@ -25,10 +25,10 @@ export const defeat = System.create({ components: { with: [enemy] } }, (row, { c commands.remove(row.entity, enemy); }); -const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => { +const readFoes = System.create({ readonly: true, components: { with: [position, enemy] } }, row => { console.log("a foe at", row[position]); }); -const readParticles = System.create({ observer: true, components: { with: [...Particle] } }, row => { +const readParticles = System.create({ readonly: true, components: { with: [...Particle] } }, row => { console.log("a particle moving at", row[velocity]); }); diff --git a/ecs/packages/ecs/src/guide/03-systems.md b/ecs/packages/ecs/src/guide/03-systems.md index 89671466..9dd174b4 100644 --- a/ecs/packages/ecs/src/guide/03-systems.md +++ b/ecs/packages/ecs/src/guide/03-systems.md @@ -8,8 +8,8 @@ it in parallel. component in `with` and none in `not`, so the `frozen` one stays put. Values are replaced whole, never edited in place. - **Systems without components**, such as [`count`](03-systems.ts#L35-L37), run once per run. -- **Observer systems**, `observer: true`, only read. They see what the runs before them left. Run one to read the - world, or add one to react to it. +- **Readonly systems**, `readonly: true`, only read. Run one to read the world, as [`read`](03-systems.ts#L40-L42) + does, or add one to react to it. [Sync points](06-sync-points.md) says when and where they run. - **Optional components**, `optional: [velocity]`, are in the row where the entity has them and `undefined` where it doesn't. They never narrow which entities a system runs on; [`readSpeeds`](03-systems.ts#L44-L47) still reads the entity without a velocity. Only commands add and remove them. diff --git a/ecs/packages/ecs/src/guide/03-systems.out b/ecs/packages/ecs/src/guide/03-systems.out index bcee4f82..aaddadab 100644 --- a/ecs/packages/ecs/src/guide/03-systems.out +++ b/ecs/packages/ecs/src/guide/03-systems.out @@ -1,5 +1,5 @@ at [ 0, 1, 5 ] moving [ 'at 2', 'at 2', 'still' ] frames 1 -Error: System "fail" (@adobe/ecs@0.2.1/src/guide/03-systems.ts#fail) failed: broken +Error: System "fail" (@adobe/ecs@0.2.3/src/guide/03-systems.ts#fail) failed: broken frames 2 diff --git a/ecs/packages/ecs/src/guide/03-systems.ts b/ecs/packages/ecs/src/guide/03-systems.ts index d65e6afc..a2944c90 100644 --- a/ecs/packages/ecs/src/guide/03-systems.ts +++ b/ecs/packages/ecs/src/guide/03-systems.ts @@ -37,16 +37,16 @@ const count = System.create({}, ({ resources }) => { }); const places: number[] = []; -const read = System.create({ observer: true, components: { with: [position] } }, row => { +const read = System.create({ readonly: true, components: { with: [position] } }, row => { places.push(row[position][0]); }); const speeds: string[] = []; -const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { +const readSpeeds = System.create({ readonly: true, components: { with: [position], optional: [velocity] } }, row => { speeds.push(row[velocity] === undefined ? "still" : `at ${row[velocity][0]}`); }); -const frameCount = System.create({ observer: true }, ({ resources }) => resources[frames]); +const frameCount = System.create({ readonly: true }, ({ resources }) => resources[frames]); const fail = System.create(() => { throw new Error("broken"); diff --git a/ecs/packages/ecs/src/guide/04-ticks.md b/ecs/packages/ecs/src/guide/04-ticks.md index b357b9bc..72f137e2 100644 --- a/ecs/packages/ecs/src/guide/04-ticks.md +++ b/ecs/packages/ecs/src/guide/04-ticks.md @@ -9,7 +9,7 @@ every tick from then on, and [`world.remove`](04-ticks.ts#L41) takes it off. `step` milliseconds, each with that `delta`. - A world ticks only after a run arrives, unless `always` is set, as for [the first world](04-ticks.ts#L36). -An observer with `resources: { changed: [elapsed] }` runs after each tick that changed `elapsed`: +A readonly system with `resources: { changed: [elapsed] }` runs after each tick that changed `elapsed`: [`watch`](04-ticks.ts#L18-L21) resolves once 30 ms have passed. Once `advance` is removed, `elapsed` stays put. **Asking for a tick.** A system that needs another tick contributes a time to the `wake` reduction: diff --git a/ecs/packages/ecs/src/guide/04-ticks.ts b/ecs/packages/ecs/src/guide/04-ticks.ts index ebef783d..f0ee66d2 100644 --- a/ecs/packages/ecs/src/guide/04-ticks.ts +++ b/ecs/packages/ecs/src/guide/04-ticks.ts @@ -16,11 +16,11 @@ const advance = System.create({}, ({ resources }) => { }); let reached: (elapsed: number) => void = () => {}; -const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => { +const watch = System.create({ readonly: true, resources: { changed: [elapsed] } }, ({ resources }) => { if (resources[elapsed] >= 30) reached(resources[elapsed]); }); -const readTime = System.create({ observer: true }, ({ resources }) => ({ elapsed: resources[elapsed], delta: resources[clock].delta })); +const readTime = System.create({ readonly: true }, ({ resources }) => ({ elapsed: resources[elapsed], delta: resources[clock].delta })); const step = System.create({}, ({ resources, reduce }) => { resources[steps] += 1; @@ -28,7 +28,7 @@ const step = System.create({}, ({ resources, reduce }) => { }); let finished: (steps: number) => void = () => {}; -const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ readonly: true, resources: { changed: [steps] } }, ({ resources }) => { if (resources[steps] === 3) finished(resources[steps]); }); diff --git a/ecs/packages/ecs/src/guide/05-scheduling.ts b/ecs/packages/ecs/src/guide/05-scheduling.ts index e514f007..87f3c019 100644 --- a/ecs/packages/ecs/src/guide/05-scheduling.ts +++ b/ecs/packages/ecs/src/guide/05-scheduling.ts @@ -20,7 +20,7 @@ const heal = System.create({ components: { with: [health] }, after: [hit] }, row }); const frame = System.create([heal, hit]); -const read = System.create({ observer: true, components: { with: [health] } }, row => { +const read = System.create({ readonly: true, components: { with: [health] } }, row => { console.log("health", row[health]); }); diff --git a/ecs/packages/ecs/src/guide/06-sync-points.md b/ecs/packages/ecs/src/guide/06-sync-points.md index e65d950f..b3ef2d22 100644 --- a/ecs/packages/ecs/src/guide/06-sync-points.md +++ b/ecs/packages/ecs/src/guide/06-sync-points.md @@ -10,5 +10,12 @@ A run ends at a *sync point*, where its commands apply. A composite is one run with one sync point. In [`frame`](06-sync-points.ts#L25), `count` runs `after: [spawn]` but before the spawned entities exist, so it counts none; run alone, after the sync point, it counts the hero. -An observer added to a world runs after the sync point of every run and tick, reading what it left: -[`watch`](06-sync-points.ts#L39-L41) logs the hero after the run that spawned it. +**Readonly systems.** `readonly: true` places a system after the sync point, so it reads what the commands did. Added +to a world, [`watch`](06-sync-points.ts#L27-L29) runs after the sync point of every run and tick, and logs the hero +after the run that spawned it; `count`, which isn't readonly, runs before the sync point, in its tier. + +- **Where:** a readonly system runs on the thread that added or ran it, so a [joined thread](19-threads.md) adds only + readonly systems. +- **Order:** none. It can't declare `before`, `after` or `during`. +- **Limits:** it can't write, use `commands` or `reduce`, or take input. Its row and resources are read-only, so each + is a type error. diff --git a/ecs/packages/ecs/src/guide/06-sync-points.ts b/ecs/packages/ecs/src/guide/06-sync-points.ts index a2ccf638..288f8fea 100644 --- a/ecs/packages/ecs/src/guide/06-sync-points.ts +++ b/ecs/packages/ecs/src/guide/06-sync-points.ts @@ -24,10 +24,10 @@ const count = System.create({ components: { with: [health] }, after: [spawn] }, }); const frame = System.create([spawn, count]); -const watch = System.create({ observer: true, components: { with: [health] } }, row => { +const watch = System.create({ readonly: true, components: { with: [health] } }, row => { console.log("health", row[health]); }); -const readSeen = System.create({ observer: true }, ({ resources }) => resources[seen]); +const readSeen = System.create({ readonly: true }, ({ resources }) => resources[seen]); export const main = async (): Promise => { await using world = World.create(); diff --git a/ecs/packages/ecs/src/guide/07-change-filters.ts b/ecs/packages/ecs/src/guide/07-change-filters.ts index 56d91eec..a5a563d4 100644 --- a/ecs/packages/ecs/src/guide/07-change-filters.ts +++ b/ecs/packages/ecs/src/guide/07-change-filters.ts @@ -36,7 +36,7 @@ const bump = System.create({ components: { with: [score, boss] } }, row => { }); let loaded = 0; -const countLoaded = System.create({ observer: true, components: { with: [shown] } }, () => { +const countLoaded = System.create({ readonly: true, components: { with: [shown] } }, () => { loaded += 1; }); @@ -45,7 +45,7 @@ const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resou }); const pause = System.create({}, ({ resources }) => { resources[paused] = true; }); const open = System.create({}, ({ resources }) => { resources[map] = { width: 4 }; }); -const readSteps = System.create({ observer: true }, ({ resources }) => resources[steps]); +const readSteps = System.create({ readonly: true }, ({ resources }) => resources[steps]); export const main = async (): Promise => { await using world = World.create(); diff --git a/ecs/packages/ecs/src/guide/08-input.ts b/ecs/packages/ecs/src/guide/08-input.ts index ed717827..2bb23491 100644 --- a/ecs/packages/ecs/src/guide/08-input.ts +++ b/ecs/packages/ecs/src/guide/08-input.ts @@ -30,7 +30,7 @@ const heal = System.create({ }); const healths: number[] = []; -const read = System.create({ observer: true, components: { with: [health] } }, row => { +const read = System.create({ readonly: true, components: { with: [health] } }, row => { healths.push(row[health]); }); diff --git a/ecs/packages/ecs/src/guide/09-rules.ts b/ecs/packages/ecs/src/guide/09-rules.ts index 6376065b..e0345ca3 100644 --- a/ecs/packages/ecs/src/guide/09-rules.ts +++ b/ecs/packages/ecs/src/guide/09-rules.ts @@ -31,7 +31,7 @@ const hide = System.create({ components: { with: [sprite] } }, (row, { commands }); const drawing: string[] = []; -const read = System.create({ observer: true, components: { with: [layer, drawn], optional: [mesh, sprite] } }, row => { +const read = System.create({ readonly: true, components: { with: [layer, drawn], optional: [mesh, sprite] } }, row => { drawing.push(`${row[mesh] === undefined ? `sprite ${row[sprite]}` : `mesh ${row[mesh]}`} on layer ${row[layer]}`); }); /** Logs what's drawn, in order. */ diff --git a/ecs/packages/ecs/src/guide/11-traits.ts b/ecs/packages/ecs/src/guide/11-traits.ts index d96a344c..5e107391 100644 --- a/ecs/packages/ecs/src/guide/11-traits.ts +++ b/ecs/packages/ecs/src/guide/11-traits.ts @@ -33,9 +33,9 @@ const destroy = System.create({ components: { with: [ship] } }, (row, { commands }); const counts = { outlined: 0, targeting: 0, entities: 0 }; -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.entities += 1; }); +const countOutlined = System.create({ readonly: true, components: { with: [outline] } }, () => { counts.outlined += 1; }); +const countTargeting = System.create({ readonly: true, components: { with: [target] } }, () => { counts.targeting += 1; }); +const countAll = System.create({ readonly: true, components: {} }, () => { counts.entities += 1; }); const count = System.create([countOutlined, countTargeting, countAll]); export const main = async (): Promise => { diff --git a/ecs/packages/ecs/src/guide/12-lookups.ts b/ecs/packages/ecs/src/guide/12-lookups.ts index 453895e3..7d444c1d 100644 --- a/ecs/packages/ecs/src/guide/12-lookups.ts +++ b/ecs/packages/ecs/src/guide/12-lookups.ts @@ -25,7 +25,7 @@ const track = System.create({ const refreshed: boolean[] = []; const watch = System.create({ - observer: true, + readonly: true, components: { with: [target] }, lookup: { enemy: { with: [health], changed: [health] } }, }, (row, { lookup }) => { @@ -34,7 +34,7 @@ const watch = System.create({ }); const aims: number[] = []; -const readAims = System.create({ observer: true, components: { with: [aim] } }, row => { +const readAims = System.create({ readonly: true, components: { with: [aim] } }, row => { aims.push(row[aim]); }); diff --git a/ecs/packages/ecs/src/guide/13-indexes.ts b/ecs/packages/ecs/src/guide/13-indexes.ts index 3b10ccb7..02cbeb06 100644 --- a/ecs/packages/ecs/src/guide/13-indexes.ts +++ b/ecs/packages/ecs/src/guide/13-indexes.ts @@ -26,14 +26,14 @@ const count = System.create({ components: { with: [cell, crowded] }, lookup: { a const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } }); -const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { +const leaders = System.create({ readonly: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { const winners: string[] = []; const high = lookup.scores({ ">=": 90 }); for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); return winners; }); -const list = System.create({ observer: true, components: { with: [player, score] }, order: byScore }, row => { +const list = System.create({ readonly: true, components: { with: [player, score] }, order: byScore }, row => { console.log(row[player], row[score]); }); @@ -42,12 +42,12 @@ const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ looku lookup.byId(input.id)?.[player] ?? null); const players = Index.create({ key: [], components: { with: [player] } }); -const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { +const countPlayers = System.create({ readonly: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { console.log("players", lookup.players().length); }); const crowds: number[] = []; -const readCrowds = System.create({ observer: true, components: { with: [crowded] } }, row => { +const readCrowds = System.create({ readonly: true, components: { with: [crowded] } }, row => { crowds.push(row[crowded]); }); diff --git a/ecs/packages/ecs/src/guide/14-kernels.ts b/ecs/packages/ecs/src/guide/14-kernels.ts index 4810c426..bab8930d 100644 --- a/ecs/packages/ecs/src/guide/14-kernels.ts +++ b/ecs/packages/ecs/src/guide/14-kernels.ts @@ -29,7 +29,7 @@ const slow = System.create({ kernel: false, components: { with: [velocity] } }, }); const moved: string[] = []; -const read = System.create({ observer: true, components: { with: [position, velocity] } }, row => { +const read = System.create({ readonly: true, components: { with: [position, velocity] } }, row => { moved.push(`at ${row[position][0]}, moving at ${row[velocity][0]}`); }); diff --git a/ecs/packages/ecs/src/guide/15-reductions.ts b/ecs/packages/ecs/src/guide/15-reductions.ts index 9bd5345e..7c50d42c 100644 --- a/ecs/packages/ecs/src/guide/15-reductions.ts +++ b/ecs/packages/ecs/src/guide/15-reductions.ts @@ -23,7 +23,7 @@ const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) reduce[count](1); }); -const report = System.create({ observer: true }, ({ resources }) => +const report = System.create({ readonly: true }, ({ resources }) => `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`); const spawn = System.create(({ commands }) => { diff --git a/ecs/packages/ecs/src/guide/16-gpu.ts b/ecs/packages/ecs/src/guide/16-gpu.ts index 316c8c2e..ef86ac6c 100644 --- a/ecs/packages/ecs/src/guide/16-gpu.ts +++ b/ecs/packages/ecs/src/guide/16-gpu.ts @@ -32,7 +32,7 @@ const draw = System.render({ shader: { accessors: { dotPosition: position, dotSi }); const shaderCode = Gpu.wgsl(draw); -const read = System.create({ observer: true, components: { with: [position] } }, row => { +const read = System.create({ readonly: true, components: { with: [position] } }, row => { console.log("at", row[position]); }); diff --git a/ecs/packages/ecs/src/guide/17-hierarchies.ts b/ecs/packages/ecs/src/guide/17-hierarchies.ts index f76481b3..60bca58b 100644 --- a/ecs/packages/ecs/src/guide/17-hierarchies.ts +++ b/ecs/packages/ecs/src/guide/17-hierarchies.ts @@ -21,7 +21,7 @@ const place = System.create({ components: { with: [offset, placed] }, up: { thro }); const where: string[] = []; -const read = System.create({ observer: true, components: { with: [label, placed] } }, row => { +const read = System.create({ readonly: true, components: { with: [label, placed] } }, row => { where.push(`${row[label]} at ${row[placed]}`); }); diff --git a/ecs/packages/ecs/src/guide/18-identity.out b/ecs/packages/ecs/src/guide/18-identity.out index bc488041..65c656a9 100644 --- a/ecs/packages/ecs/src/guide/18-identity.out +++ b/ecs/packages/ecs/src/guide/18-identity.out @@ -1 +1 @@ -Error: System "explode" (@adobe/ecs@0.2.1/src/guide/18-identity.ts#explode) failed: boom +Error: System "explode" (@adobe/ecs@0.2.3/src/guide/18-identity.ts#explode) failed: boom diff --git a/ecs/packages/ecs/src/guide/19-threads.md b/ecs/packages/ecs/src/guide/19-threads.md index 0b77804d..d53bf819 100644 --- a/ecs/packages/ecs/src/guide/19-threads.md +++ b/ecs/packages/ecs/src/guide/19-threads.md @@ -11,6 +11,8 @@ A *logical thread* is where objects live and the code touching them runs: the pa `World.join(port, { threads: ["audio"] })`, claiming the threads it fills. Usually the other end of the channel goes to a worker; [here both ends](19-threads.ts#L44-L54) are on one thread. -- `share` resolves once the thread joined, with the observer systems it added as `join` resolved, before awaiting - anything else. Those observers run there after the world's sync points: `listen` hears what `mix` wrote. +- `share` resolves once the thread joined, with the readonly systems it added as `join` resolved, before awaiting + anything else. Those readonly systems run there after the world's sync points: `listen` hears what `mix` wrote. +- A joined thread adds only [readonly systems](03-systems.md), which run on it; a system that writes is added on the + world's thread. - A joined world runs systems as the world does: [`audio.run`](19-threads.ts#L60-L61) spawns and mixes too. diff --git a/ecs/packages/ecs/src/guide/19-threads.ts b/ecs/packages/ecs/src/guide/19-threads.ts index 9c825673..4ef1f24f 100644 --- a/ecs/packages/ecs/src/guide/19-threads.ts +++ b/ecs/packages/ecs/src/guide/19-threads.ts @@ -22,7 +22,7 @@ const mix = System.create({ components: { with: [volume] } }, (row, { resources }); let heard = 0; -const listen = System.create({ observer: true }, ({ resources }) => { +const listen = System.create({ readonly: true }, ({ resources }) => { heard = resources[levels].size; }); @@ -31,7 +31,7 @@ const spawn = System.create(({ commands }) => { commands.spawn([volume])({ [volume]: 0.3 }); }); -const countVoices = System.create({ observer: true }, ({ resources }) => resources[levels].size); +const countVoices = System.create({ readonly: true }, ({ resources }) => resources[levels].size); /** Waits a turn of the event loop at a time until `done`, for two seconds at most. */ const until = async (done: () => boolean): Promise => { diff --git a/ecs/packages/ecs/src/guide/20-shared.ts b/ecs/packages/ecs/src/guide/20-shared.ts index 8b94fcf0..2cea2f8f 100644 --- a/ecs/packages/ecs/src/guide/20-shared.ts +++ b/ecs/packages/ecs/src/guide/20-shared.ts @@ -56,7 +56,7 @@ let drawn = 0; let left = Infinity; let right = -Infinity; const draw = System.create({ - observer: true, + readonly: true, components: { with: [position], where: { [cell]: (at, { resources }) => resources[camera]?.sees(at) === true } }, }, row => { drawn += 1; @@ -66,7 +66,7 @@ const draw = System.create({ let refreshed = 0; const refresh = System.create({ - observer: true, + readonly: true, components: { with: [position], changed: [position], where: { [cell]: (at, { resources }) => resources[camera]?.sees(at) === true } }, }, () => { refreshed += 1; @@ -74,7 +74,7 @@ const refresh = System.create({ // Only chunk [4, 0], which no unit is in until one crosses into it. let east = 0; -const countEast = System.create({ observer: true, components: { with: [position], where: { [cell]: [4, 0] } } }, () => { +const countEast = System.create({ readonly: true, components: { with: [position], where: { [cell]: [4, 0] } } }, () => { east += 1; }); diff --git a/ecs/packages/ecs/src/guide/21-dom.md b/ecs/packages/ecs/src/guide/21-dom.md index c2e98778..3c2ced7e 100644 --- a/ecs/packages/ecs/src/guide/21-dom.md +++ b/ecs/packages/ecs/src/guide/21-dom.md @@ -13,9 +13,9 @@ same. Each tick runs one composite: 1. [`move`](21-dom.ts#L38-L44) moves every particle and turns it back from the walls. -2. [`create`](21-dom.ts#L46-L51) gives each particle without a dot an element, with `commands.add`. Observers can't +2. [`create`](21-dom.ts#L46-L51) gives each particle without a dot an element, with `commands.add`. Readonly systems can't issue commands, so it ticks, and its condition `is: [stage]` waits for a stage. -3. [`draw`](21-dom.ts#L52-L60), an observer, sets each moved dot's `style.translate`, a transform, which the page +3. [`draw`](21-dom.ts#L52-L60), a readonly system, sets each moved dot's `style.translate`, a transform, which the page doesn't lay out again. `changed: [position]` skips the dots that didn't move. [A finalizer](21-dom.ts#L61-L63) removes a particle's element when the particle goes, and every element when the diff --git a/ecs/packages/ecs/src/guide/21-dom.ts b/ecs/packages/ecs/src/guide/21-dom.ts index c386071c..32edf5a5 100644 --- a/ecs/packages/ecs/src/guide/21-dom.ts +++ b/ecs/packages/ecs/src/guide/21-dom.ts @@ -51,7 +51,7 @@ const create = System.create({ components: { with: [position], not: [dot] }, res }); // Moves each dot whose particle moved, by a transform, which the page doesn't lay out again. const draw = System.create({ - observer: true, + readonly: true, components: { with: [position, dot], optional: [traced], changed: [position] }, }, (row, { resources }) => { const [x, y] = row[position]; diff --git a/ecs/packages/ecs/src/guide/22-canvas.md b/ecs/packages/ecs/src/guide/22-canvas.md index 57a03540..95bc2310 100644 --- a/ecs/packages/ecs/src/guide/22-canvas.md +++ b/ecs/packages/ecs/src/guide/22-canvas.md @@ -2,7 +2,7 @@ The same particles, drawn in immediate mode: each frame, the canvas is cleared and drawn again whole. -[`draw`](22-canvas.ts#L52-L80) is one observer without components, so it runs once per tick, not per particle. +[`draw`](22-canvas.ts#L52-L80) is one readonly system without components, so it runs once per tick, not per particle. - `changed: [clock]` runs it after every tick, and `is: [pen]` waits for a 2D context. - It reads every particle through [an index without a key](22-canvas.ts#L50-L51), `key: []`, and the traced one diff --git a/ecs/packages/ecs/src/guide/22-canvas.ts b/ecs/packages/ecs/src/guide/22-canvas.ts index d8419aa7..6ebe0c70 100644 --- a/ecs/packages/ecs/src/guide/22-canvas.ts +++ b/ecs/packages/ecs/src/guide/22-canvas.ts @@ -51,7 +51,7 @@ const particles = Index.create({ key: [], components: { with: [position] } }); const tracedOnes = Index.create({ key: [], components: { with: [position, traced] } }); // Clears the canvas and draws every particle, after each tick. const draw = System.create({ - observer: true, + readonly: true, resources: { is: [pen], changed: [clock] }, lookup: { particles: { index: particles }, traced: { index: tracedOnes } }, }, ({ resources, lookup }) => { diff --git a/ecs/packages/ecs/src/guide/CLAUDE.md b/ecs/packages/ecs/src/guide/CLAUDE.md index 5522dd7e..26401f31 100644 --- a/ecs/packages/ecs/src/guide/CLAUDE.md +++ b/ecs/packages/ecs/src/guide/CLAUDE.md @@ -45,5 +45,5 @@ it in `NN-name.type-test.ts`, under a `// @ts-expect-error` that the package's b before they're printed. - 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 = [position, velocity] as const`. -- Use the public API only, as a consumer imports it, not internal modules. Read a world through observer systems. +- Use the public API only, as a consumer imports it, not internal modules. Read a world through readonly systems. - Lines change as code does: the test of links fails on any that no longer covers whole code, so move them with it. diff --git a/ecs/packages/ecs/src/guide/guide.test.ts b/ecs/packages/ecs/src/guide/guide.test.ts index f5337550..b6f9a548 100644 --- a/ecs/packages/ecs/src/guide/guide.test.ts +++ b/ecs/packages/ecs/src/guide/guide.test.ts @@ -69,12 +69,12 @@ const listed = [...read("README.md").matchAll(/^(?:- |(\d+)\. )\[([^\]]+)\]\(([^ /** What each chapter teaches, which its prose names as code and its program does; 0 is the unnumbered keys chapter's. */ const teaches: { readonly [chapter: number]: readonly string[] } = { 0: ["Symbol()", "declare module \"@adobe/ecs\"", "Components", "Resources", "row[score]", "resources[bonus]", "default"], - 1: ["Resources", "default", "World.create", "observer: true", "run", "ReadonlyMap", "Entity"], + 1: ["Resources", "default", "World.create", "readonly: true", "run", "ReadonlyMap", "Entity"], 2: ["Components", "type: true", "as const", "commands.spawn", "commands.remove", "with: [...Particle]"], - 3: ["System.create", "with", "not", "observer: true", "optional: [velocity]"], + 3: ["System.create", "with", "not", "readonly: true", "optional: [velocity]"], 4: ["clock", "delta", "cadence", "always", "resources: { changed: [elapsed] }", "wake", "reduce[wake]", "world.add", "world.remove"], 5: ["after", "during", "System.create([heal, hit])", "during: [update]"], - 6: ["commands.spawn", "commands.add", "commands.despawn", "after: [spawn]"], + 6: ["commands.spawn", "commands.add", "commands.despawn", "after: [spawn]", "readonly: true"], 7: ["resources: { changed: [level] }", "changed: [score]", "resources: { is: [map], not: [paused] }", "resources[map]"], 8: ["world.run(hit, { amount: 3 })", "target: \"entity\""], 9: ["Rule.create", "derived: true"], @@ -106,10 +106,12 @@ const mutations: { readonly [chapter: number]: readonly (readonly [string, strin ], 1: [["World.create({ [gravity]: -1.6 })", "World.create()"], ["default: -9.8 ", ""], ["{ default: -Infinity }", "{}"]], 2: [["commands.remove(row.entity, enemy);", ""], ["{ [position]: [0, 10, 0] }", "{}"]], - 3: [["not: [frozen]", "not: []"], ["System.create([count, fail])", "System.create([fail])"]], + 3: [ + ["not: [frozen]", "not: []"], ["System.create([count, fail])", "System.create([fail])"], + ], 4: [["world.remove(advance);", ""]], 5: [["after: [hit] ", ""], ["during: [update] ", ""]], - 6: [["commands.add(hero, health, 5);", ""], ["commands.despawn(commands.spawn([health])({ [health]: 1 }));", "commands.spawn([health])({ [health]: 1 });"], ["watched.add(watch);", ""]], + 6: [["commands.add(hero, health, 5);", ""], ["commands.despawn(commands.spawn([health])({ [health]: 1 }));", "commands.spawn([health])({ [health]: 1 });"], ["watched.add(watch);", ""], ["const watch = System.create({ readonly: true, ", "const watch = System.create({ "]], 7: [["resources: { changed: [level] } ", ""], [", changed: [score]", ""], [", not: [paused]", ""], ["is: [map], ", ""]], 8: [["target: \"entity\",", ""]], 9: [["with: [mesh], not: [hidden]", "with: [mesh]"], ["Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] });", ""], ["Rule.create({ components: { with: [drawn] }, derive: [layer] });", ""]], diff --git a/ecs/packages/ecs/src/implementation/compiler/describe.test-helper.ts b/ecs/packages/ecs/src/implementation/compiler/describe.test-helper.ts index 2bde7e8a..9f22a7b7 100644 --- a/ecs/packages/ecs/src/implementation/compiler/describe.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/compiler/describe.test-helper.ts @@ -51,7 +51,7 @@ export const spawn = System.create(({ commands }) => { commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [1, 0, 0] }); }); -export const observe = System.create({ observer: true, components: { with: [position] } }, row => { +export const observe = System.create({ readonly: true, components: { with: [position] } }, row => { console.log(row[position]); }); diff --git a/ecs/packages/ecs/src/implementation/compiler/describe.test.ts b/ecs/packages/ecs/src/implementation/compiler/describe.test.ts index 5d5a32c0..4be7f582 100644 --- a/ecs/packages/ecs/src/implementation/compiler/describe.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/describe.test.ts @@ -109,7 +109,7 @@ describe("a module's systems described", () => { it("says why systems that run as written have no code: per-run systems, observers, render systems, kernel: false and one declared in a function", () => { expect(described("spawn")).toMatchObject({ kind: "per-run", runs: `Its body as written, on the "world" thread` }); for (const backend of ["js", "wasm", "wgsl"] as const) expect(reasonOf(described("spawn")[backend])).toBe("per-run systems run as written; only row systems compile"); - expect(described("observe")).toMatchObject({ kind: "observer", js: { kind: "none", reason: expect.stringMatching(/^an observer runs as written/) } }); + expect(described("observe")).toMatchObject({ kind: "readonly", js: { kind: "none", reason: expect.stringMatching(/^a readonly system runs as written/) } }); expect(described("draw")).toMatchObject({ kind: "render", runs: expect.stringContaining("while the world has a GPU device"), wgsl: { kind: "none", reason: expect.stringMatching(/^a render system runs as written/) } }); const unflagged = described("unflagged"); expect(unflagged).toMatchObject({ kernel: false, js: { kind: "none", reason: "as written, because `kernel: false`" }, wasm: { kind: "none", reason: "as written, because `kernel: false`" }, wgsl: { kind: "eligible" } }); diff --git a/ecs/packages/ecs/src/implementation/compiler/describe.ts b/ecs/packages/ecs/src/implementation/compiler/describe.ts index 11f7d35a..9e5ac6fd 100644 --- a/ecs/packages/ecs/src/implementation/compiler/describe.ts +++ b/ecs/packages/ecs/src/implementation/compiler/describe.ts @@ -30,7 +30,7 @@ export type Backend = * What runs a system: over every matched row, once per run, as an observer, on one entity its input names (`target`), * or as a render system. */ -export type SystemKind = "row" | "per-run" | "observer" | "target" | "render"; +export type SystemKind = "row" | "per-run" | "readonly" | "target" | "render"; /** * A system of a module as the build compiles it: its call's offsets in the code transformed, the binding it's @@ -427,7 +427,7 @@ export const describeSystems = async (sites: TransformedSites, format: (code: st const kernel: boolean | "gpu" | null = literal === true || literal === false ? literal : literal === "gpu" ? "gpu" : null; const kind: SystemKind = created === "render" ? "render" : declaration === undefined ? "per-run" - : propertyOf(declaration, "observer") !== null ? "observer" + : propertyOf(declaration, "readonly") !== null ? "readonly" : propertyOf(declaration, "target") !== null ? "target" : propertyOf(declaration, "components") !== null ? "row" : "per-run"; const base = { start: node.start, end: node.end, binding: bindings.get(node) ?? null, kind, kernel }; @@ -442,7 +442,7 @@ export const describeSystems = async (sites: TransformedSites, format: (code: st ? threadOf(declaration, body, kind === "render" ? positionsOf("render", declaration) : positionsOf("system", declaration)) : "world"; if (kind === "render") return asWritten("a render system runs as written, recording GPU work with shaders of your own; `Gpu.wgsl` gives them their accessors", "world"); if (kind === "per-run") return asWritten("per-run systems run as written; only row systems compile", written()); - if (kind === "observer") return asWritten("an observer runs as written, reading the settled state after the run's last sync point; only row systems that aren't observers compile", written()); + if (kind === "readonly") return asWritten("a readonly system runs as written, reading the settled state after the run's last sync point; only row systems that aren't readonly compile", written()); if (kind === "target") return asWritten("a system with `target` runs as written, once, on the entity its input names; only systems over every matched row compile", written()); if (declaration === undefined || body === undefined || body.type === "SpreadElement" || !isFunction(body)) { return asWritten(declaration === undefined ? "its declaration isn't written in its call, so the build can't read whether it's a kernel" : "its body isn't a function written in place or bound at module scope, so the build can't read it", "world"); diff --git a/ecs/packages/ecs/src/implementation/compiler/hygiene.test.ts b/ecs/packages/ecs/src/implementation/compiler/hygiene.test.ts index d9426c1a..fde2e102 100644 --- a/ecs/packages/ecs/src/implementation/compiler/hygiene.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -34,7 +34,7 @@ const spawn = System.create(({ commands }) => { }); const read: number[][] = []; -const readAll = System.create({ observer: true, components: { with: [p, c, l, s, e, v0, v1] } }, row => { +const readAll = System.create({ readonly: 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]]); }); diff --git a/ecs/packages/ecs/src/implementation/compiler/plugin.test.ts b/ecs/packages/ecs/src/implementation/compiler/plugin.test.ts index 8a0777f3..22a6a6e1 100644 --- a/ecs/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -645,7 +645,7 @@ 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 count = System.create({ readonly: true, components: { with: [key] } }, (row) => { total += row[key]; }); const world = World.create(); await world.run(spawn); await world.run(spawn); @@ -662,7 +662,7 @@ 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]; }); +const count = System.create({ readonly: true, components: { with: [score] } }, (row) => { counted += row[score]; }); export const copies = World === OtherWorld ? 1 : 2; export const run = async () => { const world = World.create(); diff --git a/ecs/packages/ecs/src/implementation/compiler/signature.ts b/ecs/packages/ecs/src/implementation/compiler/signature.ts index 768eae16..f915a301 100644 --- a/ecs/packages/ecs/src/implementation/compiler/signature.ts +++ b/ecs/packages/ecs/src/implementation/compiler/signature.ts @@ -120,7 +120,7 @@ export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => } } if (declaration.properties.some(property => property.type === "SpreadElement")) return null; - if (!declares(declaration, "components") || declares(declaration, "observer") || declares(declaration, "target")) return null; + if (!declares(declaration, "components") || declares(declaration, "readonly") || declares(declaration, "target")) return null; return { target: "cpu", required: false }; }; diff --git a/ecs/packages/ecs/src/implementation/compiler/transform.test.ts b/ecs/packages/ecs/src/implementation/compiler/transform.test.ts index 2d4b209b..e9efd368 100644 --- a/ecs/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/ecs/packages/ecs/src/implementation/compiler/transform.test.ts @@ -335,7 +335,7 @@ describe("kernels by default", () => { 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: { 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({ readonly: 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); }); @@ -1418,11 +1418,11 @@ describe("inferred access", () => { }); 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); }")); + const result = await transform(system("readonly: 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 row it can't follow with a warning unless the declaration says observer: true, as the world reads it", async () => { + test("a row it can't follow with a warning unless the declaration says readonly: 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); diff --git a/ecs/packages/ecs/src/implementation/compiler/transform.ts b/ecs/packages/ecs/src/implementation/compiler/transform.ts index 3cd4b69b..5d050914 100644 --- a/ecs/packages/ecs/src/implementation/compiler/transform.ts +++ b/ecs/packages/ecs/src/implementation/compiler/transform.ts @@ -141,7 +141,7 @@ export 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. */ -/** Whether a declaration gives `name` the literal `true`, as the world takes `observer`. */ +/** Whether a declaration gives `name` the literal `true`, as the world takes `readonly`. */ 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); @@ -313,9 +313,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); - // An observer's row, and a lookup's rows, it can't follow are taken as read whole, which is what their + // A readonly system'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 && !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.`); + if (used.query === null && !declaresTrue(declared, "readonly")) 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, whereReads.has(node) ? whereReads.get(node) ?? null : undefined); if (kernel !== undefined) { diff --git a/ecs/packages/ecs/src/implementation/world/change-blocks.test.ts b/ecs/packages/ecs/src/implementation/world/change-blocks.test.ts index d727de30..695239ad 100644 --- a/ecs/packages/ecs/src/implementation/world/change-blocks.test.ts +++ b/ecs/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -89,7 +89,7 @@ const setGroup = System.create({ target: "entity", components: { with: [group] } // Readers. let found: number[] = []; 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 observed = System.create({ readonly: 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]); }); diff --git a/ecs/packages/ecs/src/implementation/world/commands.ts b/ecs/packages/ecs/src/implementation/world/commands.ts index 1f0fdccd..9a8f3c5f 100644 --- a/ecs/packages/ecs/src/implementation/world/commands.ts +++ b/ecs/packages/ecs/src/implementation/world/commands.ts @@ -1,8 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Commands } from '../../interface/system/commands.js'; -import { addCommand, despawnCommand, pushCommand, pushComponent, removeCommand, type CommandBuffer } from './buffer.js'; +import { addCommand, createBuffer, despawnCommand, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; +import { reserve } from './entities.js'; import { listShapeOf, presetSpawnerOf, spawnerOf, type ListShape } from './lists.js'; import { commandIdOf, idOf, storedShared, type Key, type State } from './state.js'; +import { spawnTables } from './tables.js'; +import { declarationOf, isWorldWritten, kindOf, labelOf, threadOfDeclaration, whyUnregistered } from '../../interface/registry/index.js'; +import { matchesSchema } from '../../interface/registry/schemas.js'; const collect = (state: State, buffer: CommandBuffer, key: Key, value: unknown): void => { if (value !== undefined) pushComponent(buffer, idOf(state, key), value); @@ -44,6 +48,38 @@ const addShared = (state: State, buffer: CommandBuffer, entity: number, shape: L for (let k = 0; k < keys.length; k++) pushComponent(buffer, ids[k], values[k]); }; +/** + * Throws for a key of `resources` that isn't a resource, is one only the world writes or one living on another thread, + * or is given a value its schema doesn't take. + */ +const checkResources = (resources: object): void => { + for (const key of Object.getOwnPropertySymbols(resources)) { + const fail = (message: string): never => { throw new Error(`commands.spawnTables: "${labelOf(key)}" ${message}.`); }; + if (kindOf(key) !== "resource") fail(`isn't a registered resource: ${whyUnregistered(key)}`); + if (isWorldWritten(key)) fail("is written only by the world"); + const declaration = declarationOf(key); + if (declaration === undefined) continue; + const thread = threadOfDeclaration(declaration); + if (thread !== null && thread !== "world") fail(`lives on thread "${thread}", not the world's`); + if (declaration.type !== undefined && !matchesSchema(declaration.type, Reflect.get(resources, key))) fail("is given a value its type doesn't take"); + } +}; + +/** + * Queues `resources`' values for the sync point as the world's own change to its resource entity, which it reserves + * when the world has none yet: a buffer of their own, since the sync point skips systems' commands to that entity. + */ +const queueResources = (state: State, resources: object): void => { + if (Object.getOwnPropertySymbols(resources).length === 0) return; + const buffer = createBuffer(); + if (state.resourceEntity < 0) { + state.resourceEntity = reserve(state.entities); + pushValues(state, buffer, spawnCommand, state.resourceEntity, resources); + } + else pushValues(state, buffer, addCommand, state.resourceEntity, resources); + state.queued.push({ buffer, guarded: false }); +}; + /** Values of a list add given none, which only an untyped caller does. */ const empty = {}; @@ -83,6 +119,13 @@ export const createCommands = (state: State, target: { readonly buffer: CommandB pushComponent(buffer, ids[k], value === undefined ? starts[k] : value); } }, + spawnTables: (tables, resources) => { + if (resources === undefined) return spawnTables(state, tables); + checkResources(resources); + const spawned = spawnTables(state, tables); + queueResources(state, resources); + return spawned; + }, remove: (entity: number, components: symbol | readonly symbol[]) => { const { buffer } = target; if (typeof components === "symbol") { diff --git a/ecs/packages/ecs/src/implementation/world/defaults.test.ts b/ecs/packages/ecs/src/implementation/world/defaults.test.ts index 8b6c4c9a..6306b000 100644 --- a/ecs/packages/ecs/src/implementation/world/defaults.test.ts +++ b/ecs/packages/ecs/src/implementation/world/defaults.test.ts @@ -56,7 +56,7 @@ describe("a derived component declared without a default", () => { commands.spawn([tagged])({}); }); const seen: unknown[] = []; - const read = System.create({ observer: true, components: { with: [index] } }, row => { seen.push(row[index]); }); + const read = System.create({ readonly: true, components: { with: [index] } }, row => { seen.push(row[index]); }); await using first = createWorld(); await using second = createWorld(); for (const world of [first, second]) { diff --git a/ecs/packages/ecs/src/implementation/world/entities.ts b/ecs/packages/ecs/src/implementation/world/entities.ts index 867efe71..12867323 100644 --- a/ecs/packages/ecs/src/implementation/world/entities.ts +++ b/ecs/packages/ecs/src/implementation/world/entities.ts @@ -70,6 +70,12 @@ export const createEntities = (memory: Memory): Entities => { /** Reserves an entity. Safe to call from any thread between sync points. */ export const reserve = ({ cursor, free, generation }: Reservations): Entity => entityAt(cursor, free, generation, Atomics.add(cursor, taken, 1)); +/** Reserves an entity for each of `into`'s elements, with one atomic add. Safe to call from any thread between sync points. */ +export const reserveRun = ({ cursor, free, generation }: Reservations, into: Float64Array): void => { + const first = Atomics.add(cursor, taken, into.length); + for (let i = 0; i < into.length; i++) into[i] = entityAt(cursor, free, generation, first + i); +}; + /** * Reservations a thread takes in blocks of positions, so threads spawning together don't contend for the cursor. * Positions a block leaves unused are reserved entities that never spawn, which the sync point releases. diff --git a/ecs/packages/ecs/src/implementation/world/finalizers.test.ts b/ecs/packages/ecs/src/implementation/world/finalizers.test.ts index 1a5ce130..cc2d4df6 100644 --- a/ecs/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/ecs/packages/ecs/src/implementation/world/finalizers.test.ts @@ -137,7 +137,7 @@ describe("finalizers", () => { 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 }) => { + const watch = System.create({ readonly: true, lookup: { labels: { with: [label] } } }, ({ lookup }) => { seen = lookup.labels(other)?.[label] ?? null; }); const world = createWorld({ [workers]: 0 }); diff --git a/ecs/packages/ecs/src/implementation/world/index-changed.test.ts b/ecs/packages/ecs/src/implementation/world/index-changed.test.ts index 00a92506..34b72720 100644 --- a/ecs/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/ecs/packages/ecs/src/implementation/world/index-changed.test.ts @@ -109,7 +109,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const orders: string[] = []; - const ordering = System.create({ observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { + const ordering = System.create({ readonly: 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); @@ -205,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: { with: [total] } }, row => { + const readTotals = System.create({ readonly: true, components: { with: [total] } }, row => { totals.push(row[total]); }); const totalsOf = async (): Promise => { diff --git a/ecs/packages/ecs/src/implementation/world/index-joined.test.ts b/ecs/packages/ecs/src/implementation/world/index-joined.test.ts index 4689b912..bc34c11f 100644 --- a/ecs/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/ecs/packages/ecs/src/implementation/world/index-joined.test.ts @@ -87,7 +87,7 @@ const stirBands = System.create({ components: { with: [serial, band] } }, (row, /** 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({ - observer: true, + readonly: true, components: { with: [target, bandReader, label] }, lookup: { inBand: { index: bands } }, }, (row, { lookup }) => { @@ -219,14 +219,14 @@ const inOrder = System.create({ components: { with: [rank, label] }, order: rank 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: { with: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { +const readWhenMoved = System.create({ readonly: 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 } } }, () => {}); +const ownWhenMoved = System.create({ readonly: 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: { with: [label] } } }, ({ lookup }) => { +const readLabels = System.create({ readonly: true, lookup: { labeled: { with: [label] } } }, ({ lookup }) => { reading.labels = reading.entities.map(entity => lookup.labeled(entity)?.[label].text ?? "missing"); }); @@ -279,7 +279,7 @@ const heldSerials = Index.create({ key: [serial], unique: true, components: { wi 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, + readonly: true, lookup: { all: { index: held }, bySerial: { index: heldSerials } }, }, ({ lookup }) => { const all = lookup.all(); diff --git a/ecs/packages/ecs/src/implementation/world/index-order.test.ts b/ecs/packages/ecs/src/implementation/world/index-order.test.ts index 4f5a0963..39875a39 100644 --- a/ecs/packages/ecs/src/implementation/world/index-order.test.ts +++ b/ecs/packages/ecs/src/implementation/world/index-order.test.ts @@ -72,7 +72,7 @@ const expected = async (world: CreatedWorld, only?: (weighed: number) => boolean }; const markerValues: number[] = []; -const readMarkers = System.create({ observer: true, components: { with: [marker] } }, row => { markerValues.push(row[marker]); }); +const readMarkers = System.create({ readonly: 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 => { diff --git a/ecs/packages/ecs/src/implementation/world/join.test.ts b/ecs/packages/ecs/src/implementation/world/join.test.ts index 881f8995..5a127a5b 100644 --- a/ecs/packages/ecs/src/implementation/world/join.test.ts +++ b/ecs/packages/ecs/src/implementation/world/join.test.ts @@ -57,16 +57,16 @@ const idle = System.create({}, () => {}); const totalPlus = System.create({}, ({ resources }, input: { readonly plus: number }) => resources[total] + input.plus); const described = System.create(({ resources }) => ({ total: resources[total], names: resources[names] })); const unpostable = System.create(() => () => "a function"); -const readTotal = System.create({ observer: true }, ({ resources }) => resources[total]); +const readTotal = System.create({ readonly: true }, ({ resources }) => resources[total]); /** Writes levels, an object of joinTestAudio, so it runs on the thread claiming it. */ const mix = System.create({}, ({ resources }, input: { readonly level: number }) => { resources[levels] = [...resources[levels], input.level]; }); /** An observer system recording each total it reads into `into`. */ -const totalsInto = (into: number[]) => System.create({ observer: true }, ({ resources }) => { into.push(resources[total]); }); +const totalsInto = (into: number[]) => System.create({ readonly: true }, ({ resources }) => { into.push(resources[total]); }); /** An observer system recording how many levels it reads into `into`. */ -const levelsInto = (into: number[]) => System.create({ observer: true }, ({ resources }) => { into.push(resources[levels].length); }); -const failingRead = System.create({ observer: true }, () => { throw new Error("joinTest read failure"); }); +const levelsInto = (into: number[]) => System.create({ readonly: true }, ({ resources }) => { into.push(resources[levels].length); }); +const failingRead = System.create({ readonly: true }, () => { throw new Error("joinTest read failure"); }); /** A world shared with a thread joined through a channel, and the joined world, once both say it joined. */ const joined = async (threads: readonly ("main")[] = []): Promise<{ readonly world: CreatedWorld; readonly remote: World; readonly channel: MessageChannel }> => { diff --git a/ecs/packages/ecs/src/implementation/world/join.ts b/ecs/packages/ecs/src/implementation/world/join.ts index d14e65e5..44c36a03 100644 --- a/ecs/packages/ecs/src/implementation/world/join.ts +++ b/ecs/packages/ecs/src/implementation/world/join.ts @@ -33,6 +33,12 @@ const observerLeavesOf = (definition: Definition): readonly Leaf[] | null => { return schedule.tiers.length === 0 && schedule.observer.length > 0 ? schedule.observer : null; }; +/** Throws for a readonly system reading tables, which only the world's thread gives. */ +const refuseTables = (leaves: readonly Leaf[]): void => { + const reading = leaves.find(leaf => leaf.declaration.tables !== undefined); + if (reading !== undefined) throw new Error(`System "${reading.name}" reads tables, which only the world's thread gives: run or add it there.`); +}; + /** A run not yet settled: how to settle it, and its rejection once posted. */ type Run = { readonly resolve: (value: unknown) => void; readonly reject: (error: unknown) => void; message: ToJoined | null; error: unknown; readonly local: Local | null }; @@ -659,6 +665,7 @@ export const joinWorld = (port: Parameters[0], options: { readon // This thread's own observer systems run here, after the sync point of an empty run of the world's. const leaves = observerLeavesOf(definition); if (leaves !== null) { + refuseTables(leaves); if (failure !== null) return Promise.reject(failure); const locals = leaves.map(localOf); if (locals.length === 1 && definition.kind !== "composite") return runNumbered(locals[0].number, locals[0].leaf, undefined, locals[0]); @@ -680,7 +687,8 @@ export const joinWorld = (port: Parameters[0], options: { readon add: system => { const definition = requireDefinition(system); const leaves = observerLeavesOf(definition); - if (leaves === null) throw new Error("A joined thread adds only observer systems; others are added to ticks on the world's thread."); + if (leaves === null) throw new Error(`System "${definition.name}" isn't readonly, and a joined thread adds only readonly systems: declare it readonly: true to read the world from this thread, or add it on the world's thread.`); + refuseTables(leaves); if (added.has(definition) || failure !== null) return; const locals = leaves.map(localOf); added.set(definition, locals); @@ -692,7 +700,7 @@ export const joinWorld = (port: Parameters[0], options: { readon }, remove: system => { const definition = requireDefinition(system); - if (observerLeavesOf(definition) === null) throw new Error("A joined thread removes only observer systems; others are removed from ticks on the world's thread."); + if (observerLeavesOf(definition) === null) throw new Error(`System "${definition.name}" isn't readonly, and a joined thread removes only readonly systems: others are removed on the world's thread.`); const locals = added.get(definition); if (locals === undefined) return; added.delete(definition); diff --git a/ecs/packages/ecs/src/implementation/world/joined-observer.test.ts b/ecs/packages/ecs/src/implementation/world/joined-observer.test.ts index 7e2e3ed6..3ab7650d 100644 --- a/ecs/packages/ecs/src/implementation/world/joined-observer.test.ts +++ b/ecs/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: { with: [amount] } }, row => { seen.push(row[amount]); }); +const watch = System.create({ readonly: 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/ecs/packages/ecs/src/implementation/world/joining-worker.fixture.ts b/ecs/packages/ecs/src/implementation/world/joining-worker.fixture.ts index dfc1d0fb..5d628793 100644 --- a/ecs/packages/ecs/src/implementation/world/joining-worker.fixture.ts +++ b/ecs/packages/ecs/src/implementation/world/joining-worker.fixture.ts @@ -42,7 +42,7 @@ const channel = scopeChannel(); let hosted: ReturnType | null = null; /** What the observer system `listen` adds heard. */ const heard: number[] = []; -const hear = System.create({ observer: true }, ({ resources }) => { heard.push(resources[total]); }); +const hear = System.create({ readonly: true }, ({ resources }) => { heard.push(resources[total]); }); const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); /** Ends this thread at once: node's worker exits, and a browser worker closes, dropping the tasks it queued. */ const exit = (): void => { diff --git a/ecs/packages/ecs/src/implementation/world/joining.fixture.ts b/ecs/packages/ecs/src/implementation/world/joining.fixture.ts index 512257fe..4a73c292 100644 --- a/ecs/packages/ecs/src/implementation/world/joining.fixture.ts +++ b/ecs/packages/ecs/src/implementation/world/joining.fixture.ts @@ -16,7 +16,7 @@ import { add, drive, idle, names, shelf, total, until } from "./joining.test-hel /** A system only this thread imports, which a world on a worker never did. */ const unimportedByWorker = System.create({}, () => {}); /** An observer system only this thread imports, reading an object of `main`, so it's placed on a thread claiming it. */ -const shelvedHere = System.create({ observer: true }, ({ resources }) => resources[shelf].length); +const shelvedHere = System.create({ readonly: true }, ({ resources }) => resources[shelf].length); type Worker = { readonly ask: (message: ToJoiningWorker, transfer?: readonly unknown[]) => Promise; diff --git a/ecs/packages/ecs/src/implementation/world/joining.test-helper.ts b/ecs/packages/ecs/src/implementation/world/joining.test-helper.ts index 9f3b1d78..0a3a26d3 100644 --- a/ecs/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -36,7 +36,7 @@ export const append = System.create({}, ({ resources }, input: { readonly name: return resources[names].length; }); /** Reads `total` from an observer system, which a joined thread runs itself. */ -export const readTotal = System.create({ observer: true }, ({ resources }) => resources[total]); +export const readTotal = System.create({ readonly: true }, ({ resources }) => resources[total]); export const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); diff --git a/ecs/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/ecs/packages/ecs/src/implementation/world/kernel-spawns.test.ts index 7c86e415..66820a3e 100644 --- a/ecs/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/ecs/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -63,7 +63,7 @@ const countAdded = System.create({ components: { added: [value], with: [made] } /** Each made entity's value, in storage order. */ const madeValues: number[] = []; -const readMade = System.create({ observer: true, components: { with: [value, made] } }, row => { madeValues.push(row[value]); }); +const readMade = System.create({ readonly: 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/ecs/packages/ecs/src/implementation/world/observe-factories.test.ts b/ecs/packages/ecs/src/implementation/world/observe-factories.test.ts index a339ecb0..dd5f60fc 100644 --- a/ecs/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -37,7 +37,7 @@ const nothing = System.create({}, () => {}); /** Counts passes: the observer passes after each run's sync point. */ const passes = { count: 0 }; -const counter = System.create({ observer: true }, () => { passes.count++; }); +const counter = System.create({ readonly: true }, () => { passes.count++; }); /** * A world of `count` entities, each amount its index and one more, at level 1: the runs below registered, so runners @@ -184,7 +184,7 @@ describe("observer factories", () => { it("rethrow only their own systems' failures from their runs, not those of observer systems the world added", async () => { using reported = catchOutOfBand(); await using world = await seeded(3); - const failing = System.create({ observer: true }, () => { throw new Error("added failed"); }); + const failing = System.create({ readonly: true }, () => { throw new Error("added failed"); }); world.add(failing); const caught: unknown[] = []; const original = globalThis.queueMicrotask; diff --git a/ecs/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/ecs/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index cc450f70..c8a6e255 100644 --- a/ecs/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -29,7 +29,7 @@ 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 idle = System.create({ readonly: true }, () => {}); const fast = System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1 }; resources[always] = true; diff --git a/ecs/packages/ecs/src/implementation/world/observe.test-helper.ts b/ecs/packages/ecs/src/implementation/world/observe.test-helper.ts index 585c0df3..9c6bb1b9 100644 --- a/ecs/packages/ecs/src/implementation/world/observe.test-helper.ts +++ b/ecs/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({ observer: true }, () => {}); +const settled = System.create({ readonly: true }, () => {}); /** * Resolves once every observer factory subscribed before it had its first value: after the factories' flush, which the diff --git a/ecs/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts b/ecs/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts index 83954ca9..40e9accd 100644 --- a/ecs/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts +++ b/ecs/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({ observer: true }, () => {}); +const placed = System.create({ readonly: true }, () => {}); channel.onMessage(async message => { if ("protocol" in message) return; diff --git a/ecs/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/ecs/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 353165ad..54a9b6d3 100644 --- a/ecs/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -32,7 +32,7 @@ export const setHolding = System.create({}, ({ resources }, input: { readonly ho }); /** 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: { changed: [tally] } }, ({ resources }) => { +export const watchTally = System.create({ readonly: true, resources: { changed: [tally] } }, ({ resources }) => { const value = resources[tally]; tallies.push(value); const { words } = gate; diff --git a/ecs/packages/ecs/src/implementation/world/observer-contained.test.ts b/ecs/packages/ecs/src/implementation/world/observer-contained.test.ts index 5eeafb5b..326daf44 100644 --- a/ecs/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -29,10 +29,10 @@ const pause = System.create({}, ({ resources }, input: { readonly paused: boolea }); const seen: { wide: number[][]; steps: number[] } = { wide: [], steps: [] }; -const watchWide = System.create({ observer: true, resources: { changed: [wide] } }, ({ resources }) => { +const watchWide = System.create({ readonly: true, resources: { changed: [wide] } }, ({ resources }) => { seen.wide.push([...resources[wide]]); }); -const watchSteps = System.create({ observer: true, resources: { not: [paused], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ readonly: true, resources: { not: [paused], changed: [steps] } }, ({ resources }) => { seen.steps.push(resources[steps]); }); diff --git a/ecs/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/ecs/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 98ffddcf..f0e42bfd 100644 --- a/ecs/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -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: { with: [stamp, spot, word] } }, (row, { resources }) => { +export const watchRows = System.create({ readonly: 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: { with: [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, lookup: { found: { with: [stamp, spot] } } }, ({ resources, lookup }) => { +export const lookRows = System.create({ readonly: true, lookup: { found: { with: [stamp, spot] } } }, ({ resources, lookup }) => { const value = resources[counter]; const run: Seen = { counter: value, rows: 0, wrong: 0 }; seen.push(run); @@ -146,7 +146,7 @@ export const nextPhase = System.create({}, ({ resources }) => { export const hot: number[] = []; 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({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +export const countHot = System.create({ readonly: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot.push(lookup.hot({ ">=": 150 }).length); }); @@ -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 }) => { +export const watchMotto = System.create({ readonly: true }, ({ resources }) => { const round = resources[mottoRound]; pauseAt(resources[stall], round + 1); mottos.push({ round, motto: resources[motto] }); diff --git a/ecs/packages/ecs/src/implementation/world/observer-joined.test.ts b/ecs/packages/ecs/src/implementation/world/observer-joined.test.ts index 7ffb95d5..6c2b4076 100644 --- a/ecs/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -59,43 +59,43 @@ 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: { changed: [level] } }, ({ resources }) => { +const watchLevel = System.create({ readonly: true, resources: { changed: [level] } }, ({ resources }) => { seen.calls++; seen.levels.push(resources[level]); }); -const watchAmounts = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { +const watchAmounts = System.create({ readonly: 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 startAmounts = System.create({ readonly: true }, () => { seen.amounts.push([]); }); const amountsPass = System.create([startAmounts, watchAmounts]); -const lookChanged = System.create({ observer: true, lookup: { found: { with: [amount], changed: [amount] } } }, ({ lookup }) => { +const lookChanged = System.create({ readonly: 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]; })); }); -const countTagged = System.create({ observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { +const countTagged = System.create({ readonly: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { seen.tagged.push(lookup.all().length); }); -const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { +const inCell = System.create({ readonly: 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 }) => { +const failing = System.create({ readonly: true }, ({ resources }) => { if (resources[level] > 1) throw new Error("observer joined failure"); }); -const readLabels = System.create({ observer: true, components: { with: [label, amount] } }, row => { +const readLabels = System.create({ readonly: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); /** On the joined thread, as it reads labels: each watched entity's label and amount, which the world's run resolves to. */ -const labelsThere = System.create({ observer: true, lookup: { labelled: { with: [label, amount] } } }, ({ lookup }) => +const labelsThere = System.create({ readonly: true, lookup: { labelled: { with: [label, amount] } } }, ({ lookup }) => watched.map(entity => { const row = lookup.labelled(entity); return row === null ? "none" : `${row[label].text}${row[amount]}`; })); /** On the joined thread, as it reads labels: throws there. */ -const failsThere = System.create({ observer: true, lookup: { labelled: { with: [label] } } }, ({ lookup }) => { +const failsThere = System.create({ readonly: true, lookup: { labelled: { with: [label] } } }, ({ lookup }) => { if (lookup.labelled(Entity.none) === null) throw new Error("observerJoinedTest failure there"); return 0; }); @@ -218,7 +218,7 @@ describe("observer systems on a joined thread", () => { 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 } } }, ({ lookup }) => { + const sizes = System.create({ readonly: 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 }); @@ -284,10 +284,18 @@ describe("observer systems on a joined thread", () => { await close(); }); - it("refuse only systems that aren't observer", async () => { + it("refuse only systems that aren't readonly, saying to declare them so", async () => { const { remote, close } = await joined(); - expect(() => remote.add(raise)).toThrow("A joined thread adds only observer systems"); - expect(() => remote.remove(raise)).toThrow("A joined thread removes only observer systems"); + expect(() => remote.add(raise)).toThrow(`System "raise" isn't readonly, and a joined thread adds only readonly systems: declare it readonly: true to read the world from this thread, or add it on the world's thread.`); + expect(() => remote.remove(raise)).toThrow(`System "raise" isn't readonly, and a joined thread removes only readonly systems: others are removed on the world's thread.`); + await close(); + }); + + it("refuse a readonly system reading tables, which only the world's thread gives", async () => { + const { remote, close } = await joined(); + const readTables = System.create({ readonly: true, tables: { components: [label] } }, ({ tables }) => tables.archetypes.length); + expect(() => remote.add(readTables)).toThrow(`System "readTables" reads tables, which only the world's thread gives: run or add it there.`); + await expect(remote.run(readTables)).rejects.toThrow(`System "readTables" reads tables, which only the world's thread gives: run or add it there.`); await close(); }); @@ -309,7 +317,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: { with: [label, amount] } }, row => { + const made = () => System.create({ readonly: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); const madeLabels = made(); diff --git a/ecs/packages/ecs/src/implementation/world/observer.browser.test.ts b/ecs/packages/ecs/src/implementation/world/observer.browser.test.ts index 14c876a3..042d9eda 100644 --- a/ecs/packages/ecs/src/implementation/world/observer.browser.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -27,11 +27,11 @@ const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } } }); let total = 0; -const sum = System.create({ observer: true, components: { with: [heat] } }, row => { total += row[heat]; }); +const sum = System.create({ readonly: true, components: { with: [heat] } }, row => { total += row[heat]; }); const byHeat = Index.create({ key: [heat], sorted: true }); let hot = 0; -const countHot = System.create({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +const countHot = System.create({ readonly: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot = lookup.hot({ ">=": 150 }).length; }); diff --git a/ecs/packages/ecs/src/implementation/world/observer.test.ts b/ecs/packages/ecs/src/implementation/world/observer.test.ts index 0d90b5fa..389f8133 100644 --- a/ecs/packages/ecs/src/implementation/world/observer.test.ts +++ b/ecs/packages/ecs/src/implementation/world/observer.test.ts @@ -37,33 +37,33 @@ const grow = System.create({ components: { with: [amount] } }, row => { row[amou const nothing = System.create({}, () => {}); // 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 => { +const watchLevel = System.create(giveAccess({ readonly: true }, { resources: { read: [level] } }), context => { seen.levels.push(context.resources[level]); seen.contexts.push(context); }); -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 }) => { +const watchAmounts = System.create({ readonly: true, components: { with: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); +const count = System.create({ readonly: true, components: { with: [amount] } }, () => { seen.counted.push(1); }); +const inCell = System.create({ readonly: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { seen.cells.push(lookup.found(7).length); }); -const failing = System.create({ observer: true }, ({ resources }) => { +const failing = System.create({ readonly: true }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); const raiseThenWatch = System.create([raise, watchLevel]); -const watchLevelChanges = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { +const watchLevelChanges = System.create({ readonly: true, resources: { changed: [level] } }, ({ resources }) => { seen.levels.push(resources[level]); }); /** The world a removing body acts on. */ let current: World | null = null; -const removeLevel = System.create({ observer: true }, () => { +const removeLevel = System.create({ readonly: true }, () => { seen.removals.push(1); current?.remove(watchLevel); }); /** Adds `watchLevel` to the world the test runs, during a run. */ const addWatch = System.create({}, () => { current?.add(watchLevel); }); -const addLevel = System.create({ observer: true }, () => { +const addLevel = System.create({ readonly: true }, () => { current?.add(watchLevel); }); diff --git a/ecs/packages/ecs/src/implementation/world/optional-workers.fixture.ts b/ecs/packages/ecs/src/implementation/world/optional-workers.fixture.ts index 91d1ecd8..7392f820 100644 --- a/ecs/packages/ecs/src/implementation/world/optional-workers.fixture.ts +++ b/ecs/packages/ecs/src/implementation/world/optional-workers.fixture.ts @@ -30,7 +30,7 @@ const assign = System.create({ kernel: true, components: { with: [position], opt row[velocity] = row[position]; }); const totals = { moved: 0, still: 0 }; -const sum = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { +const sum = System.create({ readonly: true, components: { with: [position], optional: [velocity] } }, row => { if (row[velocity] === undefined) totals.still += row[position]; else totals.moved += row[position]; }); diff --git a/ecs/packages/ecs/src/implementation/world/optional.test.ts b/ecs/packages/ecs/src/implementation/world/optional.test.ts index 1f4acd5b..af6ec92c 100644 --- a/ecs/packages/ecs/src/implementation/world/optional.test.ts +++ b/ecs/packages/ecs/src/implementation/world/optional.test.ts @@ -73,7 +73,7 @@ const copySpeedKernel = System.create({ 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]); }); +const readSpeeds = System.create({ readonly: 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[] = []; @@ -87,7 +87,7 @@ export const finalizeThere = Finalizer.create({ components: { with: [position, m 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 => { +const read = System.create({ readonly: 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]); @@ -109,7 +109,7 @@ const lookedThere = System.create({ components: { with: [position, marker] }, lo }); // 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 => { +const watchThere = System.create({ readonly: 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. @@ -165,7 +165,7 @@ const markEither = System.create({ kernel: true, components: { with: [mark], opt 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 watchStamped = System.create({ readonly: 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; }); @@ -195,7 +195,7 @@ describe("optional components", () => { 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 watch = System.create({ readonly: 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; @@ -212,7 +212,7 @@ describe("optional components", () => { 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 => { + const list = System.create({ readonly: true, components: { with: [position], optional: [velocity] }, order: byOrder }, row => { ordered.push(row[velocity]); }); await using world = inline(); @@ -297,7 +297,7 @@ describe("optional components", () => { await world.run(spawnMarked); stoppedThere.length = 0; await world.run(despawnAll); - await remote.run(System.create({ observer: true }, () => {})); + await remote.run(System.create({ readonly: true }, () => {})); expect(stoppedThere.sort()).toEqual(["10:5:null", "1:none:null"]); await remote.dispose(); await world.dispose(); diff --git a/ecs/packages/ecs/src/implementation/world/reclaim.test.ts b/ecs/packages/ecs/src/implementation/world/reclaim.test.ts index fc0624d6..d4cc30a1 100644 --- a/ecs/packages/ecs/src/implementation/world/reclaim.test.ts +++ b/ecs/packages/ecs/src/implementation/world/reclaim.test.ts @@ -42,7 +42,7 @@ const caught: number[] = []; export const catchUp = System.create({ kernel: false, components: { with: [position], changed: [position], where: { [cell]: (at, { resources }) => at[0] === resources[camera] } } }, row => { caught.push(row[position]); }); // On a joined thread. const heard: number[] = []; -export const hear = System.create({ observer: true, components: { with: [cell, position], where: { [cell]: at => at[0] === 50 } } }, row => { heard.push(row[cell][0] * 1000 + row[position]); }); +export const hear = System.create({ readonly: true, components: { with: [cell, position], where: { [cell]: at => at[0] === 50 } } }, row => { heard.push(row[cell][0] * 1000 + row[position]); }); /** Moves an entity to cell `[x, 0]`. */ const step = System.create({}, ({ commands }, input: { readonly entity: Entity; readonly x: number }) => { commands.add(input.entity, cell, [input.x, 0]); }); @@ -59,7 +59,7 @@ const spawnAt = (world: ReturnType, x: number, components: readon /** Every row's cell and position, by position. */ const cellsOf = async (world: ReturnType): Promise => { const found: [number, string][] = []; - await world.run(System.create({ observer: true, components: { with: [cell, position] } }, row => { found.push([row[position], `${row[cell][0]}:${row[position]}`]); })); + await world.run(System.create({ readonly: true, components: { with: [cell, position] } }, row => { found.push([row[position], `${row[cell][0]}:${row[position]}`]); })); return found.sort((a, b) => a[0] - b[0]).map(([, text]) => text); }; @@ -132,7 +132,7 @@ describe("reclaiming archetypes of shared values", () => { found.length = 0; await world.run(System.create({ kernel: false, components: { with: [cell, position] } }, row => { found.push(`body ${row[cell][0]}`); })); await world.run(copyCell); - await world.run(System.create({ observer: true, components: { with: [cell, seen] } }, row => { found.push(`kernel ${row[seen]}`); })); + await world.run(System.create({ readonly: true, components: { with: [cell, seen] } }, row => { found.push(`kernel ${row[seen]}`); })); await world.run(atFifty); await world.run(atLast); expect(found.sort()).toEqual(["body 50", `body ${emptyFor + 2}`, "kernel 50", `kernel ${emptyFor + 2}`, "value 0", "where 100"].sort()); diff --git a/ecs/packages/ecs/src/implementation/world/register.ts b/ecs/packages/ecs/src/implementation/world/register.ts index bccb76aa..bd059263 100644 --- a/ecs/packages/ecs/src/implementation/world/register.ts +++ b/ecs/packages/ecs/src/implementation/world/register.ts @@ -132,7 +132,7 @@ const observerTargetOf = (state: State, leaf: Leaf): ObserverTarget | undefined /** A runner for a leaf where it runs now: on the joined thread filling its thread, or that added it, or here. */ const placedRunner = (state: State, leaf: Leaf, since = 0): Runner => { - if (leaf.declaration.observer === true) { + if (leaf.declaration.readonly === true) { const target = observerTargetOf(state, leaf); return target === undefined ? createRunner(state, leaf, since) : createJoinedObserverRunner(state, leaf, target, since); } @@ -394,7 +394,7 @@ export const createRegistrar = (state: State): Registrar => { const place = (): void => { eachRunner((leaf, old, entry) => { - if (leaf.declaration.observer === true) { + if (leaf.declaration.readonly === true) { if (observerTargetOf(state, leaf) === old.joined?.target) return; replace(leaf, entry, old, placedRunner(state, leaf, old.lastRun())); return; diff --git a/ecs/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/ecs/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index 9ae6a561..113f3ff1 100644 --- a/ecs/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -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: { with: [phase, caption] }, flagged: { with: [flag] } } }, ({ lookup }) => { +export const observerWatch = System.create({ readonly: 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/ecs/packages/ecs/src/implementation/world/rows.ts b/ecs/packages/ecs/src/implementation/world/rows.ts index 4ed11021..4794c562 100644 --- a/ecs/packages/ecs/src/implementation/world/rows.ts +++ b/ecs/packages/ecs/src/implementation/world/rows.ts @@ -12,6 +12,7 @@ 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 { readTables } from './tables-read.js'; import { combine, contributed, identityOf, type ReductionTarget } from './reductions.js'; import { createIndexLookup, type Since } from './index-lookups.js'; import type { WorldIndex } from './indexes.js'; @@ -506,8 +507,11 @@ 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 { tables } = declaration; + // Each read copies the tables as they are then. + if (tables !== undefined) Object.defineProperty(value, "tables", { get: () => readTables(state, tables), enumerable: true }); const { lookup, resources } = declaration; const names = Object.keys(lookup ?? {}); if (names.length > 0) { diff --git a/ecs/packages/ecs/src/implementation/world/run-results.test.ts b/ecs/packages/ecs/src/implementation/world/run-results.test.ts index c3f3faca..1d461e0c 100644 --- a/ecs/packages/ecs/src/implementation/world/run-results.test.ts +++ b/ecs/packages/ecs/src/implementation/world/run-results.test.ts @@ -30,11 +30,11 @@ const spawn = System.create(({ commands }) => { const raise = System.create(({ resources }) => { resources[level] += 1; }); -const count = System.create({ observer: true, components: { with: [amount] } }, () => { +const count = System.create({ readonly: true, components: { with: [amount] } }, () => { counted++; }); let counted = 0; -const readLevel = System.create({ observer: true }, ({ resources }) => resources[level]); +const readLevel = System.create({ readonly: true }, ({ resources }) => resources[level]); const broken = System.create(() => { throw new Error("broken"); }); @@ -57,7 +57,7 @@ describe("a run", () => { expect(await world.run(System.create({ components: { with: [amount] } }, row => row[amount]))).toBeUndefined(); expect(await world.run(System.create([readLevel, raise]))).toBeUndefined(); expect(await world.run(raise)).toBeUndefined(); - const whenChanged = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => resources[level]); + const whenChanged = System.create({ readonly: true, resources: { changed: [level] } }, ({ resources }) => resources[level]); expect(await world.run(whenChanged)).toBe(2); // Unchanged since it last ran: skipped. expect(await world.run(whenChanged)).toBeUndefined(); @@ -94,7 +94,7 @@ describe("a run", () => { it("rejects when its own observer system fails, and reports one added out of band", async () => { using reported = catchOutOfBand(); await using world = inline(); - const failingObserver = System.create({ observer: true }, () => { + const failingObserver = System.create({ readonly: true }, () => { throw new Error("observer failed"); }); await expect(world.run(failingObserver)).rejects.toThrow(/observer failed/); @@ -144,7 +144,7 @@ const spawnTwins = System.create(({ commands }) => { commands.spawn([serial])({ [serial]: 1 }); commands.spawn([serial])({ [serial]: 1 }); }); -const findTwin = System.create({ observer: true, lookup: { twins: { index: bySerial } } }, ({ lookup }) => lookup.twins(1)?.entity ?? null); +const findTwin = System.create({ readonly: true, lookup: { twins: { index: bySerial } } }, ({ lookup }) => lookup.twins(1)?.entity ?? null); const setGate = System.create({}, ({ resources }, input: { readonly open: boolean }) => { resources[gate] = input.open; }); @@ -207,10 +207,10 @@ describe("a run's observer pass", () => { it("fails the run for its own observer system, and reports one added out of band, in one run", async () => { using reported = catchOutOfBand(); await using world = inline(); - const ownFails = System.create({ observer: true }, () => { + const ownFails = System.create({ readonly: true }, () => { throw new Error("own failed"); }); - const addedFails = System.create({ observer: true }, () => { + const addedFails = System.create({ readonly: true }, () => { throw new Error("added failed"); }); world.add(addedFails); @@ -255,7 +255,7 @@ describe("out-of-band reports", () => { it("aren't made again for a system skipped between its failures", async () => { using reported = catchOutOfBand(); await using world = inline(); - const failsWhenChanged = System.create({ observer: true, resources: { changed: [level] } }, () => { + const failsWhenChanged = System.create({ readonly: true, resources: { changed: [level] } }, () => { throw new Error("changed failed"); }); world.add(failsWhenChanged); @@ -294,7 +294,7 @@ describe("out-of-band reports", () => { using reported = catchOutOfBand(); const world = inline(); let failing = false; - const failsAdded = System.create({ observer: true }, () => { + const failsAdded = System.create({ readonly: true }, () => { if (failing) throw new Error("added failed"); }); world.add(failsAdded); @@ -331,7 +331,7 @@ describe("out-of-band reports", () => { it("aren't made again for a row system whose change filter passed no row between its failures", async () => { using reported = catchOutOfBand(); await using world = inline(); - const failsOnChange = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, () => { + const failsOnChange = System.create({ readonly: true, components: { with: [amount], changed: [amount] } }, () => { throw new Error("row failed"); }); const bump = System.create({ components: { with: [amount] } }, row => { diff --git a/ecs/packages/ecs/src/implementation/world/runner.ts b/ecs/packages/ecs/src/implementation/world/runner.ts index 3e4c909e..173bcac2 100644 --- a/ecs/packages/ecs/src/implementation/world/runner.ts +++ b/ecs/packages/ecs/src/implementation/world/runner.ts @@ -260,7 +260,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { : 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)); + const looks = [...new Set([...Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []), ...declaration.up?.read ?? [], ...declaration.tables?.components ?? []])].map(key => idOf(state, key)); /** Record keys by archetype. */ const keys = new Map(); const keyOf = (archetype: Archetype): string => { @@ -339,7 +339,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { let runInput: unknown; const context = createContext(state, declaration, { targets: kernel === null ? targets : [], - commands: leaf.kind === "render" || declaration.kernel === "gpu" || declaration.observer === true ? undefined : commands, + commands: leaf.kind === "render" || declaration.kernel === "gpu" || declaration.readonly === true ? undefined : commands, indexes: indexLookups.byName, since: lookupSince, }); @@ -1343,7 +1343,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { return taken; }, didRun: () => ran && (query === undefined || bodyRanOnRow()), - restart: declaration.observer !== true || !holdsNothing(holdings) ? null : since => { + restart: declaration.readonly !== true || !holdsNothing(holdings) ? null : since => { lastRun = since; holdings.removed = false; holdings.released = false; diff --git a/ecs/packages/ecs/src/implementation/world/shared-gpu.browser.test.ts b/ecs/packages/ecs/src/implementation/world/shared-gpu.browser.test.ts index dda7e7a8..e6afc0d3 100644 --- a/ecs/packages/ecs/src/implementation/world/shared-gpu.browser.test.ts +++ b/ecs/packages/ecs/src/implementation/world/shared-gpu.browser.test.ts @@ -59,7 +59,7 @@ const fractions = System.create({ kernel: "gpu", components: { with: [scale, val }); const fractionsOf = async (world: ReturnType): Promise => { const read: [number, number[]][] = []; - await world.run(System.create({ observer: true, components: { with: [value, copied, product, sum, compound] } }, row => { read.push([row[value], [row[copied], row[product], row[sum], row[compound], row[value]]]); })); + await world.run(System.create({ readonly: true, components: { with: [value, copied, product, sum, compound] } }, row => { read.push([row[value], [row[copied], row[product], row[sum], row[compound], row[value]]]); })); return read.sort((a, b) => a[0] - b[0]).map(([, values]) => values); }; @@ -71,7 +71,7 @@ const device = async (): Promise => { const results = async (world: ReturnType): Promise => { const read: [number, string][] = []; - await world.run(System.create({ observer: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); })); + await world.run(System.create({ readonly: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); })); return read.sort((a, b) => a[0] - b[0]).map(([, text]) => text); }; diff --git a/ecs/packages/ecs/src/implementation/world/shared-kernels.test.ts b/ecs/packages/ecs/src/implementation/world/shared-kernels.test.ts index 6b3d99e0..674f53ed 100644 --- a/ecs/packages/ecs/src/implementation/world/shared-kernels.test.ts +++ b/ecs/packages/ecs/src/implementation/world/shared-kernels.test.ts @@ -62,7 +62,7 @@ const wideOnGpu = System.create({ kernel: "gpu", components: { with: [wide, out] /** Every row's results, by its value. */ const results = async (world: ReturnType): Promise => { const read: [number, string][] = []; - await world.run(System.create({ observer: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); })); + await world.run(System.create({ readonly: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); })); return read.sort((a, b) => a[0] - b[0]).map(([, text]) => text); }; @@ -98,7 +98,7 @@ describe("a kernel reading a shared component", () => { await world.run(spawn); expect((await runReported(world, moving)).failures).toEqual([]); const scales: number[] = []; - await world.run(System.create({ observer: true, components: { with: [scale] } }, row => { scales.push(row[scale]); })); + await world.run(System.create({ readonly: true, components: { with: [scale] } }, row => { scales.push(row[scale]); })); expect(new Set(scales)).toEqual(new Set([2])); // A kernel built without knowing a component is shared, naming it in a command, fails where it registers. const description = kernelOf(compute); diff --git a/ecs/packages/ecs/src/implementation/world/shared-workers.fixture.ts b/ecs/packages/ecs/src/implementation/world/shared-workers.fixture.ts index 04aec38e..4cfe82f7 100644 --- a/ecs/packages/ecs/src/implementation/world/shared-workers.fixture.ts +++ b/ecs/packages/ecs/src/implementation/world/shared-workers.fixture.ts @@ -39,7 +39,7 @@ const written = System.create({ kernel: false, components: { with: [scale, cell, row[steps] = x * 1000 + y; }); const read: [number, string][] = []; -const results = System.create({ observer: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); }); +const results = System.create({ readonly: true, components: { with: [value, out, steps] } }, row => { read.push([row[value], `${row[out]} ${row[steps]}`]); }); const outcome = async (system: typeof compute, wasm: boolean): Promise => { await using world = createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm }); @@ -66,7 +66,7 @@ export const whereOnWorkers = async (wasm: boolean): Promise<{ readonly written: await world.run(negative); let written = 0; let left = 0; - await world.run(System.create({ observer: true, components: { with: [value, out] } }, row => { + await world.run(System.create({ readonly: true, components: { with: [value, out] } }, row => { if (row[out] === 0 && row[value] !== 0) left++; else written++; })); diff --git a/ecs/packages/ecs/src/implementation/world/shared.test.ts b/ecs/packages/ecs/src/implementation/world/shared.test.ts index 01d055be..b375548b 100644 --- a/ecs/packages/ecs/src/implementation/world/shared.test.ts +++ b/ecs/packages/ecs/src/implementation/world/shared.test.ts @@ -39,7 +39,7 @@ const inline = () => createWorld({ [workers]: 0 }); // Systems a joined thread runs, which it names by the ids the build gives them at the top level. const heard: string[] = []; -export const listen = System.create({ observer: true, components: { with: [team, health] } }, row => { heard.push(`${row[team]} ${row[health]}`); }); +export const listen = System.create({ readonly: true, components: { with: [team, health] } }, row => { heard.push(`${row[team]} ${row[health]}`); }); export const spawnBanners = System.create(({ commands }) => { commands.spawn([team, health, banner], { [team]: "red" })({ [health]: 1 }); commands.spawn([team, health, banner], { [team]: "blue" })({ [health]: 2 }); @@ -60,11 +60,11 @@ export const spawnThere = System.create({ components: { with: [banner] } }, (row commands.spawn([health, marker], base)({ [marker]: 1 }); commands.spawn([health], base)({}); }); -export const readBanners = System.create({ observer: true, components: { with: [banner] } }, row => { banners.push(row[banner]?.text ?? "none"); }); +export const readBanners = System.create({ readonly: true, components: { with: [banner] } }, row => { banners.push(row[banner]?.text ?? "none"); }); /** Every entity's team and health, and whether it's armed, sorted by health. */ const rowsOf = async (world: ReturnType): Promise => { - const read = System.create({ observer: true, components: { with: [team, health], optional: [armed] } }, (row) => { + const read = System.create({ readonly: true, components: { with: [team, health], optional: [armed] } }, (row) => { found.push(`${row[team]} ${row[health]}${row[armed] === true ? " armed" : ""}`); }); const found: string[] = []; @@ -116,7 +116,7 @@ describe("a shared component", () => { // Three cells' worth of [1, 2] and [2, 1], three weights and one style. expect((await world.tables()).archetypes - before).toBe(2 + 3 + 1); const read: string[] = []; - await world.run(System.create({ observer: true, components: { with: [health], optional: [cell, weight, style] } }, row => { + await world.run(System.create({ readonly: true, components: { with: [health], optional: [cell, weight, style] } }, row => { if (row[health] === 0) return; read.push(`${row[health]}: ${JSON.stringify(row[cell] ?? row[style] ?? null)} ${String(row[weight])}`); })); @@ -216,8 +216,8 @@ describe("a shared component", () => { commands.spawn([team, health], { [team]: "red" })({ [health]: 2 }); })); const changes: string[] = []; - const watch = System.create({ observer: true, components: { with: [health], changed: [team] } }, row => { changes.push(`changed ${row[health]}`); }); - const adds = System.create({ observer: true, components: { with: [health], added: [team] } }, row => { changes.push(`added ${row[health]}`); }); + const watch = System.create({ readonly: true, components: { with: [health], changed: [team] } }, row => { changes.push(`changed ${row[health]}`); }); + const adds = System.create({ readonly: true, components: { with: [health], added: [team] } }, row => { changes.push(`added ${row[health]}`); }); world.add(watch); world.add(adds); await world.run(System.create(() => {})); @@ -294,7 +294,7 @@ describe("a shared component", () => { })); await world.run(System.create({ components: { with: [weight, health] } }, row => { row[health] *= row[weight]; })); const read: number[] = []; - await world.run(System.create({ observer: true, components: { with: [health] } }, row => { read.push(row[health]); })); + await world.run(System.create({ readonly: true, components: { with: [health] } }, row => { read.push(row[health]); })); expect(read.sort()).toEqual([2, 3]); }); @@ -338,7 +338,7 @@ describe("a shared component", () => { await world.run(writeBanners); await world.run(spawnThere); const found: string[] = []; - await world.run(System.create({ observer: true, components: { with: [health], optional: [marker, team] } }, row => { + await world.run(System.create({ readonly: true, components: { with: [health], optional: [marker, team] } }, row => { if (row[team] === undefined) found.push(`${row[health]} ${row[marker] ?? "none"}`); })); expect(found.sort()).toEqual(["7 1", "7 1", "7 none", "7 none"]); diff --git a/ecs/packages/ecs/src/implementation/world/sharing.ts b/ecs/packages/ecs/src/implementation/world/sharing.ts index ff300db0..cfa1ca01 100644 --- a/ecs/packages/ecs/src/implementation/world/sharing.ts +++ b/ecs/packages/ecs/src/implementation/world/sharing.ts @@ -613,7 +613,7 @@ 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: Resolved = { - observer: true, + readonly: true, // Its `where` runs on its thread; here, an empty one says it has one, which every archetype passes. ...components === null ? {} : { components: { ...listed([["read", components.read], ["has", components.has], ["optional", components.optional], ["not", components.not], ["added", components.added], ["changed", components.changed]]), ...components.where ? { where: {} } : {} } }, ...resources === null ? {} : { resources: listed([["read", resources.read], ["changed", resources.changed], ["is", resources.is], ["not", resources.not]]) }, diff --git a/ecs/packages/ecs/src/implementation/world/staging.ts b/ecs/packages/ecs/src/implementation/world/staging.ts index 31a22956..b946417b 100644 --- a/ecs/packages/ecs/src/implementation/world/staging.ts +++ b/ecs/packages/ecs/src/implementation/world/staging.ts @@ -69,14 +69,15 @@ export const createBatch = (layouts: readonly Layout[], ids: readonly number[], return { ids: sorted, shared, archetype: null, columns: sorted.map(id => columnOf(layouts[id], id, initialCapacity)), entities: new Float64Array(initialCapacity), count: 0, capacity: initialCapacity, revive: null, generation: 0 }; }; -const grow = (batch: SpawnBatch): void => { +/** Grows a batch to hold at least `needed` rows: twice its capacity, or `needed` where that's more. */ +const grow = (batch: SpawnBatch, needed = batch.count + 1): void => { const { revive } = batch; if (revive !== null) { batch.revive = null; revive(); - if (batch.count < batch.capacity) return; + if (needed <= batch.capacity) return; } - const capacity = batch.capacity * 2; + const capacity = Math.max(batch.capacity * 2, needed); for (const column of batch.columns) { const views = viewsOf(new ArrayBuffer(capacity * column.stride), 0, capacity * column.stride); views.u8.set(column.views.u8.subarray(0, batch.count * column.stride)); @@ -146,6 +147,18 @@ export const openRow = (batch: SpawnBatch): number => { return batch.count; }; +/** The first of the `n` rows the batch's next spawns write, growing it first to hold them. */ +export const openRows = (batch: SpawnBatch, n: number): number => { + if (batch.count + n > batch.capacity) grow(batch, batch.count + n); + return batch.count; +}; + +/** Claims the open rows for `entities`, once all their values are written. */ +export const claimRows = (batch: SpawnBatch, entities: Float64Array): void => { + batch.entities.set(entities, batch.count); + batch.count += entities.length; +}; + /** Claims the open row for `entity`, once all its values are written. */ export const claim = (batch: SpawnBatch, entity: number): void => { batch.entities[batch.count] = entity; diff --git a/ecs/packages/ecs/src/implementation/world/tables-read.ts b/ecs/packages/ecs/src/implementation/world/tables-read.ts new file mode 100644 index 00000000..32d6998c --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/tables-read.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { labelOf } from '../../interface/registry/names.js'; +import type { Tables, TablesDeclaration } from '../../interface/system/tables.js'; +import type { Table } from '../../interface/world/table/table.js'; +import type { Storage } from './archetype.js'; +import { readCell } from './cells.js'; +import { resourceCell } from './resources.js'; +import { idOf, type Key, type State } from './state.js'; + +// Reading tables, apart from spawning them: rows' contexts import it, and what spawning imports would make a cycle. + +/** What an archetype's storage of a component gives a table: a copy of its rows `0` to `count`. */ +const columnOf = (key: Key, storage: Storage, count: number): Table.Column => { + const { layout } = storage; + switch (layout.kind) { + case "linear": return { kind: "bytes", bytes: storage.views.u8.slice(0, count * layout.stride), stride: layout.stride }; + case "constant": return { kind: "value", value: layout.value }; + case "object": + if (storage.remote !== null) throw new Error(`Tables can't read "${labelOf(key)}": a joined thread fills its objects.`); + return { kind: "values", values: storage.values.slice(0, count) }; + case "string": { + const values: unknown[] = new Array(count); + for (let row = 0; row < count; row++) values[row] = readCell(storage, row); + return { kind: "values", values }; + } + } +}; + +/** + * A readonly system's `tables`: of each archetype matching `with` and `not` and holding any of `components`, its + * entities and a copy of the column of each of them it holds; and the value of each resource of `resources`. + */ +export const readTables = (state: State, declaration: TablesDeclaration): Tables => { + const asked = declaration.components.map(key => ({ key, id: idOf(state, key) })); + const required = (declaration.with ?? []).map(key => idOf(state, key)); + const excluded = (declaration.not ?? []).map(key => idOf(state, key)); + const archetypes: Table[] = []; + for (const archetype of state.archetypes) { + const { count } = archetype; + if (count === 0 || archetype.reclaimed) continue; + const { storageById } = archetype; + if (required.some(id => storageById[id] === undefined) || excluded.some(id => storageById[id] !== undefined)) continue; + const columns: { [key: symbol]: Table.Column } = {}; + let held = false; + for (const { key, id } of asked) { + const storage = storageById[id]; + if (storage === undefined) continue; + columns[key] = columnOf(key, storage, count); + held = true; + } + if (held) archetypes.push({ count, entities: archetype.entities.slice(0, count), columns }); + } + const resources: { [key: symbol]: unknown } = {}; + for (const key of declaration.resources ?? []) { + const cell = resourceCell(state, key); + resources[key] = readCell(cell.storage, cell.row); + } + return { archetypes, resources }; +}; diff --git a/ecs/packages/ecs/src/implementation/world/tables.test.ts b/ecs/packages/ecs/src/implementation/world/tables.test.ts new file mode 100644 index 00000000..064a4476 --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/tables.test.ts @@ -0,0 +1,267 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { clock, Component, Entity, Resource, System, workers } from "../../index.js"; +import type { Vec3 } from "../../interface/math/index.js"; +import type { Tables } from "../../interface/system/tables.js"; +import type { Table } from "../../interface/world/table/table.js"; +import { createWorld } from "./create.js"; + +const position = Symbol("tablesTest/position"); +const label = Symbol("tablesTest/label"); +const tags = Symbol("tablesTest/tags"); +const team = Symbol("tablesTest/team"); +const pinned = Symbol("tablesTest/pinned"); +const parent = Symbol("tablesTest/parent"); +const target = Symbol("tablesTest/target"); +const link = Symbol("tablesTest/link"); +const friends = Symbol("tablesTest/friends"); +const hidden = Symbol("tablesTest/hidden"); +const buddy = Symbol("tablesTest/buddy"); +const score = Symbol("tablesTest/score"); +const title = Symbol("tablesTest/title"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [label]: { type: string }; + [tags]: { type: readonly string[] }; + [team]: { type: "red" | "blue"; shared: true }; + [pinned]: { type: true }; + [parent]: { type: Entity; reference: "owner" }; + [target]: { type: Entity; reference: "weak" }; + [link]: { type: { readonly to: Entity; readonly weight: number } }; + [friends]: { type: readonly Entity[] }; + [hidden]: { type: true }; + [buddy]: { type: Entity }; + } + interface Resources { + [score]: { type: number; default: 0 }; + [title]: { type: string; default: "" }; + } +} + +const inline = () => createWorld({ [workers]: 0 }); + +/** A readonly system's tables of these components. */ +const tablesOf = (components: readonly Component.Key[], resources: readonly Resource.Key[] = [], not: readonly Component.Key[] = []) => + System.create({ readonly: true, tables: { components, resources, not } }, ({ tables }): Tables => tables); + +const spawnedBy: { spawned: Table.Spawned } = { spawned: { entities: [], dropped: 0 } }; +const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown } } = { tables: [] }; +const spawnGiven = System.create(({ commands }) => { + spawnedBy.spawned = given.resources === undefined ? commands.spawnTables(given.tables) : commands.spawnTables(given.tables, given.resources); +}); +const spawnTables = async (world: ReturnType, tables: readonly Table[]): Promise => { + given.tables = tables; + await world.run(spawnGiven); + return spawnedBy.spawned; +}; + +const values = System.create({ readonly: true, components: { with: [label], optional: [position, parent, target, link, friends, tags, buddy] } }, row => { + found.push({ entity: row.entity, label: row[label], position: row[position], parent: row[parent], target: row[target], link: row[link], friends: row[friends], tags: row[tags], buddy: row[buddy] }); +}); +type Found = { entity: Entity; label: string; position?: Vec3; parent?: Entity; target?: Entity; link?: { readonly to: Entity; readonly weight: number }; friends?: readonly Entity[]; tags?: readonly string[]; buddy?: Entity }; +const found: Found[] = []; +const readAll = async (world: ReturnType): Promise => { + found.length = 0; + await world.run(values); + return [...found].sort((a, b) => a.label.localeCompare(b.label)); +}; + +describe("tables", () => { + it("give each archetype holding a component asked for, its entities, and a copy of each column in the world's layout", async () => { + await using world = inline(); + await world.run(System.create(({ commands }) => { + commands.spawn([position, label])({ [position]: [1, 2, 3], [label]: "a" }); + commands.spawn([position, label, tags])({ [position]: [4, 5, 6], [label]: "b", [tags]: ["x", "y"] }); + commands.spawn([team, label], { [team]: "red" })({ [label]: "c" }); + commands.spawn([pinned])(); + })); + const { archetypes } = await world.run(tablesOf([position, label, tags, team, pinned])); + expect(archetypes.map(table => table.count)).toEqual([1, 1, 1, 1]); + const plain = archetypes.find(table => table.columns[tags] === undefined && table.columns[position] !== undefined); + const bytes = plain?.columns[position]; + expect(bytes?.kind === "bytes" && [...new Float32Array(bytes.bytes.buffer, bytes.bytes.byteOffset, 3)]).toEqual([1, 2, 3]); + expect(bytes?.kind === "bytes" && bytes.stride).toBe(12); + const withTags = archetypes.find(table => table.columns[tags] !== undefined); + expect(withTags?.columns[tags]).toEqual({ kind: "values", values: [["x", "y"]] }); + expect(withTags?.columns[label]).toEqual({ kind: "values", values: ["b"] }); + const shared = archetypes.find(table => table.columns[team] !== undefined); + expect(shared?.columns[team]).toEqual({ kind: "value", value: "red" }); + expect(archetypes.find(table => table.columns[pinned] !== undefined)?.columns[pinned]).toEqual({ kind: "value", value: true }); + }); + + it("leave out archetypes not matching with and not, and give the values of resources asked for", async () => { + await using world = createWorld({ [workers]: 0, [score]: 7, [title]: "won" }); + await world.run(System.create(({ commands }) => { + commands.spawn([label])({ [label]: "shown" }); + commands.spawn([label, hidden])({ [label]: "hidden" }); + })); + const { archetypes, resources } = await world.run(tablesOf([label], [score, title], [hidden])); + expect(archetypes.map(table => table.columns[label])).toEqual([{ kind: "values", values: ["shown"] }]); + expect(resources[score]).toBe(7); + expect(resources[title]).toBe("won"); + }); + + it("spawn back as they were read, shared values and strings included", async () => { + await using from = inline(); + await from.run(System.create(({ commands }) => { + commands.spawn([position, label, tags])({ [position]: [1, 2, 3], [label]: "a", [tags]: ["t"] }); + commands.spawn([team, label], { [team]: "blue" })({ [label]: "b" }); + })); + const { archetypes } = await from.run(tablesOf([position, label, tags, team])); + await using into = inline(); + const { entities, dropped } = await spawnTables(into, archetypes); + expect([entities.map(list => list.length), dropped]).toEqual([[1, 1], 0]); + const again = await into.run(tablesOf([position, label, tags, team])); + expect(again.archetypes.map(table => ({ ...table, entities: [] }))).toEqual(archetypes.map(table => ({ ...table, entities: [] }))); + expect(await readAll(into)).toMatchObject([{ label: "a", position: [1, 2, 3], tags: ["t"] }, { label: "b" }]); + }); + + it("rewrite entities in references, struct fields and arrays to those spawned for them, self references and cycles included", async () => { + await using from = inline(); + await from.run(System.create(({ commands }) => { + const root = commands.spawn([label])({ [label]: "root" }); + const child = commands.spawn([label, parent])({ [label]: "child", [parent]: root }); + commands.spawn([label, target, link, friends])({ [label]: "other", [target]: child, [link]: { to: root, weight: 2 }, [friends]: [root, child] }); + const a = commands.spawn([label, link])({ [label]: "self", [link]: { to: Entity.none, weight: 1 } }); + commands.add(a, link, { to: a, weight: 1 }); + })); + const before = await readAll(from); + const { archetypes } = await from.run(tablesOf([label, parent, target, link, friends])); + await using into = inline(); + // Entities already in the world, so the new ones differ from the old. + await into.run(System.create(({ commands }) => { for (let i = 0; i < 5; i++) commands.spawn([pinned])(); })); + await spawnTables(into, archetypes); + const after = await readAll(into); + const old = new Map(before.map(entry => [entry.entity, entry.label])); + const now = new Map(after.map(entry => [entry.label, entry.entity])); + const translated = (entity: Entity | undefined): Entity | undefined => entity === undefined ? undefined : now.get(old.get(entity) ?? ""); + expect(after.map(entry => entry.label)).toEqual(before.map(entry => entry.label)); + for (const entry of before) { + const moved = after.find(other => other.label === entry.label); + expect(moved?.entity).not.toBe(entry.entity); + expect(moved?.parent).toBe(translated(entry.parent)); + expect(moved?.target).toBe(translated(entry.target)); + expect(moved?.link?.to).toBe(translated(entry.link?.to)); + expect(moved?.friends).toEqual(entry.friends?.map(translated)); + } + expect(after.find(entry => entry.label === "self")?.link?.to).toBe(now.get("self")); + }); + + it("leave out a weak reference to an entity outside the tables, its holder spawned without it", async () => { + await using world = inline(); + await world.run(System.create(({ commands }) => { + const outside = commands.spawn([pinned])(); + const inside = commands.spawn([label])({ [label]: "inside" }); + commands.spawn([label, target])({ [label]: "away", [target]: outside }); + commands.spawn([label, target])({ [label]: "near", [target]: inside }); + })); + const { archetypes } = await world.run(tablesOf([label, target])); + await using other = inline(); + expect((await spawnTables(other, archetypes)).dropped).toBe(0); + const read = await readAll(other); + expect(read.map(entry => [entry.label, entry.target === undefined ? "none" : read.find(found => found.entity === entry.target)?.label])).toEqual([["away", "none"], ["inside", "none"], ["near", "inside"]]); + }); + + it("spawn no row owned, directly or through others, by an entity outside the tables, saying how many", async () => { + await using world = inline(); + await world.run(System.create(({ commands }) => { + const outside = commands.spawn([pinned])(); + const top = commands.spawn([label, parent])({ [label]: "top", [parent]: outside }); + const middle = commands.spawn([label, parent])({ [label]: "middle", [parent]: top }); + commands.spawn([label, parent])({ [label]: "bottom", [parent]: middle }); + const kept = commands.spawn([label])({ [label]: "kept" }); + commands.spawn([label, parent, target])({ [label]: "kept child", [parent]: kept, [target]: middle }); + })); + const { archetypes } = await world.run(tablesOf([label, parent, target])); + await using other = inline(); + const { entities, dropped } = await spawnTables(other, archetypes); + expect(dropped).toBe(3); + expect(entities.flatMap(list => [...list]).filter(entity => entity === Entity.none).length).toBe(3); + const read = await readAll(other); + // The kept child's weak target was dropped, so it's spawned without it. + expect(read.map(entry => [entry.label, entry.parent === undefined ? null : read.find(found => found.entity === entry.parent)?.label, entry.target ?? null])).toEqual([["kept", null, null], ["kept child", "kept", null]]); + }); + + it("make any other entity value naming an entity outside the tables none: a whole component, a struct's field and an array's item", async () => { + await using world = inline(); + await world.run(System.create(({ commands }) => { + const outside = commands.spawn([pinned])(); + const inside = commands.spawn([label])({ [label]: "inside" }); + commands.spawn([label, buddy, link, friends])({ [label]: "pointing", [buddy]: outside, [link]: { to: outside, weight: 1 }, [friends]: [outside, inside] }); + })); + const { archetypes } = await world.run(tablesOf([label, buddy, link, friends])); + await using other = inline(); + await spawnTables(other, archetypes); + const read = await readAll(other); + const inside = read.find(entry => entry.label === "inside")?.entity; + expect(read.find(entry => entry.label === "pointing")).toMatchObject({ buddy: Entity.none, link: { to: Entity.none, weight: 1 }, friends: [Entity.none, inside] }); + }); + + it("set the resources they're given at the sync point, beside the entities", async () => { + await using world = createWorld({ [workers]: 0, [score]: 7 }); + given.tables = []; + given.resources = { [score]: 12, [title]: "loaded" }; + try { + await world.run(spawnGiven); + } + finally { + given.resources = undefined; + } + const { resources } = await world.run(tablesOf([label], [score, title])); + expect(resources[score]).toBe(12); + expect(resources[title]).toBe("loaded"); + }); + + it("refuse a resource only the world writes or a value its type doesn't take, spawning nothing", async () => { + await using world = inline(); + const table: Table = { count: 1, entities: new Float64Array([1]), columns: { [label]: { kind: "values", values: ["kept out"] } } }; + given.tables = [table]; + try { + given.resources = { [clock]: { now: 0, delta: 0, tick: 0 } }; + await expect(world.run(spawnGiven)).rejects.toThrow(/clock" is written only by the world/); + given.resources = { [score]: "twelve" }; + await expect(world.run(spawnGiven)).rejects.toThrow(/score" is given a value its type doesn't take/); + given.resources = { [position]: [0, 0, 0] }; + await expect(world.run(spawnGiven)).rejects.toThrow(/position" isn't a registered resource/); + } + finally { + given.resources = undefined; + } + expect(await readAll(world)).toEqual([]); + expect((await world.run(tablesOf([label], [score]))).resources[score]).toBe(0); + }); + + it("fail a column that doesn't fit its component's layout or its table's count", async () => { + await using world = inline(); + const table = (columns: Table["columns"], count = 1): Table => ({ count, entities: new Float64Array(count).fill(1), columns }); + given.tables = [table({ [position]: { kind: "bytes", bytes: new Uint8Array(8), stride: 8 } })]; + await expect(world.run(spawnGiven)).rejects.toThrow(/stride of 12 bytes in this world, not 8/); + given.tables = [table({ [position]: { kind: "bytes", bytes: new Uint8Array(24), stride: 12 } })]; + await expect(world.run(spawnGiven)).rejects.toThrow(/gives 24 bytes for 1 rows of 12/); + given.tables = [table({ [label]: { kind: "values", values: ["a", "b"] } })]; + await expect(world.run(spawnGiven)).rejects.toThrow(/gives 2 values for 1 rows/); + given.tables = [table({ [team]: { kind: "values", values: ["red"] } })]; + await expect(world.run(spawnGiven)).rejects.toThrow(/shared component, so its column is one value/); + // Two rows of one entity can't both be what a reference to it names. + given.tables = [table({ [friends]: { kind: "values", values: [[]] } }, 1), table({ [friends]: { kind: "values", values: [[]] } }, 1)]; + await expect(world.run(spawnGiven)).rejects.toThrow(/entity 1 is in two rows/); + expect(await readAll(world)).toEqual([]); + }); + + it("are read only by a readonly system without components", () => { + // @ts-expect-error: tables are read only by a readonly system, which the declaration's type says too. + expect(() => System.create({ tables: { components: [label] } }, () => {})).toThrow(/reads tables, which only a readonly system reads/); + }); +}); + +describe("declarations", () => { + it("give every component and resource registered, with its schema and traits", () => { + const declared = Component.declarations().get(parent); + expect(declared?.type).toEqual({ type: "integer", format: "entity" }); + expect(declared?.reference).toBe("owner"); + expect(Resource.declarations().get(score)?.default).toBe(0); + const components: readonly symbol[] = [...Component.declarations().keys()]; + expect(components.includes(score)).toBe(false); + }); +}); diff --git a/ecs/packages/ecs/src/implementation/world/tables.ts b/ecs/packages/ecs/src/implementation/world/tables.ts new file mode 100644 index 00000000..fed14f80 --- /dev/null +++ b/ecs/packages/ecs/src/implementation/world/tables.ts @@ -0,0 +1,330 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Layout, Path } from '../../interface/registry/layout.js'; +import { declarationOf, labelOf } from '../../interface/registry/names.js'; +import { isEntity } from '../../interface/registry/schemas.js'; +import type { Schema } from '../../interface/schema/schema.js'; +import type { Table } from '../../interface/world/table/table.js'; +import { batchOf } from './batches.js'; +import { indexOf, reserveRun } from './entities.js'; +import { internShared } from './shared.js'; +import { claimRows, openRows, writerOf, type Column as BatchColumn } from './staging.js'; +import { commandIdOf, type Key, type State } from './state.js'; + +/** Whether a value of `schema` may hold an entity anywhere in it. */ +const holdsEntity = (schema: Schema): boolean => { + if (isEntity(schema)) return true; + if ("anyOf" in schema) return schema.anyOf.some(holdsEntity); + if ("allOf" in schema) return schema.allOf.some(holdsEntity); + if (!("type" in schema)) return false; + switch (schema.type) { + case "array": case "set": return holdsEntity(schema.items); + case "map": return holdsEntity(schema.keys) || holdsEntity(schema.values); + case "object": return Object.values(schema.properties).some(holdsEntity); + default: return false; + } +}; + +/** The schema at `path` inside `schema`, through struct fields and tuple items. */ +const schemaAt = (schema: Schema, path: Path): Schema | undefined => { + let at: Schema | undefined = schema; + for (const step of path) { + if (at === undefined || !("type" in at)) return undefined; + if (at.type === "object" && typeof step === "string") at = at.properties[step]; + else if (at.type === "array" && typeof step === "number") at = at.items; + else return undefined; + } + return at; +}; + +/** The f64 leaves of a linear layout holding entities: where, in f64 elements, each row's is. */ +const entityLeavesOf = (layout: Layout, schema: Schema | undefined): readonly { readonly unitStride: number; readonly unitOffset: number }[] => { + if (layout.kind !== "linear" || schema === undefined) return []; + return layout.leaves.filter(leaf => { + const leafSchema = schemaAt(schema, leaf.path); + return leaf.view === "f64" && leafSchema !== undefined && isEntity(leafSchema); + }); +}; + +/** The entity each entity of the tables given is spawned as, and what a value naming none of them becomes. */ +type Remap = (entity: number) => number; + +/** `value` of `schema` with each entity in it replaced by `remap`'s. */ +const remapValue = (schema: Schema, value: unknown, remap: Remap): unknown => { + if (isEntity(schema)) return typeof value === "number" ? remap(value) : value; + if (!holdsEntity(schema)) return value; + if ("anyOf" in schema) { + for (const member of schema.anyOf) { + if (isEntity(member) && typeof value === "number") return remap(value); + if (!("type" in member) || !holdsEntity(member)) continue; + if ((member.type === "array" && Array.isArray(value)) || (member.type === "set" && value instanceof Set) || (member.type === "map" && value instanceof Map) + || (member.type === "object" && typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map))) return remapValue(member, value, remap); + } + return value; + } + if ("allOf" in schema) return schema.allOf.reduce((next, member) => remapValue(member, next, remap), value); + if (!("type" in schema)) return value; + switch (schema.type) { + case "array": return Array.isArray(value) ? value.map(item => remapValue(schema.items, item, remap)) : value; + case "set": return value instanceof Set ? new Set(Array.from(value, item => remapValue(schema.items, item, remap))) : value; + case "map": return value instanceof Map ? new Map(Array.from(value, ([key, item]) => [remapValue(schema.keys, key, remap), remapValue(schema.values, item, remap)])) : value; + case "object": { + if (typeof value !== "object" || value === null) return value; + const copy: { [name: string]: unknown } = {}; + for (const name of Object.keys(value)) { + const field = schema.properties[name]; + const item: unknown = Reflect.get(value, name); + copy[name] = field === undefined ? item : remapValue(field, item, remap); + } + return copy; + } + default: return value; + } +}; + +/** A table's components with their ids, the columns given for them, and their layouts. */ +type Planned = { + readonly table: Table; + readonly keys: readonly Key[]; + readonly ids: readonly number[]; + readonly shared: (number | undefined)[]; +}; + +/** Throws, naming the component, for a column that doesn't fit its component's layout or its table's count. */ +const checkColumn = (state: State, key: Key, id: number, column: Table.Column, count: number): void => { + const layout = state.layouts[id]; + const fail = (message: string): never => { throw new Error(`commands.spawnTables: "${labelOf(key)}" ${message}.`); }; + if (state.shared[id] !== undefined) { + if (column.kind !== "value") fail("is a shared component, so its column is one value"); + return; + } + if (layout.kind === "constant" || column.kind === "value") return; + if (layout.kind === "linear") { + if (column.kind !== "bytes") fail("is stored as bytes, so its column gives bytes"); + else if (column.stride !== layout.stride) fail(`has a stride of ${layout.stride} bytes in this world, not ${column.stride}`); + else if (column.bytes.length !== count * layout.stride) fail(`gives ${column.bytes.length} bytes for ${count} rows of ${layout.stride}`); + return; + } + if (column.kind !== "values") fail("is stored as values, so its column gives values"); + else if (column.values.length !== count) fail(`gives ${column.values.length} values for ${count} rows`); +}; + +/** The plan of each table: its keys, their command ids, and the numbers of its shared values. Throws for one that won't spawn. */ +const planOf = (state: State, table: Table): Planned => { + const { count, entities, columns } = table; + if (!Number.isInteger(count) || count < 0 || entities.length !== count) throw new Error(`commands.spawnTables: a table of ${count} rows gives ${entities.length} entities.`); + const keys = Object.getOwnPropertySymbols(columns).filter(key => Reflect.get(columns, key) !== undefined); + const ids = keys.map(key => commandIdOf(state, key)); + const shared: (number | undefined)[] = []; + keys.forEach((key, k) => { + const column: Table.Column = Reflect.get(columns, key); + checkColumn(state, key, ids[k], column, count); + const values = state.shared[ids[k]]; + if (values !== undefined && column.kind === "value") { + const number = internShared(values, column.value); + if (number < 0) throw new Error(`commands.spawnTables: "${labelOf(key)}", a shared component, isn't given one of its type's values: ${labelOf(column.value)}.`); + shared[ids[k]] = number; + } + }); + return { table, keys, ids, shared }; +}; + +/** + * Where each entity of the tables is, by its index: the entity itself, to tell it from another of that index, and its + * position among all the tables' rows. Throws for an entity two rows name. + */ +const placesOf = (plans: readonly Planned[]): { readonly entities: Float64Array; readonly positions: Int32Array } => { + let largest = 0; + for (const { table } of plans) for (let row = 0; row < table.count; row++) largest = Math.max(largest, indexOf(table.entities[row])); + const entities = new Float64Array(largest + 1).fill(-1); + const positions = new Int32Array(largest + 1); + let position = 0; + for (const { table } of plans) { + for (let row = 0; row < table.count; row++) { + const entity = table.entities[row]; + const index = indexOf(entity); + if (entities[index] === entity) throw new Error(`commands.spawnTables: entity ${entity} is in two rows.`); + entities[index] = entity; + positions[index] = position++; + } + } + return { entities, positions }; +}; + +/** A reference column's entity value in each row: the f64 at the row's start, a reference's whole value. */ +const referencesOf = (column: Table.Column): ((row: number) => number) => { + if (column.kind !== "bytes") return () => 0; + const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength); + const { stride } = column; + return row => view.getFloat64(row * stride, true); +}; + +/** Copies a table's `rows`, or all of them, of one column into rows `start` on of a batch's column, remapping entities. */ +const copyColumn = (target: BatchColumn, column: Table.Column, schema: Schema | undefined, rows: readonly number[] | null, count: number, start: number, remap: Remap | null): void => { + const rowAt = (i: number): number => rows === null ? i : rows[i]; + const remapped = remap !== null && schema !== undefined && holdsEntity(schema); + if (column.kind === "value") { + const write = writerOf(target, column.value); + const value = remapped ? remapValue(schema, column.value, remap) : column.value; + for (let i = 0; i < count; i++) write(start + i, value); + return; + } + if (column.kind === "bytes") { + const { stride } = target; + if (rows === null) target.views.u8.set(column.bytes, start * stride); + else for (let i = 0; i < count; i++) target.views.u8.set(column.bytes.subarray(rows[i] * stride, (rows[i] + 1) * stride), (start + i) * stride); + if (!remapped) return; + const { f64 } = target.views; + for (const leaf of entityLeavesOf(target.layout, schema)) { + for (let row = start, end = start + count; row < end; row++) { + const index = row * leaf.unitStride + leaf.unitOffset; + f64[index] = remap(f64[index]); + } + } + return; + } + if (target.remote !== null && column.values.some(value => value !== undefined && value !== null)) throw new Error(target.remote); + for (let i = 0; i < count; i++) { + const value = column.values[rowAt(i)]; + target.values[start + i] = remapped ? remapValue(schema, value, remap) : value; + } +}; + +/** Spawns `rows` of a plan's table, or all of them, with the components it holds but `removed`, as `entities`. */ +const spawnRows = (state: State, { table, keys, ids, shared }: Planned, rows: readonly number[] | null, removed: ReadonlySet, entities: Float64Array, remap: Remap | null): void => { + const kept = keys.flatMap((key, k) => removed.has(key) ? [] : [k]); + const batch = batchOf(state, kept.map(k => ids[k]), shared.length === 0 ? undefined : shared); + const start = openRows(batch, entities.length); + for (const k of kept) { + const target = batch.columns.find(column => column.id === ids[k]); + if (target === undefined || target.layout.kind === "constant") continue; + const column: Table.Column = Reflect.get(table.columns, keys[k]); + copyColumn(target, column, declarationOf(keys[k])?.type, rows, entities.length, start, remap); + } + claimRows(batch, entities); +}; + +/** + * Spawns an entity for each row of `tables` into the batches of their components, as references would leave them had + * the entities outside the tables despawned: a row owned, directly or through others, by an entity outside them isn't + * spawned; a weak reference to one is removed; and any other entity value naming one is `Entity.none`. Every entity + * value naming an entity spawned names the one spawned for it. + */ +export const spawnTables = (state: State, tables: readonly Table[]): Table.Spawned => { + const plans = tables.map(table => planOf(state, table)); + // Entities are mapped only where a value of the tables may hold one. + const remapping = plans.some(({ keys }) => keys.some(key => { + const schema = declarationOf(key)?.type; + return schema !== undefined && holdsEntity(schema); + })); + const none: ReadonlySet = new Set(); + if (!remapping) { + const entities = plans.map(({ table }) => { + const spawned = new Float64Array(table.count); + reserveRun(state.entities, spawned); + return spawned; + }); + plans.forEach((plan, t) => spawnRows(state, plan, null, none, entities[t], null)); + return { entities, dropped: 0 }; + } + const places = placesOf(plans); + const positionOf = (entity: number): number => { + const index = indexOf(entity); + return entity !== 0 && index < places.entities.length && places.entities[index] === entity ? places.positions[index] : -1; + }; + const firsts: number[] = []; + let total = 0; + for (const { table } of plans) { + firsts.push(total); + total += table.count; + } + // A row owned by an entity outside the tables isn't spawned, nor anything it owns, as despawning its owner would. + const dropped = new Uint8Array(total); + /** Each owner reference between rows of the tables: the owned row's position, and its owner's. */ + const ownedRows: number[] = []; + const ownerRows: number[] = []; + const waiting: number[] = []; + plans.forEach(({ table, keys }, t) => { + for (const key of keys) { + if (declarationOf(key)?.reference !== "owner") continue; + const ownerAt = referencesOf(Reflect.get(table.columns, key)); + for (let row = 0; row < table.count; row++) { + const owner = ownerAt(row); + if (owner === 0) continue; + const at = positionOf(owner); + const position = firsts[t] + row; + if (at >= 0) { + ownedRows.push(position); + ownerRows.push(at); + } + else if (dropped[position] === 0) { + dropped[position] = 1; + waiting.push(position); + } + } + } + }); + if (waiting.length > 0) { + const owned = new Map(); + for (let i = 0; i < ownedRows.length; i++) { + const list = owned.get(ownerRows[i]); + if (list === undefined) owned.set(ownerRows[i], [ownedRows[i]]); + else list.push(ownedRows[i]); + } + for (let next = waiting.pop(); next !== undefined; next = waiting.pop()) { + for (const child of owned.get(next) ?? []) { + if (dropped[child] !== 0) continue; + dropped[child] = 1; + waiting.push(child); + } + } + } + const spawnedAt = new Float64Array(total); + const entities = plans.map(({ table }, t) => { + const alive = []; + for (let row = 0; row < table.count; row++) if (dropped[firsts[t] + row] === 0) alive.push(row); + const reserved = new Float64Array(alive.length); + reserveRun(state.entities, reserved); + const spawned = new Float64Array(table.count); + alive.forEach((row, i) => { + spawned[row] = reserved[i]; + spawnedAt[firsts[t] + row] = reserved[i]; + }); + return spawned; + }); + const isSpawned = (entity: number): boolean => { + const at = positionOf(entity); + return at >= 0 && dropped[at] === 0; + }; + const remap: Remap = entity => isSpawned(entity) ? spawnedAt[positionOf(entity)] : 0; + let droppedCount = 0; + plans.forEach((plan, t) => { + const { table, keys } = plan; + const first = firsts[t]; + const weak = keys.filter(key => declarationOf(key)?.reference === "weak").map(key => ({ key, targetAt: referencesOf(Reflect.get(table.columns, key)) })); + const loses = (row: number): boolean => weak.some(({ targetAt }) => { + const target = targetAt(row); + return target !== 0 && !isSpawned(target); + }); + let whole = true; + for (let row = 0; row < table.count && whole; row++) if (dropped[first + row] !== 0 || loses(row)) whole = false; + if (whole) return spawnRows(state, plan, null, none, entities[t], remap); + // Rows by the weak references they lose, whose targets aren't spawned: each set of components a batch of its own. + const groups = new Map; readonly rows: number[] }>(); + for (let row = 0; row < table.count; row++) { + if (dropped[first + row] !== 0) { + droppedCount++; + continue; + } + const removed = weak.filter(({ targetAt }) => { + const target = targetAt(row); + return target !== 0 && !isSpawned(target); + }).map(({ key }) => key); + const signature = removed.map(key => weak.findIndex(entry => entry.key === key)).join(","); + let group = groups.get(signature); + if (group === undefined) groups.set(signature, group = { removed: new Set(removed), rows: [] }); + group.rows.push(row); + } + for (const { removed, rows } of groups.values()) spawnRows(state, plan, rows, removed, Float64Array.from(rows, row => entities[t][row]), remap); + }); + return { entities, dropped: droppedCount }; +}; diff --git a/ecs/packages/ecs/src/implementation/world/tails.test.ts b/ecs/packages/ecs/src/implementation/world/tails.test.ts index a4c0cd5d..0fff4147 100644 --- a/ecs/packages/ecs/src/implementation/world/tails.test.ts +++ b/ecs/packages/ecs/src/implementation/world/tails.test.ts @@ -75,7 +75,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 }) => { counters.push(resources[counter]); }); + const values = System.create({ readonly: true }, ({ resources }) => { counters.push(resources[counter]); }); world.add(ticked); world.add(values); await until(Observe.resource(world, counter), value => value >= 5); diff --git a/ecs/packages/ecs/src/implementation/world/target.test.ts b/ecs/packages/ecs/src/implementation/world/target.test.ts index 8de7cf41..8bce08eb 100644 --- a/ecs/packages/ecs/src/implementation/world/target.test.ts +++ b/ecs/packages/ecs/src/implementation/world/target.test.ts @@ -24,8 +24,8 @@ const remove = System.create({ target: "entity", components: { with: [tagged] } }); let amounts: number[] = []; let changed: number[] = []; -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 readAmounts = System.create({ readonly: true, components: { with: [amount] } }, row => { amounts.push(row[amount]); }); +const watchChanged = System.create({ readonly: true, components: { with: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); const inline = () => createWorld({ [workers]: 0 }); const amountsOf = async (world: ReturnType): Promise => { diff --git a/ecs/packages/ecs/src/implementation/world/thread-observer.ts b/ecs/packages/ecs/src/implementation/world/thread-observer.ts index 0557a5d4..68bde2eb 100644 --- a/ecs/packages/ecs/src/implementation/world/thread-observer.ts +++ b/ecs/packages/ecs/src/implementation/world/thread-observer.ts @@ -69,7 +69,7 @@ const errorOf = (error: unknown): WorkerError => const refuse = (): never => { throw new Error("An observer system issues no commands."); }; /** An observer system's context has no commands, so these are never called. */ -const noCommands: Commands = { spawn: refuse, despawn: refuse, add: refuse, remove: refuse }; +const noCommands: Commands = { spawn: refuse, despawn: refuse, add: refuse, remove: refuse, spawnTables: refuse }; const noStamps: number[] = []; export const createThreadObserver = (host: ThreadObserverHost) => { diff --git a/ecs/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/ecs/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 852d712e..2cf3f324 100644 --- a/ecs/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/ecs/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -172,7 +172,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, + readonly: true, lookup: { ids: { with: [uid] }, captions: { with: [caption] }, notes: { with: [note] } }, }, ({ lookup }) => { reading.objects = reading.entities.map(entity => { @@ -322,9 +322,9 @@ export const paintReader = System.create(({ resources }) => () => resources[pain /** A composite of systems on the thread returning values, which no run resolves to. */ export const paintsTogether = System.create([paintReader, paintLengthThere]); /** An observer system on the thread, as it reads the thread's paint: what a run of it resolves to. */ -export const paintObserved = System.create({ observer: true }, ({ resources }) => resources[paint].text); +export const paintObserved = System.create({ readonly: true }, ({ resources }) => resources[paint].text); /** An observer system on the thread, throwing there. */ -export const paintObservedFails = System.create({ observer: true }, ({ resources }) => { +export const paintObservedFails = System.create({ readonly: true }, ({ resources }) => { if (resources[paint].text !== "never") throw new Error("threadSystemsTest observer failure there"); return 0; }); diff --git a/ecs/packages/ecs/src/implementation/world/thread-systems.ts b/ecs/packages/ecs/src/implementation/world/thread-systems.ts index 16fb83f1..8ff91838 100644 --- a/ecs/packages/ecs/src/implementation/world/thread-systems.ts +++ b/ecs/packages/ecs/src/implementation/world/thread-systems.ts @@ -279,6 +279,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return typeof preset === "object" && preset !== null ? presetSpawnerOf(components, preset) : spawnerOf(components); }, despawn: entity => beginCommand(writer, despawnCommand, entity), + spawnTables: () => { throw new Error("commands.spawnTables runs on the world's thread: run the system there."); }, add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => { if (typeof components === "symbol") { const key = givenKeyOf(components); diff --git a/ecs/packages/ecs/src/implementation/world/where.test.ts b/ecs/packages/ecs/src/implementation/world/where.test.ts index 7974f36f..e1a66d44 100644 --- a/ecs/packages/ecs/src/implementation/world/where.test.ts +++ b/ecs/packages/ecs/src/implementation/world/where.test.ts @@ -62,10 +62,10 @@ export const noteInView = System.create({ components: { with: [position, note], row[note] = { text: `${row[position]}` }; }); const heard: number[] = []; -export const hearInView = System.create({ observer: true, components: { with: [position], changed: [position], where: { [cell]: (at, { resources }) => visible(at, resources[camera].x, resources[camera].radius) } } }, row => { heard.push(row[position]); }); +export const hearInView = System.create({ readonly: true, components: { with: [position], changed: [position], where: { [cell]: (at, { resources }) => visible(at, resources[camera].x, resources[camera].radius) } } }, row => { heard.push(row[position]); }); // Filtered by a resource the predicate doesn't read: a culled archetype coming into view catches up on its change. const signalled: number[] = []; -export const signalInView = System.create({ observer: true, components: { with: [position], where: { [cell]: (at, { resources }) => visible(at, resources[camera].x, resources[camera].radius) } }, resources: { changed: [other] } }, row => { signalled.push(row[position]); }); +export const signalInView = System.create({ readonly: true, components: { with: [position], where: { [cell]: (at, { resources }) => visible(at, resources[camera].x, resources[camera].radius) } }, resources: { changed: [other] } }, row => { signalled.push(row[position]); }); // A system moving the camera its own predicate reads: its next run sees the new view. const followed: number[] = []; export const follow = System.create({ components: { with: [position], where: { [cell]: (at, { resources }) => visible(at, resources[camera].x, resources[camera].radius) } } }, (row, { resources }) => { @@ -79,7 +79,7 @@ export const noteChanged = System.create({ components: { with: [position, note], row[note] = { text: `${row[position]}` }; }); const notes: string[] = []; -export const readNotes = System.create({ observer: true, components: { with: [note] } }, row => { if (row[note] !== null) notes.push(row[note].text); }); +export const readNotes = System.create({ readonly: true, components: { with: [note] } }, row => { if (row[note] !== null) notes.push(row[note].text); }); export const nudgeNoted = System.create({ components: { with: [position, note] } }, row => { row[position] += 0.5; }); /** Waits a turn at a time until `done`, for two seconds at most. */ @@ -91,7 +91,7 @@ const until = async (done: () => boolean): Promise => { /** Every row's position and how often it was seen, by position. */ const seenOf = async (world: ReturnType): Promise => { const read: [number, string][] = []; - await world.run(System.create({ observer: true, components: { with: [position, seen] } }, row => { read.push([row[position], `${row[position]}:${row[seen]}`]); })); + await world.run(System.create({ readonly: true, components: { with: [position, seen] } }, row => { read.push([row[position], `${row[position]}:${row[seen]}`]); })); return read.sort((a, b) => a[0] - b[0]).map(([, text]) => text).filter(text => !text.endsWith(":0")); }; diff --git a/ecs/packages/ecs/src/implementation/world/workers.fixture.ts b/ecs/packages/ecs/src/implementation/world/workers.fixture.ts index a34af434..a9b54eea 100644 --- a/ecs/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/ecs/packages/ecs/src/implementation/world/workers.fixture.ts @@ -149,8 +149,8 @@ const emitted = { seen: 0 }; const countEmitted = System.create({ components: { added: [position], with: [spawned] } }, () => { emitted.seen++; }); const gathered: 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); }); +const gatherPositioned = System.create({ readonly: true, components: { with: [position] } }, row => { gathered.push(row.entity); }); +const gatherSpawned = System.create({ readonly: 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/ecs/packages/ecs/src/index.ts b/ecs/packages/ecs/src/index.ts index 0ebc7e50..54b43c88 100644 --- a/ecs/packages/ecs/src/index.ts +++ b/ecs/packages/ecs/src/index.ts @@ -3,9 +3,6 @@ * 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'; import type { World as Created } from './interface/world/world.js'; export * from './interface/index.js'; @@ -15,30 +12,8 @@ export type World = Created; /** * How a world is made, `World.create(resources?)`, on the thread that will run it, with resources starting at the given - * values in place of their defaults; and how another thread joins one that shared itself with it, - * `World.join(port, { threads })`. + * values in place of their defaults; how another thread joins one that shared itself with it, + * `World.join(port, { threads })`; and `World.Table`, an archetype's rows as a readonly system's `tables` copies them + * and `commands.spawnTables` spawns them. */ -export const World: { - /** - * Creates a world on this thread, synchronously, with resources starting at the given values in place of their - * defaults. This thread is its `"world"` thread for the world's life: it runs the sync points, the ticks and the GPU - * device, and fills every logical thread no joined thread claims. Keys are the resources' symbols, built-in ones - * included (`{ [workers]: 0 }`); string keys are never a resource's, so they're ignored. Throws for a symbol that isn't - * a resource, or is one only the world writes. While a declaration's trait or default doesn't hold, each call fails to - * compile, once, naming each such key, its members and what's wrong with them. - */ - readonly create: Create; - /** - * Joins, from another thread, a world that `share` let join through the other end of `port`, claiming the logical - * `threads` this thread fills: their objects live here, and the systems and finalizers reading them run here. Resolves, - * once the world accepted the claim, to the same world: its `run` runs systems there, and its `add` and `remove` take - * observer systems, which run here after the world's sync points. Add them before awaiting anything else, so the - * world's `share` resolves with them in place. Its `dispose` disconnects this thread. Rejects with an `Error` saying - * why it didn't join: the world refused the claim, was disposed, or its thread ended first, as the platform reports it: - * the world's `Worker` failing to start or throwing first, a port closed, as in node, or, in a browser, the world's - * thread ending once it was shared through a port this thread listened on. A browser world's worker that the page ended - * before this thread heard from it isn't reported, so the join stays pending. Rejects too where memory can't be shared, - * as without cross-origin isolation. - */ - readonly join: Join; -} = { create, join }; +export * as World from './world.js'; diff --git a/ecs/packages/ecs/src/interface/component/declarations.ts b/ecs/packages/ecs/src/interface/component/declarations.ts new file mode 100644 index 00000000..d75f835d --- /dev/null +++ b/ecs/packages/ecs/src/interface/component/declarations.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Declared } from './declared.js'; +import { declaredOf } from '../registry/names.js'; +import type { Key } from './key.js'; + +/** Every component its modules registered so far, with its declaration. */ +export const declarations = (): ReadonlyMap => { + // Registration takes only keys declared in `Components`, so each symbol registered as a component is one of its keys. + const found = declaredOf("component") as ReadonlyMap; + return found; +}; diff --git a/ecs/packages/ecs/src/interface/component/declared.ts b/ecs/packages/ecs/src/interface/component/declared.ts new file mode 100644 index 00000000..de666ee8 --- /dev/null +++ b/ecs/packages/ecs/src/interface/component/declared.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from '../schema/schema.js'; +import type { Key as TraitKey } from '../trait/key.js'; + +/** + * A component's or resource's declaration as registered: its schema as `type` where its value is data, what it + * starts at as `default`, and its other traits, the built-in ones by name and those of your own by their keys. + */ +export type Declared = { readonly type?: Schema; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; diff --git a/ecs/packages/ecs/src/interface/component/index.ts b/ecs/packages/ecs/src/interface/component/index.ts index 1905785b..07fdd340 100644 --- a/ecs/packages/ecs/src/interface/component/index.ts +++ b/ecs/packages/ecs/src/interface/component/index.ts @@ -1,3 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type { Key } from './key.js'; export type { With } from './value.js'; +export { declarations } from './declarations.js'; +export type { Declared } from './declared.js'; diff --git a/ecs/packages/ecs/src/interface/index.type-test.ts b/ecs/packages/ecs/src/interface/index.type-test.ts index 739f47c3..97ccc0af 100644 --- a/ecs/packages/ecs/src/interface/index.type-test.ts +++ b/ecs/packages/ecs/src/interface/index.type-test.ts @@ -27,8 +27,8 @@ 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: { with: [position, slot] } }, () => {}); -export const readOne = System.create({ observer: true, lookup: { at: { with: [position, slot] } } }, () => {}); +export const read = System.create({ readonly: true, components: { with: [position, slot] } }, () => {}); +export const readOne = System.create({ readonly: true, lookup: { at: { with: [position, slot] } } }, () => {}); export const report = world.run(all); export const shader = wgsl(draw); export const added = world.add(read); diff --git a/ecs/packages/ecs/src/interface/indexes/index.type-test.ts b/ecs/packages/ecs/src/interface/indexes/index.type-test.ts index 4008d123..40a2f944 100644 --- a/ecs/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/ecs/packages/ecs/src/interface/indexes/index.type-test.ts @@ -219,7 +219,7 @@ System.create({ // 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. -System.create({ observer: true, lookup: { living: { index: living, changed: true } } }, ({ lookup }) => { +System.create({ readonly: true, lookup: { living: { index: living, changed: true } } }, ({ lookup }) => { const rows = lookup.living(); check>(); check>(); diff --git a/ecs/packages/ecs/src/interface/registry/names.ts b/ecs/packages/ecs/src/interface/registry/names.ts index 403141f5..11e6a2db 100644 --- a/ecs/packages/ecs/src/interface/registry/names.ts +++ b/ecs/packages/ecs/src/interface/registry/names.ts @@ -61,6 +61,14 @@ const entryOf = (key: symbol): Entry | undefined => { return entries.get(key); }; +/** Every key registered as `kind`, each by the symbol a world gives its values by, with its declaration. */ +export const declaredOf = (kind: "component" | "resource"): ReadonlyMap => { + catchUp(); + const found = new Map(); + for (const entry of entries.values()) if (entry.kind === kind && entry.declaration !== undefined) found.set(entry.newest, entry.declaration); + return found; +}; + /** Whether a key is a component, a resource or a trait. */ export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entryOf(key)?.kind : undefined; diff --git a/ecs/packages/ecs/src/interface/registry/schedule.ts b/ecs/packages/ecs/src/interface/registry/schedule.ts index 894d3cc6..c24aadf8 100644 --- a/ecs/packages/ecs/src/interface/registry/schedule.ts +++ b/ecs/packages/ecs/src/interface/registry/schedule.ts @@ -28,15 +28,15 @@ const checkInputs = (leaves: readonly Leaf[]): void => { /** * Leaves in run order, grouped into tiers. Leaves in one tier have no ordering path between them. `reductions` are the - * resources its leaves reduce into, each with how many of them contribute. `observer` are its observer leaves, in no + * resources its leaves reduce into, each with how many of them contribute. `observer` are its readonly leaves, in no * tier: they run after its sync point, ordered with nothing. */ export type Schedule = { readonly tiers: readonly (readonly Leaf[])[]; readonly reductions: ReadonlyMap; readonly observer: readonly Leaf[] }; 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 & { readonly declaration: { readonly observer: true } } => system.kind !== "composite" && system.declaration.observer === true; +/** Whether a system is a readonly system leaf, which runs after a sync point rather than in a tier. */ +export const isObserver = (system: Definition): system is Leaf & { readonly declaration: { readonly readonly: true } } => system.kind !== "composite" && system.declaration.readonly === true; /** Each system is an interval, from its start node to its end node. */ const start = (unit: number): number => 2 * unit; @@ -65,7 +65,7 @@ const graphOf = (root: Definition): Graph => { }; visit(root); const successors = units.flatMap(() => [new Set(), new Set()]); - // An observer system is ordered with nothing, so constraints naming one are ignored. + // A readonly system is ordered with nothing, so constraints naming one are ignored. const resolve = (systems: readonly object[] | undefined): readonly number[] => (systems ?? []).flatMap(system => { const unit = indexOf.get(firstItemOf(system)); diff --git a/ecs/packages/ecs/src/interface/resource/declarations.ts b/ecs/packages/ecs/src/interface/resource/declarations.ts new file mode 100644 index 00000000..73f9cf50 --- /dev/null +++ b/ecs/packages/ecs/src/interface/resource/declarations.ts @@ -0,0 +1,11 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Declared } from '../component/declared.js'; +import { declaredOf } from '../registry/names.js'; +import type { Key } from './key.js'; + +/** Every resource its modules registered so far, with its declaration. */ +export const declarations = (): ReadonlyMap => { + // Registration takes only keys declared in `Resources`, so each symbol registered as a resource is one of its keys. + const found = declaredOf("resource") as ReadonlyMap; + return found; +}; diff --git a/ecs/packages/ecs/src/interface/resource/index.ts b/ecs/packages/ecs/src/interface/resource/index.ts index 94707a6d..a26ae149 100644 --- a/ecs/packages/ecs/src/interface/resource/index.ts +++ b/ecs/packages/ecs/src/interface/resource/index.ts @@ -1,2 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export type { Key } from './key.js'; +export { declarations } from './declarations.js'; +export type { Declared } from '../component/declared.js'; diff --git a/ecs/packages/ecs/src/interface/system/body.ts b/ecs/packages/ecs/src/interface/system/body.ts index c9a77b27..d499ac6c 100644 --- a/ecs/packages/ecs/src/interface/system/body.ts +++ b/ecs/packages/ecs/src/interface/system/body.ts @@ -10,14 +10,15 @@ 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 { Tables } from './tables.js'; import type { ComponentValues, LookupFunction, ResourceValues, Values } from './values.js'; /** 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; +/** Whether a declaration only reads: one declared `readonly`, or a render system. */ +type Reading = D extends { readonly readonly: true } | { readonly shader: object } ? true : false; /** The components a declaration's rows hold only where an entity has them. */ export type Optional = Exclude, "optional">, Members>; @@ -51,8 +52,8 @@ type Is = Listed, "is">; /** * 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. + * parent's values with `up`, and its commands, there for every system but a GPU kernel, a render system and one + * declared `readonly`. The build infers which of them the body reads and writes. */ export type Context = { @@ -66,7 +67,8 @@ export type Context = /** 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 }); + & (D extends { readonly kernel: "gpu" } | { readonly shader: object } | { readonly readonly: true } ? {} : { readonly commands: Commands }) + & (D extends { readonly tables: object } ? { readonly tables: Tables } : {}); /** * `(row, context, input)` for a system with `components`, `(context, input)` for one without, and `(gpu, context)` for diff --git a/ecs/packages/ecs/src/interface/system/check.ts b/ecs/packages/ecs/src/interface/system/check.ts index 6c68a551..c0ddaaa1 100644 --- a/ecs/packages/ecs/src/interface/system/check.ts +++ b/ecs/packages/ecs/src/interface/system/check.ts @@ -147,15 +147,15 @@ const checkWhere = (leaf: Leaf): void => { }; /** - * Throws for what an observer system can't declare: it only reads, after a sync point, so it writes and reduces into + * Throws for what a readonly system can't declare: it only reads, after a sync point, so it writes and reduces into * nothing, takes no input, isn't a kernel or a render system, has no `up`, and is ordered with nothing. */ -const checkObserver = (leaf: Leaf): void => { +const checkReadonly = (leaf: Leaf): void => { const { name, declaration } = leaf; - const { observer } = declaration; - if (observer === undefined) return; - 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.`); + const { readonly } = declaration; + if (readonly === undefined) return; + const fail = (message: string): never => { throw new Error(`System "${name}" is readonly, so it ${message}.`); }; + if (readonly !== true) throw new Error(`System "${name}": readonly is true or left out.`); if (leaf.kind === "render") fail("records no GPU work"); 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"); @@ -165,9 +165,22 @@ const checkObserver = (leaf: Leaf): void => { if ((declaration.before?.length ?? 0) + (declaration.after?.length ?? 0) + (declaration.during?.length ?? 0) > 0) fail("is ordered with nothing"); }; +/** Throws for `tables` on a system that isn't readonly or has components, or naming keys that aren't registered as their kind. */ +const checkTables = (leaf: Leaf): void => { + const { name, declaration } = leaf; + const { tables } = declaration; + if (tables === undefined) return; + if (declaration.readonly !== true) throw new Error(`System "${name}" reads tables, which only a readonly system reads.`); + if (declaration.components !== undefined) throw new Error(`System "${name}" reads tables, so it has no components.`); + requireRegistered("System", name, "component", tables.components); + requireRegistered("System", name, "component", tables.with); + requireRegistered("System", name, "component", tables.not); + requireRegistered("System", name, "resource", tables.resources); +}; + /** * 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, 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 a readonly system can't, or whose * reductions, conditions, `up` or placement can't hold. */ export const checkLeaf = (leaf: Leaf): void => { @@ -190,6 +203,7 @@ export const checkLeaf = (leaf: Leaf): void => { if (writes.has(key)) throw new Error(`System "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", which the system writes.`); } checkTarget(leaf); + checkTables(leaf); threadOf(declaration, "System", name); requireRegistered("System", name, "component", components?.has); requireRegistered("System", name, "component", components?.not); @@ -202,7 +216,7 @@ export const checkLeaf = (leaf: Leaf): void => { if (declarationOf(key)?.reference !== undefined) throw new Error(`System "${name}" writes "${labelOf(key)}", a reference, which changes only through commands.`); if (declarationOf(key)?.shared === true) throw new Error(`System "${name}" writes "${labelOf(key)}", a shared component, whose value is its archetype's: give it another with commands.add.`); } - checkObserver(leaf); + checkReadonly(leaf); checkWhere(leaf); checkReductions(leaf); checkConditions(leaf); diff --git a/ecs/packages/ecs/src/interface/system/commands.ts b/ecs/packages/ecs/src/interface/system/commands.ts index 995e4db5..a0a6da74 100644 --- a/ecs/packages/ecs/src/interface/system/commands.ts +++ b/ecs/packages/ecs/src/interface/system/commands.ts @@ -2,6 +2,7 @@ import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Essential, Shared } from '../component/value.js'; +import type { Table } from '../world/table/table.js'; import type { ValuesOf } from './values-of.js'; /** The value an `add` of one component takes: none for a tag. */ @@ -68,4 +69,17 @@ export type Commands = { add(entity: Entity, components: C, values: ValuesOf): void; remove(entity: Entity, component: Essential): void; remove(entity: Entity, components: readonly Essential[]): void; + /** + * Spawns an entity for each row of `tables`, as a readonly system's `tables` gives them, of each table's components + * with its columns' values: a block copy per column. Entity values are rewritten as the world's references would + * leave them had the entities outside the tables despawned: each naming an entity of the tables, a reference, a + * struct's field or an array's item, names the one spawned for it; a row whose owner reference names an entity + * outside them isn't spawned, nor anything it owns, through owner references, as despawning its owner would; a + * weak reference to one is left out of its row, which is spawned without it; any other value naming one is + * `Entity.none`. Returns, at once, each table's new entities, alive at the sync point. A column's stride must be its + * component's in this world, and its length its table's count. `resources` sets each resource it names, by key, at + * the sync point, as commands apply, each value checked against its resource's schema; not one only the world + * writes, nor one living on another thread. On the world's thread only. + */ + spawnTables(tables: readonly Table[], resources?: { readonly [key: symbol]: unknown }): Table.Spawned; }; diff --git a/ecs/packages/ecs/src/interface/system/create.test.ts b/ecs/packages/ecs/src/interface/system/create.test.ts index ccfb7042..dcbc90d3 100644 --- a/ecs/packages/ecs/src/interface/system/create.test.ts +++ b/ecs/packages/ecs/src/interface/system/create.test.ts @@ -105,17 +105,17 @@ describe("System.create", () => { [{ 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] }, 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] }, resources: { reduce: [total] } }, "is an observer, so it reduces into nothing"], - [{ 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"], - [{ observer: true, resources: { read: [time] }, during: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], + [{ readonly: false, resources: { read: [time] } }, "readonly is true or left out"], + [{ readonly: true, components: { write: [position] } }, "is readonly, so it writes nothing"], + [{ readonly: true, resources: { write: [time] } }, "is readonly, so it writes nothing"], + [{ readonly: true, components: { has: [position] }, resources: { reduce: [total] } }, "is readonly, so it reduces into nothing"], + [{ readonly: true, kernel: true, components: { read: [position] } }, "is readonly, so it isn't a kernel"], + [{ readonly: true, components: { read: [position] }, up: { through: parent } }, "is readonly, so it has no up"], + [{ readonly: true, resources: { read: [time] }, after: [System.create(() => {})] }, "is readonly, so it is ordered with nothing"], + [{ readonly: true, resources: { read: [time] }, before: [System.create(() => {})] }, "is readonly, so it is ordered with nothing"], + [{ readonly: true, resources: { read: [time] }, during: [System.create(() => {})] }, "is readonly, so it is ordered with nothing"], ]; - expect(() => createTaking({ observer: true }, { type: "object", properties: {} })).toThrow("is an observer, so it takes no input"); + expect(() => createTaking({ readonly: true }, { type: "object", properties: {} })).toThrow("is readonly, so it takes no input"); rejected.forEach(([declaration, message], index) => { expect(() => create({ ...declaration })).toThrow(message); }); @@ -171,9 +171,9 @@ describe("System.create of a composite", () => { 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", () => { + it("runs readonly members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { 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 watch = System.create({ readonly: 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]))); @@ -205,6 +205,6 @@ describe("System.render", () => { 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("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"); + expect(() => render({ shader: { accessors: {} }, readonly: true })).toThrow("is readonly, so it records no GPU work"); }); }); diff --git a/ecs/packages/ecs/src/interface/system/create.ts b/ecs/packages/ecs/src/interface/system/create.ts index 5e4cf480..849ed80d 100644 --- a/ecs/packages/ecs/src/interface/system/create.ts +++ b/ecs/packages/ecs/src/interface/system/create.ts @@ -50,10 +50,10 @@ type GpuKernel = { }; /** - * An observer system only reads, after a sync point: its row and resources are read-only, and it never compiles to a + * A readonly 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 = { +type Readonly = { readonly kernel?: false; readonly up?: never; readonly before?: never; @@ -97,7 +97,12 @@ type Validate = // GPU kernels don't read components an archetype may lack yet. & (D extends { readonly components: { readonly optional: readonly [unknown, ...unknown[]] } } ? { readonly kernel?: boolean | { readonly "error: a GPU kernel can't have optional components yet": true } } : {}) - & (D extends { readonly observer: true } ? Observer : {}) + & (D extends { readonly readonly: true } ? Readonly : {}) + // Tables are copies a readonly system without components reads. + & (D extends { readonly tables: object } + ? (D extends { readonly readonly: true } ? {} : { readonly tables?: { readonly "error: tables are read by a readonly system": true } }) + & (Property extends undefined ? {} : { readonly tables?: { readonly "error: a system reading tables has no components": true } }) + : {}) & (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 } } : {} : {}) // A body's input is the type of its parameter after its context, not part of its declaration. @@ -109,7 +114,7 @@ type EntityFields = { [K in keyof I]-?: [Exclude] extends [E /** * 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 + * runs pass different input, nor is it a readonly system. Every check passes for `never`, the input before the body is * inferred. */ type Checks = [I] extends [never] ? unknown : @@ -119,7 +124,7 @@ type Checks = [I] extends [never] ? unknown : : 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)); + & (D extends { readonly readonly: true } ? { readonly "error: a readonly system takes no input": true } : unknown)); /** The body of a system that declares nothing, which runs once per run with its resources and commands. */ type Alone = (context: Context<{}>) => void; @@ -144,10 +149,10 @@ type Checked = C extends Alone ? ReturnsNoPromise : C extends CompositeDec * 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 + * different threads, or its reductions, conditions, `up`, `order` or placement can't hold, or it's a readonly system and * 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 + * declare an input field with different schemas. A composite's readonly members run after its sync point, ordered * with nothing. * * A run of a system without components resolves to what its body returns, or `undefined` when its conditions, resource @@ -180,13 +185,13 @@ export function create(first: Declaration | Leaf["body"] | readonly System): Resolved => { - const reading = declared.observer === true; + const reading = declared.readonly === true; const { components, lookup, up, resources, ...rest } = declared; const uses = resourceUses(what, access, resources?.is); - // What `where`'s predicates read counts as the system's reads, for its order, conflicts and placement. const whereReads = whereReadsOf(what, components, access); const conditions: readonly symbol[] = resources?.is ?? []; - for (const key of whereReads) if (!uses.read.includes(key) && !uses.write.includes(key) && !conditions.includes(key)) uses.read.push(key); + // What `where`'s predicates and `tables` read counts as the system's reads, for its order, conflicts and placement. + for (const key of [...whereReads, ...declared.tables?.resources ?? []]) if (!uses.read.includes(key) && !uses.write.includes(key) && !conditions.includes(key)) uses.read.push(key); const resolved: Resolved = { ...rest, ...components === undefined ? {} : { components: componentLists(components, access.components, reading, whereReads) }, diff --git a/ecs/packages/ecs/src/interface/system/declaration.ts b/ecs/packages/ecs/src/interface/system/declaration.ts index f9ca0dac..db0145e1 100644 --- a/ecs/packages/ecs/src/interface/system/declaration.ts +++ b/ecs/packages/ecs/src/interface/system/declaration.ts @@ -4,6 +4,7 @@ import type { Resource } from '../resource/resource.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'; +import type { TablesDeclaration } from './tables.js'; /** * A system never declares its thread: it runs on the one its object components and resources live on, those it @@ -59,9 +60,15 @@ export type Declaration = { readonly during?: readonly System[]; /** * Runs after the last sync point of each run and tick (a tick is a run of the added composite), reading the settled - * state; changes nothing; never ordered; doesn't keep the loop ticking. + * state, on the thread that added or ran it; changes nothing, its row and resources typed read-only and its context + * without `commands`; takes no input; never ordered; doesn't keep the loop ticking. A joined thread adds only these. */ - readonly observer?: true; + readonly readonly?: true; + /** + * For a readonly system without components: gives its context `tables`, copies of whole archetypes' columns, as + * `TablesDeclaration` says, read on the world's thread. Its components and resources count as what it reads. + */ + readonly tables?: TablesDeclaration; }; /** A declaration as the world takes it: the build's inferred reads, writes and reductions joined with what it declares. */ diff --git a/ecs/packages/ecs/src/interface/system/lists.type-test.ts b/ecs/packages/ecs/src/interface/system/lists.type-test.ts index c3e37b94..9faae306 100644 --- a/ecs/packages/ecs/src/interface/system/lists.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/lists.type-test.ts @@ -23,7 +23,7 @@ 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]; }); +export const read = System.create({ readonly: 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]; diff --git a/ecs/packages/ecs/src/interface/system/optional.type-test.ts b/ecs/packages/ecs/src/interface/system/optional.type-test.ts index 7bc51c5d..5192a5f6 100644 --- a/ecs/packages/ecs/src/interface/system/optional.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/optional.type-test.ts @@ -36,7 +36,7 @@ System.create({ }, row => { void row[velocity]; }); // An observer system reads its optional components and writes nothing. -System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { +System.create({ readonly: true, components: { with: [position], optional: [velocity] } }, row => { void row[velocity]; // @ts-expect-error an observer system's row is read-only row[velocity] = 1; diff --git a/ecs/packages/ecs/src/interface/system/shared.type-test.ts b/ecs/packages/ecs/src/interface/system/shared.type-test.ts index 3f520206..47c14f5c 100644 --- a/ecs/packages/ecs/src/interface/system/shared.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/shared.type-test.ts @@ -126,7 +126,7 @@ System.create({ components: { with: [health], optional: [team] } }, row => { // @ts-expect-error nor an optional one row[team] = "red"; }); -System.create({ observer: true, components: { with: [team] } }, row => { +System.create({ readonly: true, components: { with: [team] } }, row => { check>(); }); diff --git a/ecs/packages/ecs/src/interface/system/system.type-test.ts b/ecs/packages/ecs/src/interface/system/system.type-test.ts index b3615efd..893129e8 100644 --- a/ecs/packages/ecs/src/interface/system/system.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/system.type-test.ts @@ -367,7 +367,7 @@ 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, + readonly: true, components: { with: [position], changed: [position] }, lookup: { targets: { with: [velocity] } }, }, (row, context) => { @@ -379,33 +379,39 @@ export const watchPositions = System.create({ void context.commands; }); check>(); -export const watchTime = System.create({ observer: true, resources: { changed: [time] } }, ({ resources }) => { void resources[time]; }); +export const watchTime = System.create({ readonly: 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 }, () => {}); -System.create({ observer: true, components: { with: [position] } }, (row, { resources }) => { - // @ts-expect-error an observer system writes no components +// @ts-expect-error observer isn't a declaration's: readonly is +System.create({ observer: true }, () => {}); +// @ts-expect-error readonly is true or left out +System.create({ readonly: false }, () => {}); +System.create({ readonly: true, components: { with: [position] } }, (row, { resources }) => { + // @ts-expect-error a readonly system writes no components row[position] = 0; - // @ts-expect-error an observer system writes no resources + // @ts-expect-error a readonly system writes no resources resources[score] = 0; }); -System.create({ observer: true }, context => { - // @ts-expect-error an observer system reduces into nothing +System.create({ readonly: true }, context => { + // @ts-expect-error a readonly system reduces into nothing void context.reduce; + // @ts-expect-error a readonly system has no commands + void context.commands; }); -// @ts-expect-error an observer system takes no 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: { 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, after: [integrate] }, () => {}); -// @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, before: [integrate] }, () => {}); -// @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, during: [integrate] }, () => {}); +// @ts-expect-error a readonly system takes no input +System.create({ readonly: true }, (_context, input: { readonly count: number }) => { void input; }); +// @ts-expect-error a readonly system isn't a kernel +System.create({ readonly: true, kernel: true, components: { with: [position] } }, () => {}); +// @ts-expect-error a readonly system isn't a GPU kernel +System.create({ readonly: true, kernel: "gpu", components: { with: [position] } }, () => {}); +System.create({ readonly: true, kernel: false, components: { with: [position] } }, () => {}); +// @ts-expect-error a readonly system is ordered with nothing +System.create({ readonly: true, after: [integrate] }, () => {}); +// @ts-expect-error a readonly system is ordered with nothing +System.create({ readonly: true, before: [integrate] }, () => {}); +// @ts-expect-error a readonly system is ordered with nothing +System.create({ readonly: 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 }; diff --git a/ecs/packages/ecs/src/interface/system/tables.ts b/ecs/packages/ecs/src/interface/system/tables.ts new file mode 100644 index 00000000..ca4579d4 --- /dev/null +++ b/ecs/packages/ecs/src/interface/system/tables.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component } from '../component/component.js'; +import type { Resource } from '../resource/resource.js'; +import type { Table } from '../world/table/table.js'; + +/** + * What a readonly system's `tables` reads: of every archetype matching `with` and `not` that holds any of + * `components`, a table of those it holds; and, with `resources`, each resource's value. + */ +export type TablesDeclaration = { + readonly components: readonly Component.Key[]; + readonly with?: readonly Component.Key[]; + readonly not?: readonly Component.Key[]; + readonly resources?: readonly Resource.Key[]; +}; + +/** A readonly system's `tables`: a table of each archetype its declaration reads, and the values of the resources it reads. */ +export type Tables = { + readonly archetypes: readonly Table[]; + readonly resources: { readonly [K in Resource.Key]?: Resource }; +}; diff --git a/ecs/packages/ecs/src/interface/system/up.type-test.ts b/ecs/packages/ecs/src/interface/system/up.type-test.ts index d3b811ec..0b526dfa 100644 --- a/ecs/packages/ecs/src/interface/system/up.type-test.ts +++ b/ecs/packages/ecs/src/interface/system/up.type-test.ts @@ -62,6 +62,6 @@ System.create({ 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: { with: [local] }, up: { through: parent } }, () => {}); +// A readonly system reads what a sync point left, in no order. +// @ts-expect-error a readonly system has no up +System.create({ readonly: true, components: { with: [local] }, up: { through: parent } }, () => {}); diff --git a/ecs/packages/ecs/src/interface/world/index.ts b/ecs/packages/ecs/src/interface/world/index.ts index 4d29ed06..78489ed7 100644 --- a/ecs/packages/ecs/src/interface/world/index.ts +++ b/ecs/packages/ecs/src/interface/world/index.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -// The `World` namespace's members: none, since a consumer declares nothing more with it. -export {}; +// The `World` namespace's types. +export type { Table } from './table/table.js'; diff --git a/ecs/packages/ecs/src/interface/world/run.type-test.ts b/ecs/packages/ecs/src/interface/world/run.type-test.ts index 58809973..1f3096c4 100644 --- a/ecs/packages/ecs/src/interface/world/run.type-test.ts +++ b/ecs/packages/ecs/src/interface/world/run.type-test.ts @@ -19,7 +19,7 @@ declare module '@adobe/ecs' { } // A run resolves to its per-run body's value: a one-off read is an observer system returning it. -const count = System.create({ observer: true }, ({ resources }) => resources[frames]); +const count = System.create({ readonly: true }, ({ resources }) => resources[frames]); check>>(); const counted: Promise = world.run(count); // @ts-expect-error a run of it resolves to a number @@ -32,7 +32,7 @@ const scaled = System.create({}, ({ resources }, input: { readonly by: number }) const scaledValue: Promise<{ scaled: number }> = world.run(scaled, { by: 2 }); // A body that may be skipped, as one whose conditions or resource change filters can fail, may give undefined. -const changedFrames = System.create({ observer: true, resources: { changed: [frames] } }, ({ resources }) => resources[frames]); +const changedFrames = System.create({ readonly: true, resources: { changed: [frames] } }, ({ resources }) => resources[frames]); check>>(); const whileDevice = System.create({ resources: { is: [gpuDevice] } }, () => "drawn"); const drawn: Promise = world.run(whileDevice); @@ -68,7 +68,7 @@ void [counted, notText, nextValue, scaledValue, drawn, rows, composite, input, n // @ts-expect-error a body returns no promise System.create(() => Promise.resolve(1)); // @ts-expect-error a body returns no promise -System.create({ observer: true }, () => Promise.resolve(1)); +System.create({ readonly: true }, () => Promise.resolve(1)); // @ts-expect-error a body returns no promise, a row system's neither System.create({ components: { with: [position] } }, () => Promise.resolve()); // @ts-expect-error a body returns no promise, a render system's neither diff --git a/ecs/packages/ecs/src/interface/world/table/index.ts b/ecs/packages/ecs/src/interface/world/table/index.ts new file mode 100644 index 00000000..60733f88 --- /dev/null +++ b/ecs/packages/ecs/src/interface/world/table/index.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** + * One component's values in a table, copies taken as a readonly system reads `tables`: + * - `bytes`: a struct's or number's, `stride` bytes per row in the world's layout for it, which is a function of the + * component's schema alone; + * - `values`: a string's or an object's, one per row; + * - `value`: one value every row holds, for a shared component or one whose schema is a constant. + */ +export type Column = + | { readonly kind: "bytes"; readonly bytes: Uint8Array; readonly stride: number } + | { readonly kind: "values"; readonly values: readonly unknown[] } + | { readonly kind: "value"; readonly value: unknown }; + +/** + * What `commands.spawnTables` spawned: each table's new entities in its row order, `Entity.none` for a row it didn't + * spawn, and how many rows it didn't, those owned by an entity outside the tables. + */ +export type Spawned = { readonly entities: readonly Float64Array[]; readonly dropped: number }; diff --git a/ecs/packages/ecs/src/interface/world/table/table.ts b/ecs/packages/ecs/src/interface/world/table/table.ts new file mode 100644 index 00000000..4ad010cd --- /dev/null +++ b/ecs/packages/ecs/src/interface/world/table/table.ts @@ -0,0 +1,12 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component } from '../../component/component.js'; +import type { Column } from './index.js'; + +/** The rows of one archetype: their entities in row order, and a column for each component asked for that it holds. */ +export type Table = { + readonly count: number; + readonly entities: Float64Array; + readonly columns: { readonly [K in Component.Key]?: Column }; +}; + +export type * as Table from './index.js'; diff --git a/ecs/packages/ecs/src/interface/world/world.ts b/ecs/packages/ecs/src/interface/world/world.ts index 34604db1..443f14d8 100644 --- a/ecs/packages/ecs/src/interface/world/world.ts +++ b/ecs/packages/ecs/src/interface/world/world.ts @@ -3,23 +3,23 @@ import type { Input } from '../system/input.js'; import type { System } from '../system/system.js'; /** - * A world. Systems read and change it; observer systems read it after each run's and tick's sync point. Every member's + * A world. Systems read and change it; readonly systems read it after each run's and tick's sync point. Every member's * type is named from the package root, so declarations a consumer emits for its results name no internal module. */ export type World = { /** * The one way to change state. Runs are sequential in call order; physically, any that don't conflict overlap. Each - * runs a system or composite, ending in a sync point, then observer systems, the run's and those added, and + * runs a system or composite, ending in a sync point, then readonly systems, the run's and those added, and * resolves to what a per-run body returns: a system without components resolves to its body's value, or * `undefined` when its body was skipped, and any other system or composite to nothing. So a one-off read is a run - * of an observer system returning what it read; one that runs on a joined thread, as it reads objects there, runs - * there before the run resolves, to its value as structured clone. A run of observer systems alone runs them once + * of a readonly system returning what it read; one that runs on a joined thread, as it reads objects there, runs + * there before the run resolves, to its value as structured clone. A run of readonly systems alone runs them once * after an empty sync point, and doesn't wake the loop. * * When a system or finalizer of the run fails, later tiers are skipped, the sync point still applies what was done, - * and once its observer systems ran the run rejects with an `Error` naming each failure, its system's label and id, + * and once its readonly systems ran the run rejects with an `Error` naming each failure, its system's label and id, * message and cause. It rejects too 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. What fails after it, an observer system + * its GPU device is lost, or its sync point can't apply a command's value. What fails after it, a readonly system * added or GPU work, is reported out of band, as ticks' failures are (see `add`). */ run(system: System, ...input: Input>): Promise; @@ -27,9 +27,9 @@ export type World = { * Adds a system to every tick, from the next one on, ordered with those already added by its constraints, as a * composite's members are. A tick runs on a beat of the `cadence` resource where `always` is set, `wake` is due or * a run arrived since the last tick, and only while some system is added. Throws for a system with no ordering path - * to one already added that conflicts with it. An observer system runs after every run's and tick's sync point + * to one already added that conflicts with it. A readonly system runs after every run's and tick's sync point * instead. It never conflicts and doesn't count as an added system for ticking. On a joined thread `add` takes only - * observer systems, whose keys need ids: each runs on that thread after the world's sync points, once for the + * readonly systems, whose keys need ids: each runs on that thread after the world's sync points, once for the * newest when the thread fell behind, reading structs and the objects of the threads it fills as of one sync point. * * What added systems throw, and what fails in ticks, is reported out of band on the world's thread, as an `Error` @@ -43,14 +43,14 @@ export type World = { * released. A system added both alone and within an added composite stays until both are removed; constraints * naming a removed system are ignored. Removing a value that isn't added does nothing. Throws, removing nothing, * when the systems left would conflict with no ordering path between them, as `add` does. On a joined thread it - * takes only observer systems. + * takes only readonly systems. */ remove(system: System): void; /** * Lets another thread join this world through `port`, one end of a channel to it, of the browser or node: a * `MessagePort`, a `Worker`, or a worker's own scope, `self` or node's `parentPort`. That thread joins through the * other end, claiming the logical threads it fills. Resolves once it has joined: the threads it claims are its own, - * and the observer systems it added as its join resolved, before awaiting anything else, are added, so a run + * and the readonly systems it added as its join resolved, before awaiting anything else, are added, so a run * started afterwards runs on it and reaches them; one it adds later reaches the runs after its add arrived. * Rejects with an `Error` saying why it didn't join: the world refused its claim, as of a thread claimed twice, the * world was disposed, the port is shared already, or that thread ended first, as the platform reports it: a diff --git a/ecs/packages/ecs/src/observe/entity.ts b/ecs/packages/ecs/src/observe/entity.ts index 26292966..8bdb07df 100644 --- a/ecs/packages/ecs/src/observe/entity.ts +++ b/ecs/packages/ecs/src/observe/entity.ts @@ -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: { with: required, changed: required }, values: { with: required }, members: { index: membersOf(required), changed: required } } }, name, { lookup: { values: required } }), ({ lookup }) => { + ? System.create(labelled({ readonly: 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: optionalReads(required, optional) }), ({ lookup }) => { + : System.create(labelled({ readonly: 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) }; diff --git a/ecs/packages/ecs/src/observe/resource.ts b/ecs/packages/ecs/src/observe/resource.ts index 15218466..2e26006b 100644 --- a/ecs/packages/ecs/src/observe/resource.ts +++ b/ecs/packages/ecs/src/observe/resource.ts @@ -31,14 +31,14 @@ const createShape = (state: WorldState, byKey: Map, const label = `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: { changed: [key] } }, label, { resources: [key] }), ({ resources }) => { + const notifier = System.create(labelled({ readonly: 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 }, label, { resources: [key] }), ({ resources }) => { + const reader = System.create(labelled({ readonly: true }, label, { resources: [key] }), ({ resources }) => { const through = shape.called.shift() ?? 0; entry.value = resources[key]; entry.valid = true; diff --git a/ecs/packages/ecs/src/observe/select.ts b/ecs/packages/ecs/src/observe/select.ts index 79fcba05..f13a9334 100644 --- a/ecs/packages/ecs/src/observe/select.ts +++ b/ecs/packages/ecs/src/observe/select.ts @@ -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, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { + ? System.create(labelled({ readonly: 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, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { + ? System.create(labelled({ readonly: 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), changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { + : System.create(labelled({ readonly: 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: { with: [...reads, ...required.filter(key => !reads.includes(key))], not } }, name, { components: reads }), row => { + const scanner = System.create(labelled({ readonly: 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/ecs/packages/ecs/src/observe/world-state.ts b/ecs/packages/ecs/src/observe/world-state.ts index ea57812d..ee2f5078 100644 --- a/ecs/packages/ecs/src/observe/world-state.ts +++ b/ecs/packages/ecs/src/observe/world-state.ts @@ -116,7 +116,7 @@ const ignore = (): void => { }; /** Runs nothing: a run of it resolves while its world lives. */ -const alive = System.create(labelled({ observer: true }, "Observe/alive"), () => {}); +const alive = System.create(labelled({ readonly: true }, "Observe/alive"), () => {}); /** * What a rejected run of the factories' own does: nothing for a disposed world, whose first values never come, and for @@ -232,7 +232,7 @@ export const stateOf = (world: World): WorldState => { deliveries: [], idle: [], rejected: rejectedOf(world), - deliver: System.create(labelled({ observer: true }, "Observe/deliver"), () => { + deliver: System.create(labelled({ readonly: true }, "Observe/deliver"), () => { const delivery = created.deliveries.shift(); if (delivery === undefined) return; created.delivered = delivery.batch; @@ -243,7 +243,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(labelled({ observer: true }, "Observe/maintain"), () => { + world.add(System.create(labelled({ readonly: true }, "Observe/maintain"), () => { created.pass++; if (created.idle.length > 0) sweepShapes(created); })); diff --git a/ecs/packages/ecs/src/perftest/benchmarks.ts b/ecs/packages/ecs/src/perftest/benchmarks.ts index 4e505017..9d7999bf 100644 --- a/ecs/packages/ecs/src/perftest/benchmarks.ts +++ b/ecs/packages/ecs/src/perftest/benchmarks.ts @@ -505,7 +505,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: { with: [hierarchyParent] } }, row => { + await world.run(System.create({ readonly: true, components: { with: [hierarchyParent] } }, row => { if (found.child === null && row[hierarchyParent] === roots[0]) found.child = row.entity; })); const { child } = found; diff --git a/ecs/packages/ecs/src/perftest/indexes.ts b/ecs/packages/ecs/src/perftest/indexes.ts index 360e6a2d..5e6092c3 100644 --- a/ecs/packages/ecs/src/perftest/indexes.ts +++ b/ecs/packages/ecs/src/perftest/indexes.ts @@ -307,13 +307,13 @@ 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({ observer: true }, () => {}); +const pass = System.create({ readonly: 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 }) => { + ? System.create({ readonly: true, lookup: { byFlag: { index: byFlag, changed: true } } }, ({ lookup }) => { found += lookup.byFlag(false).length + lookup.byFlag(true).length; }) - : System.create({ observer: true, lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + : System.create({ readonly: true, lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { const off = lookup.byFlag(false); const on = lookup.byFlag(true); if (!off.changed && !on.changed) return; diff --git a/ecs/packages/ecs/src/perftest/loop.ts b/ecs/packages/ecs/src/perftest/loop.ts index 38a6c450..eaf71e5e 100644 --- a/ecs/packages/ecs/src/perftest/loop.ts +++ b/ecs/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: { with: [unused] } }, () => {})); +const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ readonly: 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/ecs/packages/ecs/src/perftest/observer.ts b/ecs/packages/ecs/src/perftest/observer.ts index 55f5657b..64c7c9a9 100644 --- a/ecs/packages/ecs/src/perftest/observer.ts +++ b/ecs/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({ observer: true, resources: { changed: [clock] } }, () => { notify(); }); + const counter = System.create({ readonly: 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({ observer: true }, ({ resources }) => { +const readClock = System.create({ readonly: 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 }) => { + const counter = System.create({ readonly: 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: { with: [...has], not } }, row => { found.push(row.entity); })); + await world.run(System.create({ readonly: true, components: { with: [...has], not } }, row => { found.push(row.entity); })); return found; }; diff --git a/ecs/packages/ecs/src/perftest/reads.ts b/ecs/packages/ecs/src/perftest/reads.ts index 60741c83..a1657ee6 100644 --- a/ecs/packages/ecs/src/perftest/reads.ts +++ b/ecs/packages/ecs/src/perftest/reads.ts @@ -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: { with: [value] } }, row => { found.push(row.entity); })); + await world.run(System.create({ readonly: true, components: { with: [value] } }, row => { found.push(row.entity); })); return found; }; @@ -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 }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({ readonly: true }, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run observer: wrong value"); await world.dispose(); return best; diff --git a/ecs/packages/ecs/src/perftest/remote.ts b/ecs/packages/ecs/src/perftest/remote.ts index dfcc5a62..9159c83f 100644 --- a/ecs/packages/ecs/src/perftest/remote.ts +++ b/ecs/packages/ecs/src/perftest/remote.ts @@ -49,7 +49,7 @@ 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: { changed: [counter] } }, ({ resources }) => { +export const observerCounter = System.create({ readonly: true, resources: { changed: [counter] } }, ({ resources }) => { observerSeen.last = resources[counter]; observerSeen.notified++; }); @@ -61,7 +61,7 @@ const sortedKeys = { keys: new Float64Array(0), entities: new Float64Array(0), p * 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 } } }, ({ lookup }) => { +export const observerSelect = System.create({ readonly: true, lookup: { all: { index: ranked } } }, ({ lookup }) => { const list = lookup.all(); const count = list.length; if (sortedKeys.keys.length < count) { @@ -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: { with: [caption] } }, row => { +export const observerCaptions = System.create({ readonly: 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: { with: [amount] } }, row => { +export const observerAmounts = System.create({ readonly: 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 }) => { value = resources[counter]; })); + await world.run(System.create({ readonly: true }, ({ resources }) => { value = resources[counter]; })); return value; }; diff --git a/ecs/packages/ecs/src/perftest/strings.ts b/ecs/packages/ecs/src/perftest/strings.ts index 2239eb1c..a77a5883 100644 --- a/ecs/packages/ecs/src/perftest/strings.ts +++ b/ecs/packages/ecs/src/perftest/strings.ts @@ -80,7 +80,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: { with: [text, boxed] } }, row => { + await world.run(System.create({ readonly: 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/ecs/packages/ecs/src/perftest/suite.ts b/ecs/packages/ecs/src/perftest/suite.ts index d4e1ee38..c93330d3 100644 --- a/ecs/packages/ecs/src/perftest/suite.ts +++ b/ecs/packages/ecs/src/perftest/suite.ts @@ -196,7 +196,7 @@ export const idle = System.create({ components: { with: [unused] } }, () => {}); /** A system that throws, so each run of it rejects. */ 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: { with: [unused] } }, () => {}); +export const idleObserver = System.create({ readonly: true, components: { with: [unused] } }, () => {}); /** Fifty systems no entity matches, in one tier. */ export const idles = System.create({ diff --git a/ecs/packages/ecs/src/world.test.ts b/ecs/packages/ecs/src/world.test.ts index aad3445a..182bebd7 100644 --- a/ecs/packages/ecs/src/world.test.ts +++ b/ecs/packages/ecs/src/world.test.ts @@ -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: { with: [count] } }, row => { seen = row[count]; })); + await world.run(System.create({ readonly: 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", () => { @@ -40,7 +40,7 @@ describe("World.join", () => { await shared; await joined.run(raise); let seen = 0; - await world.run(System.create({ observer: true }, ({ resources }) => { seen = resources[total]; })); + await world.run(System.create({ readonly: true }, ({ resources }) => { seen = resources[total]; })); expect(seen).toBe(7); }); }); diff --git a/ecs/packages/ecs/src/world.ts b/ecs/packages/ecs/src/world.ts new file mode 100644 index 00000000..9007f869 --- /dev/null +++ b/ecs/packages/ecs/src/world.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { create as createWorld, join as joinWorld } from './implementation/index.js'; +import type { Create } from './interface/world/create.js'; +import type { Join } from './interface/world/join.js'; + +export type { Table } from './interface/world/table/table.js'; + +/** + * Creates a world on this thread, synchronously, with resources starting at the given values in place of their + * defaults. This thread is its `"world"` thread for the world's life: it runs the sync points, the ticks and the GPU + * device, and fills every logical thread no joined thread claims. Keys are the resources' symbols, built-in ones + * included (`{ [workers]: 0 }`); string keys are never a resource's, so they're ignored. Throws for a symbol that isn't + * a resource, or is one only the world writes. While a declaration's trait or default doesn't hold, each call fails to + * compile, once, naming each such key, its members and what's wrong with them. + * @function + */ +export const create: Create = createWorld; + +/** + * Joins, from another thread, a world that `share` let join through the other end of `port`, claiming the logical + * `threads` this thread fills: their objects live here, and the systems and finalizers reading them run here. Resolves, + * once the world accepted the claim, to the same world: its `run` runs systems there, and its `add` and `remove` take + * readonly systems, which run here after the world's sync points. Add them before awaiting anything else, so the + * world's `share` resolves with them in place. Its `dispose` disconnects this thread. Rejects with an `Error` saying + * why it didn't join: the world refused the claim, was disposed, or its thread ended first, as the platform reports it: + * the world's `Worker` failing to start or throwing first, a port closed, as in node, or, in a browser, the world's + * thread ending once it was shared through a port this thread listened on. A browser world's worker that the page ended + * before this thread heard from it isn't reported, so the join stays pending. Rejects too where memory can't be shared, + * as without cross-origin isolation. + * @function + */ +export const join: Join = joinWorld; diff --git a/ecs/packages/ecs/threads.md b/ecs/packages/ecs/threads.md index 2f557b3a..07360ce6 100644 --- a/ecs/packages/ecs/threads.md +++ b/ecs/packages/ecs/threads.md @@ -37,7 +37,7 @@ Referenced from `design.md`. 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 + registry already takes the symbols of one id as one key; a key without one can't cross, so a joined thread's readonly 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. @@ -94,11 +94,11 @@ await shared; `World.join`, from the package root as `World.create` is, resolves to the same `World` type once the world accepted its claims; its port is `share`'s, and the interface names no type for it, its port or its options. Its `run` works as -on the world's thread, its `add` and `remove` take only observer systems, which run there, `share` rejects there, and +on the world's thread, its `add` and `remove` take only readonly systems, which run there, `share` rejects there, and its `dispose` disconnects this thread; disposing on the world's thread disposes the world, and joined threads' runs reject from then on. `share` resolves once the thread joined: its claims are made, and what it did on the turn its join -resolved, observer systems it added included, reached the world, so a run started afterwards runs on the threads it -claims and reaches those observer systems. A thread adds them before awaiting anything else after `join`; one added +resolved, readonly systems it added included, reached the world, so a run started afterwards runs on the threads it +claims and reaches those readonly systems. A thread adds them before awaiting anything else after `join`; one added later, after an import or a fetch, reaches the runs after its add arrived. Both reject with an `Error` saying why the thread didn't join: the world refused its claim, the world was disposed, a port was shared twice while its thread lives, or the other side's thread ended first as the platform reports it (see A thread's end); and where memory can't @@ -141,10 +141,10 @@ All derived from declarations and, for terminal systems, the plugin's analysis o refuses the claim, or fails the system's registration once the thread is joined. So does one that writes a string, which only the world's thread writes, and a lookup through an index with an object or string key part, which the world keeps on its own thread; one keyed by structs is read where the system runs. - An index lookup's `changed: true` runs only on `world` too, observer systems' included, since the world decides from + An index lookup's `changed: true` runs only on `world` too, readonly systems' included, since the world decides from its index whether the system runs. -- **Observer systems** are placed as other systems are, and run on their thread after the world's sync points (see - Observer systems on joined threads); a joined thread's own, which its `add` or `run` takes, run there whatever they +- **Readonly systems** are placed as other systems are, and run on their thread after the world's sync points (see + Readonly systems on joined threads); a joined thread's own, which its `add` or `run` takes, run there whatever they read, and read only structs, strings and the objects of the threads it fills. One of the world's placed on a joined thread that didn't import it as it joined fails its registration, so its run rejects and an added one is reported, and a post the thread can't run that a run waits for is answered with why: no run waits on a post nothing answers. @@ -166,9 +166,9 @@ All derived from declarations and, for terminal systems, the plugin's analysis o clone, and rejects naming what failed; a tick's failures are reported out of band on the world's thread. - **A thread that dies** fails its systems with a `"thread"` failure, skipping them, and later tiers still run: the run rejects, and a tick reports them out of band. -- **Observer systems** run on `world` after each run's and tick's sync point; those on joined threads are posted to them +- **Readonly systems** run on `world` after each run's and tick's sync point; those on joined threads are posted to them in the same pass. They never make the loop tick, and a run of them alone doesn't wake it. While a tick's tails run - on, its observer systems run at once with its values. + on, its readonly systems run at once with its values. ### Shared layout directory @@ -344,7 +344,7 @@ thread as its only writer, drained by the world's thread at the sync point. Comm block, and its number; then the systems and finalizers on the claimed threads are placed there, each posted to the thread with its index there, its id and the world's numbers for the keys it names. The thread's `join` resolves as `joined` arrives; on its next turn of its event loop it posts `ready`, after what it did as its join resolved, so its - observer systems added then are declared and added when the world's `share` resolves on `ready`. A thread gone before + readonly systems added then are declared and added when the world's `share` resolves on `ready`. A thread gone before `ready` rejects the world's `share`. Claims are released when the thread is gone, between runs, its objects coming back as each row's tag names them, `null` or the start, since a value the thread staged or wrote was its own, and its systems and finalizers placed on the world's thread again, each system @@ -368,7 +368,7 @@ thread as its only writer, drained by the world's thread at the sync point. Comm cases received its task inputs but ran no task, while the world's thread waited synchronously for it; without the request it ran them. - **Control words,** each on a line of its own: the signal the thread waits on, which the world bumps when runs - complete, a task is posted or an observer post waits for it, beside an unread word the thread clears before it reads + complete, a task is posted or a readonly system post waits for it, beside an unread word the thread clears before it reads what's new, so the world notifies only a thread that has read every earlier wake; the last run the thread posted; and the last one the world completed. A thread whose unread word is set again once it read reads again rather than wait, since that wake's notify may have found no waiter, and the next wake won't notify while the word is set. A wake sets @@ -414,7 +414,7 @@ moves or fills a row appends: `commitBatches` and `commitStaged`, `writeValues` publishes "through position P, sync N" in the log's header after each sync point's entries, before the structure epoch turns even. -**Replay.** A thread replays lazily, only syncs published: before a task whose payload names sync S, or an observer +**Replay.** A thread replays lazily, only syncs published: before a task whose payload names sync S, or a readonly system post, it processes each pending sync in order: its finalizers first, against values still at their pre-apply rows, then its log. It copies the entries out, then checks the writer hasn't lapped it: the writer stores a limit 64 entries ahead of where it writes before writing past it, and the copy holds if the limit is within the ring's size of @@ -461,7 +461,7 @@ consecutive tiers all on that thread are one task, which stops after a tier with - **`where`** runs there, as written, over the thread's views: a value compared by its canonical text with each archetype's, a predicate reading the thread's view of the resources, each archetype's point kept there, rows moved in owing by the arrivals ring the world writes (`arrivals.ts`), read through the run's tick, its offset in the - directory header. The world posts an observer system with `where` and change filters after + directory header. The world posts a readonly system with `where` and change filters after every sync point, since it can't see those points, and copies every matched row into its snapshot. - **Commands and spawns** write into the thread's command ring, a segment per system, as a pool worker's do, with the merge tag of its tick and position. A value given the thread's own object is staged in its mirror under a slot whose @@ -510,7 +510,7 @@ and is reported once in a sync point's failures. Once the thread is gone, they r 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 - point that published stops for it, so a thread with no task or observer post still finalizes. + point that published stops for it, so a thread with no task or post of a readonly system still finalizes. - **Running them** (`thread-finalizers.ts`): the thread runs a sync's stop list as it replays that sync, at its sync 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 @@ -524,7 +524,7 @@ and is reported once in a sync point's failures. Once the thread is gone, they r 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 the world reports it out of band. - **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 + post of a readonly system after the sync point and before the entity's index is reused, seeing its declared struct and string 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 @@ -538,9 +538,9 @@ and is reported once in a sync point's failures. Once the thread is gone, they r structure epoch; (b) the world waits for remote finalizers inside the sync point, exact timing for about a 10 µs round trip per sync point with such stops. -### Observer systems on joined threads +### Readonly systems on joined threads -An observer system runs on a joined thread when that thread's `add` or `run` took it, as its own, or when the threads it +A readonly system runs on a joined thread when that thread's `add` or `run` took it, as its own, or when the threads it names objects of are ones that thread claims (`joined-observer.ts` on the world's thread, `thread-observer.ts` there). One placed by its objects is named there by its id and its keys' ids, as systems placed there are: without them, it fails to register, its run rejecting and an added one's failure reported out of band. @@ -556,7 +556,7 @@ fails to register, its run rejecting and an added one's failure reported out of its removed word, the run it's owed to, the sync of the thread's log it's as of, and the tick it takes and the `lastRun` it filters by. The thread is posted its index there, its block, the world's numbers for its keys and the records of its index lookups, written before each post as for systems on joined threads. -- **Posting:** after each run's and tick's sync point, in the observer pass, the world posts one whose conditions hold +- **Posting:** after each run's and tick's sync point, in the readonly pass, the world posts one whose conditions hold and that has no change filters, or one whose filter may pass: a resource it filters on changed since its `lastRun`, or a chunk of an archetype it matches was stamped, or its column stamped by a GPU kernel, which reaches the thread as row ticks. One a run named, from either thread, is posted after each sync point until it ran. Posting reads back what the @@ -615,12 +615,12 @@ One shared word, the layout directory header's fourth, kept while a joined threa each sync point that applies anything, before `materialize`, until its directory writes and logs' publishing at the end, a window that includes commits of staged spawns and any GPU readback, and around flushes between sync points. It turns odd by an atomic add, so later writes can't be seen before it, and even by a store, since only the world writes -it. Threads that read entity locations outside a task, observer posts and rebuilds, read while it's +it. Threads that read entity locations outside a task, posts of readonly systems and rebuilds, read while it's even and check it again after, retrying on a change; the closing check is an atomic add of 0, so every read before it completes first. A reader that finds it odd counts itself in the header's waiters word and waits on the epoch, which the world notifies when it turns even only while a reader waits, rather than spinning. The guarded words (`archetype`, `row`, `generation`) are read with `Atomics.load`. Values are never guarded by it: scheduled reads are ordered by -tiers, and observer posts read as of one sync point. +tiers, and posts of readonly systems read as of one sync point. ### Tails @@ -711,7 +711,7 @@ Memory isn't shared, so the world runs inline, and `share` and `join` reject. | A late tail and the world's work after its writer, together over a beat | the two overlap: `tail overlap` 2.53 ms a tick of a 2.5 ms tail and 2 ms of work, against 4.70 for `tail overlap waiting`, in node | | Commands and staged spawns from a worker kernel or joined thread | ring writes; the private spill and its message only past the ring's size | | `run` from a joined thread | one slot and notify; resolves through a word | -| An observer system on a joined thread, per sync point | a check of its filters, a post into its block and at most one notify per thread; one reading only struct resources posts copies of them and is never waited for: `joined observer resource` 4.3–5.0 µs a tick, in node; otherwise the next writer copies what it hasn't read, the next sync point waits only for a post in progress: `joined observer 10K` 0.41–0.51 ms | +| A readonly system on a joined thread, per sync point | a check of its filters, a post into its block and at most one notify per thread; one reading only struct resources posts copies of them and is never waited for: `joined observer resource` 4.3–5.0 µs a tick, in node; otherwise the next writer copies what it hasn't read, the next sync point waits only for a post in progress: `joined observer 10K` 0.41–0.51 ms | | Structural change touching archetypes with remote objects | one log entry per row moved, replayed once on that thread, `log replay` 21 ns a move in node | | A thread rebuilding after its log dropped entries | a pass over its archetypes' rows, `log rebuild 100K` 3.2 ns a row in node | | Despawning entities with remote finalizers | stop-list copy of declared reads, a column at a time; the world's thread doesn't wait: `despawn objects 10K` 83 ns a despawn in node | @@ -729,7 +729,7 @@ Memory isn't shared, so the world runs inline, and `share` and `join` reject. - **Joined threads:** per thread, its control words and 64 run slots, 16 KiB; per pair of joined threads one of which fills objects, a `MessageChannel`; per index a system was placed at there, a 64-byte line for its removed word, kept for the index's next system. -- **Observer systems on joined threads:** per system, a block of four lines, a record per index lookup, and, once a +- **Readonly systems on joined threads:** per system, a block of four lines, a record per index lookup, and, once a writer copied what it reads, a snapshot as a tail's; for one reading only struct resources, two blocks, and two snapshots of its resources when they're over 80 bytes. - **Tail buffers:** only once a fallback snapshot was taken: passing rows times their declared struct reads, kept for @@ -777,7 +777,7 @@ entity-table layout from it, and `publish.ts` posts only new components. WASM lo capacity, then one spawn into each timed as one sync point, followed by a kernel over them in the workers variant so workers take up the layout; repeated for a fixed number of doublings, keeping the fastest. - New, in node and chromium: `loop tick idle`: ticks with nothing changed, driven back to back by the `MessageChannel` - loop host with `always` set, counted by an observer system filtering on the clock; N = 10,000 ticks. + loop host with `always` set, counted by a readonly system filtering on the clock; N = 10,000 ticks. ### Built: phase 2, thread ports and node workers @@ -1002,17 +1002,17 @@ threads looking up through indexes of struct keys, and hash indexes built again - New: `index rebuild 1M workers` 6.7 ms against 27 on the world's thread alone, `grid neighbors 100K workers` 179 ns a row. -### Built: observer systems on joined threads +### Built: readonly systems on joined threads -A joined thread's `add` and `remove` take observer systems, its `run` one of its own, which run there after the world's -sync points, posted into blocks of their own, and observer systems placed on the joined thread filling their objects run +A joined thread's `add` and `remove` take readonly systems, its `run` one of its own, which run there after the world's +sync points, posted into blocks of their own, and readonly systems placed on the joined thread filling their objects run there the same way (`joined-observer.ts`, `thread-observer.ts`, `thread-directory.ts`). - Interface: the documentation of `World.add` and `World.remove`; no types. -- Contract: on a joined thread `add` and `remove` take only observer systems, whose keys need ids, and `run` runs one of +- Contract: on a joined thread `add` and `remove` take only readonly systems, whose keys need ids, and `run` runs one of its own there; each runs after the world's sync points, once for the newest when the thread fell behind, reading structs and the objects of the threads it fills as of one sync point; its failures are in a later run's report, a - run's in that run's; an observer system naming objects of a thread a joined thread claims is placed there, no longer + run's in that run's; a readonly system naming objects of a thread a joined thread claims is placed there, no longer refused. - Tests: on one thread through a `MessageChannel`, resources and rows with change filters, composites, lookups by entity with `changed`, keyless and keyed index lookups, runs with their failures, added ones' failures in a later report, diff --git a/ecs/pnpm-lock.yaml b/ecs/pnpm-lock.yaml index 478e2b4b..34470442 100644 --- a/ecs/pnpm-lock.yaml +++ b/ecs/pnpm-lock.yaml @@ -221,6 +221,25 @@ importers: specifier: ^5.0.5 version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4) + packages/ecs-persistence: + dependencies: + '@adobe/ecs': + specifier: workspace:* + version: link:../ecs + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.4) + packages/ecs-sample-browser: dependencies: '@adobe/ecs': diff --git a/ecs/pnpm-workspace.yaml b/ecs/pnpm-workspace.yaml index 8a945f88..70176922 100644 --- a/ecs/pnpm-workspace.yaml +++ b/ecs/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - 'packages/ecs' - 'packages/ecs-gpu' + - 'packages/ecs-persistence' - 'packages/ecs-gpu-views' - 'packages/ecs-particles-webgpu' - 'packages/ecs-todo-vanillajs' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc660985..c7503353 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,25 @@ importers: specifier: ^5.0.5 version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + ecs/packages/ecs-persistence: + dependencies: + '@adobe/ecs': + specifier: workspace:* + version: link:../ecs + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + ecs/packages/ecs-sample-browser: dependencies: '@adobe/ecs': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e646dbe..bddd261d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - 'packages/data-ai' - 'ecs/packages/ecs' - 'ecs/packages/ecs-gpu' + - 'ecs/packages/ecs-persistence' - 'ecs/packages/ecs-gpu-views' - 'ecs/packages/ecs-particles-webgpu' - 'ecs/packages/ecs-todo-vanillajs'